diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000000..2408084cf9 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000000..2a4cd2cb93 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 388b44f032..94a4c545fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,152 +1,92 @@ name: CI +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. Nightly fuzzing is in fuzz.yml. on: push: branches: [main] pull_request: + # `labeled` so that adding `full-ci` to an open PR starts a run with Windows in it. + types: [opened, synchronize, reopened, labeled] workflow_dispatch: - schedule: - - cron: "17 3 * * *" # nightly fuzzing -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 +# The token is only used to check out the code: no job writes to the repository. +permissions: + contents: read +# A newer push to a PR branch cancels its older run; every push to main runs to the end. Adding +# any label but `full-ci` gets a group of its own, so it neither runs the gate nor cancels a run. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.event_name == 'pull_request' && (github.event.action != 'labeled' || github.event.label.name == 'full-ci') && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - test: - name: test (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - env: - CARGO_PROFILE_DEV_DEBUG: line-tables-only - CARGO_PROFILE_TEST_DEBUG: line-tables-only - strategy: - fail-fast: false - matrix: - # No macOS on PRs, main or nightly: the org's 5 macOS slots are kept for release builds, - # and release.yml builds macOS (a failing release is fixed and rebuilt). - os: [ubuntu-latest, windows-latest] + # The shared gate doesn't check where git dependencies come from; release.yml runs the same job. + git-sources: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The optional craft-fonts build input (Japanese fonts), so the Japanese font tests run on - # Linux; Windows tests the build without it. See craftrules standards/fonts.md. - - name: Check out craft-fonts (Linux) - if: runner.os == 'Linux' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - repository: storytold/craft-fonts - ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 - path: craft-fonts - - - name: Configure craft-fonts (Linux) - if: runner.os == 'Linux' - run: | - echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV" - echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV" - - - name: Install Linux system dependencies (egui/winit/rfd) and the oracles - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ - libgl1-mesa-dev libgtk-3-dev \ - qpdf poppler-utils - - # The independent oracles (crates/oracle) are only worth having when they run. With this - # set, a missing one fails the job instead of printing a skip notice that reads as a pass. - # Only the tools the job actually uses are installed above. - - name: Require the oracles (Linux) - if: runner.os == 'Linux' - run: echo "PDFCRAFT_ORACLES_REQUIRED=1" >> "$GITHUB_ENV" - - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - toolchain: stable - components: clippy, rustfmt - - - name: Require Windows executable resources - if: runner.os == 'Windows' - run: Add-Content -Path $env:GITHUB_ENV -Value 'PDFCRAFT_REQUIRE_WINRES=1' + persist-credentials: false + - name: Windows font notice regressions shell: pwsh + run: ./packaging/windows/test-font-notices.ps1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - - - name: Format - if: runner.os == 'Linux' - run: cargo fmt --all -- --check - - - name: Clippy - run: cargo clippy --workspace --all-targets -- -D warnings - - - name: Test - if: runner.os != 'Linux' - run: cargo test --workspace - - - name: Test (with craft-fonts) - if: runner.os == 'Linux' - run: cargo test --workspace - - - name: Layering - if: runner.os == 'Linux' - run: cargo xtask layers - - - name: Assets (licences and attribution) - if: runner.os == 'Linux' - run: cargo xtask assets - - - name: CLI without the MCP server still builds - if: runner.os == 'Linux' - run: cargo check -p pdfcraft-cli --no-default-features - - wasm: - name: wasm32 check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - toolchain: stable - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: wasm32 - - name: Check library crates for wasm32 - run: cargo xtask wasm - - deny: - name: dependency licences and advisories - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: EmbarkStudios/cargo-deny-action@v2 - with: - command: check - - fuzz: - name: fuzz (mutation, 15 min) - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - toolchain: stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: fuzz - - name: Fetch the pdf.js corpus (seeds) - run: cargo xtask corpus - - name: Fuzz - run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }} - - name: Upload findings - if: always() - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: fuzz-findings - path: fuzz-out/findings - if-no-files-found: ignore + gate: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Windows on main, manual runs and pull requests labelled `full-ci`. No macOS on PRs or main: + # the org's 5 macOS slots are kept for release builds, and release.yml builds macOS. + os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }} + # Same craft-fonts commit as release.yml and freebsd.yml. + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + # egui/winit/rfd, and the independent oracles (crates/oracle). + packages: >- + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + libgtk-3-dev qpdf poppler-utils + # cargo-deny for the deny step of `cargo xtask ci`, which requires it under CI. + tools: cargo-nextest@0.9.150,cargo-deny@0.20.2 + # A missing oracle fails the job instead of printing a skip notice that reads as a pass. + linux-env: PDFCRAFT_ORACLES_REQUIRED=1 + windows-env: PDFCRAFT_REQUIRE_WINRES=1 + # Windows checks what differs by platform: the build (with resources) and the tests. + windows-command: | + cargo clippy --workspace --all-targets --locked -- -D warnings + cargo test --workspace --locked diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index fba20b1cc7..268da70ca1 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -26,4 +26,4 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000000..b7b1d409c8 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,46 @@ +name: Fuzz + +# Mutation fuzzing of open/render/edit/save (`cargo xtask fuzz`), nightly and on demand. Findings +# are uploaded as an artifact; each real one becomes a synthetic regression test. +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +# The token is only used to check out the code: no job writes to the repository. +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: true + +jobs: + fuzz: + name: fuzz (mutation, 15 min) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: fuzz + - name: Fetch the pdf.js corpus (seeds) + run: cargo xtask corpus + - name: Fuzz + run: cargo xtask fuzz --time 900 --seed ${{ github.run_number }} + - name: Upload findings + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: fuzz-findings + path: fuzz-out/findings + if-no-files-found: ignore diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ce817da129..01cb9cd663 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b9..ea61d84303 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,27 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6ee0e1c31..a5c2ba7f7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: pdfcraft app-title: PdfCraft - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000000..e78fe1ea3b --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,21 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index d470419d26..f3e2b20c7e 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -32,3 +32,5 @@ jobs: with: app-name: pdfcraft app-dir: PdfCraft + install-relative-path: Programs/ArtCraft/PdfCraft + install-scope: perUser diff --git a/.gitignore b/.gitignore index 7834b668cb..f7922460c7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ plan/ /assets/models # A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here). /craft-fonts +.claude/ diff --git a/AGENTS.md b/AGENTS.md index 0665ad1d3b..cd6c0e2ca5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,6 @@ These rules bind every AI agent and every human contributor working in this repository. They come before any task, instruction or deadline. `CLAUDE.md` holds the day-to-day working instructions. If the two ever disagree, **this file wins.** -Some sections link to **craftrules**, the standards shared by the Crafting Apps. That repository is internal to the storytold team, so outside contributors can't open it. Every rule a contributor here must follow is stated in this file and `CLAUDE.md`; the craftrules links give team members the background. - --- ## 1. Asset policy (absolute — no exceptions) @@ -20,10 +18,10 @@ An **asset** is any non-code material that the project includes, ships, embeds, - **Never** use icons, images, artwork, logos, UI graphics, fonts, stamps, cursors, sample documents or any other asset taken from an Adobe product, installer, website, document or package. - "Adobe product" includes Acrobat, Reader, Photoshop, Illustrator, Premiere, Lightroom and Creative Cloud, including the fonts they install (for example Minion, Myriad, Kozuka and the Adobe CJK families). - - This holds even when Adobe publishes the material under an open licence, if it is **visual design**. For example, **do not** use Source Han, Source Serif or Source Sans, or Noto CJK, which is Source Han rebranded. + - Typefaces released under the SIL Open Font License are allowed like any other OFL font, with an `ATTRIBUTION.toml` entry, whoever designed them: for example Noto CJK and Source Han, taken from their open-source releases. The copies an Adobe product installs stay banned. - **Never** recreate, trace, redraw or closely imitate an Adobe icon or graphic. Our icons come from Lucide, and our visual design is our own. - Screenshots of Adobe software exist only as private, local observation notes under `plan/acrobat/`, which is gitignored. They must **never** be committed, published, linked, shown in docs, used as a design source to trace, or placed anywhere else in the repository or its outputs. -- The only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed: +- Apart from those OFL typefaces, the only Adobe-authored material allowed is **non-visual technical data** that is openly licensed and required for PDF compatibility. Each item needs its own entry in `ATTRIBUTION.toml` with `adobe_data = true`, and the list below is closed: - The CMap resources (BSD-3-Clause), bundled inside the `hayro-cmap` dependency. - The standard-14 font metrics and encoding tables in the PDF specification. They are data, not typefaces. They are embedded as code in hayro, and as our own generated table in `crates/fonts/src/std14_metrics.rs` (approved by the owner, 2026-10-08; that file holds the data alone, so the code reading it can change without re-attesting the table), which is what generated appearances measure with and what survives hayro being retired. @@ -54,23 +52,25 @@ Anything else is forbidden, including assets whose licence you cannot prove from - path or URL, title, author; - source (URL, with version or commit); - SPDX licence, and the licence file in the repo; - - SHA-256; + - SHA-256, except for translation catalogs (see below); - how it is used. +- **Translation catalogs** (`crates/ui-egui/src/i18n/*.tsv`, `kind = "translation"`) are project files under the project licence. Their entries carry no `sha256`: git history records every change and who made it. Every other kind (fonts, images, data) is hashed. - **`ATTRIBUTION.md`** is generated from it (`cargo xtask assets --write`). Never edit it by hand. - **`NOTICE`** keeps the licence notices that the licences require. - **`cargo xtask assets`** runs in `cargo xtask ci`. It fails if: - an asset file in the repository has no entry; - - an entry's hash does not match; + - an entry's hash is missing or does not match (translation catalogs carry none); - a licence is not on the allowlist; - a licence file is missing; - - an image, icon or font names Adobe as author or source. + - an image, icon or font names Adobe as author or source, except a font licensed under OFL-1.1 alone. - **`cargo xtask demo-pdf`** downloads its fonts only from `ATTRIBUTION.toml` entries, pinned by SHA-256. Afterwards it **fails if the PDF embeds any font not in its allowlist**, which catches the browser silently falling back to a system font. ### 1.4 Fonts live in craft-fonts -- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) Team members: the shared rules are in [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md) (internal). +- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) - craft-fonts is an **optional build input**, never a Cargo dependency: `CRAFT_FONTS_DIR= cargo build` makes `crates/fonts/build.rs` embed its fonts as `pdfcraft_fonts::CRAFT_FONTS`. Without it PdfCraft builds, tests and runs, but has no Japanese face (Japanese UI text shows replacement boxes, and writing Japanese into a PDF returns a clear error). Release builds always set it (`.github/workflows/release.yml`), and `ATTRIBUTION.toml` lists it as a `[[build_input]]`. - The desktop app may read one font **already installed on the user's machine** at runtime, as the last interface fallback for characters no embedded face has (`crates/ui-egui/src/system_fonts.rs`). That font is never embedded, shipped, committed or copied, and never appears in published screenshots (`PDFCRAFT_SYSTEM_FONTS=0`, which `cargo xtask screenshots` sets). It is not a way around §1.2: nothing from it may enter a PDF, a package or the repository. +- The page viewer may also draw a CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF does **not embed** with one face already installed on the machine (`crates/render/src/system_font.rs`), **on screen only**. `RenderConfig::system_fonts` is off by default and is enabled only for on-screen viewer and preview rasters (`RenderConfig::on_screen`, used by the engine viewer, Combine thumbnails and accessibility figure previews), so exports, printing, OCR and the automation tools never use it. Like the interface font it is read at runtime and never embedded, shipped, committed or copied; Noto CJK and Source Han are never candidates (§1.1), nor is any Adobe family; and `PDFCRAFT_SYSTEM_FONTS=0` turns it off. It is not a way around §1.2 either: nothing drawn with it may be saved or printed. ### 1.5 Procedure for adding or changing an asset @@ -91,7 +91,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi - Behaviour comes only from public specifications (ISO 32000-2, the Arlington model), public documentation and black-box observation. - Never copy GPL/AGPL code. Copyleft tools run only as external oracle processes. - Observe Acrobat only with synthetic fixtures. Never capture personal data, account information or recent files. -- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md) (internal). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. +- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. ## 3. Agent control (automation and MCP) @@ -104,7 +104,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi ## 4. Never crash -PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository; the shared standard for every Crafting App, [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md), is internal to the storytold team. +PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository. - **Fail with `Result`.** Non-test code returns errors through the crate's error type and `?`. Where readers are lenient by design, fall back gracefully and record the repair (see Fidelity in `CLAUDE.md`). - **No panicking shortcuts.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!` outside tests, unless the call is provably infallible (e.g. writing to a `Vec`), with a comment saying why. `assert!` only for internal invariants that input cannot reach. @@ -123,17 +123,19 @@ Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to r ## Contributor credits (About window) - About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` - (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for themselves). See `docs/contributors.md`. - **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5cfee8d55a..8da4b0a200 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -2,32 +2,40 @@ -Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). +Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (272) +## In this repository (289) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| -| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -223,6 +231,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) | | `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) | | `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. | +| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 | | `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | @@ -231,22 +240,22 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `docs/brand/artcraft-mark-black.svg` | ArtCraft brand: artcraft-mark-black | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-mark.png` | ArtCraft brand: artcraft-mark | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | | `docs/brand/artcraft-mark.svg` | ArtCraft brand: artcraft-mark | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. | -| `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) | -| `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md | -| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) | -| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon | -| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) | +| `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) | +| `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md | +| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) | +| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon | +| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) | | `assets/app-icon/pdfcraft-document.svg` | PdfCraft PDF document icon (master vector) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images | | `assets/app-icon/pdfcraft-document.ico` | PdfCraft PDF document icon (Windows .ico, 16-256 px) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations | -| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 48 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 64 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 128 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 256 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs) | -| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 512 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | -| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | PdfCraft app icon (Linux scalable) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 48 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 64 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 128 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 256 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs) | +| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 512 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | +| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | PdfCraft app icon (Linux scalable) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) | | `docs/images/pdfcraft-bookmarks.png` | PdfCraft screenshot: Bookmarks | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: navigation. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | | `docs/images/pdfcraft-dark.png` | PdfCraft screenshot: Dark theme | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: themes. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | | `docs/images/pdfcraft-find.png` | PdfCraft screenshot: Find text | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: search. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | @@ -267,29 +276,37 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs) | +| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) | | `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make | +| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make | | `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) | | `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py | | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | -| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format | +| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | +| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs | +| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs | ## Compiled in through dependencies (6) | Crate | File | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---|---| -| `epaint_default_fonts` 0.36.2 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui fallback fonts (eframe feature default_fonts) | -| `hayro-cmap` 0.1.0 | `assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) (non-visual data, AGENTS.md §1.1) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 | +| `epaint_default_fonts` 0.37.0 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | | `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 | ## Downloaded at build time (22) @@ -318,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model | `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) | | `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | | `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | -| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) | -| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) | +| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) | +| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) | ## Optional build inputs (1) @@ -327,4 +344,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui | Input | Option | Title | Author | Licence | Source | Attribution | Used for | |---|---|---|---|---|---|---|---| -| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1) | +| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index db3dd6124f..84f71f904e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2,17 +2,18 @@ path = "crates/ui-egui/src/i18n/el.tsv" title = "Greek interface translation catalog" author = "Camthalion77 and contributors" -source = "Contributor-original: clean-room translations of PdfCraft English UI labels" +source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919" +usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). # # [[asset]] a file in this repository (committed or vendored); `sha256` must match the file. +# Translation catalogs (kind = "translation", crates/ui-egui/src/i18n/*.tsv) are project files +# under the project licence and carry no `sha256`: git history records their provenance. # [[bundled]] a file compiled into PdfCraft by a Cargo dependency (its crate version must match Cargo.lock). # [[fetched]] downloaded at build time by xtask (never committed), pinned by `sha256`. # [[build_input]] an optional build input outside this repository (e.g. craft-fonts via CRAFT_FONTS_DIR); @@ -25,211 +26,270 @@ sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919" path = "crates/ui-egui/src/i18n/zh-hans.tsv" title = "Simplified Chinese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4" +usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" title = "Traditional Chinese (Taiwan) interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e" +usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" title = "Czech interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665" +usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" title = "Brazilian Portuguese interface translation catalog" author = "André Cardoso (andrecards), PdfCraft contributors" -source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors" +source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95" +usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" title = "Spanish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8" +usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" title = "French interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714" +usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" title = "German interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d" +usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs" + +[[asset]] +path = "crates/ui-egui/src/i18n/lo.tsv" +title = "Lao interface translation catalog" +author = "@bounkirdni-2025" +source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" title = "Telugu interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29" +usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" title = "Italian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7" +usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" title = "Polish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa" +usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" title = "Russian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa" +usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" title = "Hungarian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c" +usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" title = "Bulgarian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b" +usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" title = "Kazakh interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017" +usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" title = "Japanese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba" +usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" title = "Ukrainian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2" +usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" title = "Arabic interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe" +usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs" + +[[asset]] +path = "crates/ui-egui/src/i18n/ckb.tsv" +title = "Central Kurdish (Sorani) interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs" + +[[asset]] +path = "crates/ui-egui/src/i18n/fa.tsv" +title = "Persian (Farsi) interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" title = "Turkish interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927" +usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" title = "Lithuanian interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a" +usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" +[[asset]] +path = "crates/ui-egui/src/i18n/hi.tsv" +title = "Hindi interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs" +[[asset]] +path = "crates/ui-egui/src/i18n/id.tsv" +title = "Indonesian interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs" +[[asset]] +path = "crates/ui-egui/src/i18n/ko.tsv" +title = "Korean interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs" + +[[asset]] +path = "crates/ui-egui/src/i18n/ro.tsv" +title = "Romanian interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" + + +[[asset]] +path = "crates/ui-egui/src/i18n/fi.tsv" +title = "Finnish interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "assets/icons/accessibility.svg" @@ -2379,7 +2439,7 @@ sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/Ubuntu-Light.ttf" title = "Ubuntu Light" author = "Canonical Ltd. / Dalton Maag" @@ -2391,7 +2451,7 @@ sha256 = "80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/Hack-Regular.ttf" title = "Hack Regular" author = "Source Foundry Authors (derived from Bitstream Vera / DejaVu)" @@ -2403,36 +2463,47 @@ sha256 = "15f55cc0c85a2988d2b4b3a8cdb5d77fdfbaf319e1bb5309d725db9818fb7125" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/NotoEmoji-Regular.ttf" title = "Noto Emoji (monochrome)" author = "Google Inc." source = "https://github.com/googlefonts/noto-emoji" licence = "OFL-1.1" kind = "font" -usage = "egui fallback fonts (eframe feature default_fonts)" +usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)" sha256 = "415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/emoji-icon-font.ttf" title = "emoji-icon-font" author = "John Slegers" source = "https://github.com/jslegers/emoji-icon-font" licence = "MIT" kind = "font" -usage = "egui fallback fonts (eframe feature default_fonts)" +usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)" sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca" [[bundled]] -crate = "hayro-cmap" -version = "0.1.0" -path = "assets/cmaps.brotli" +crate = "epaint_default_fonts" +version = "0.37.0" +path = "fonts/egui-icons.ttf" +title = "egui icons (monochrome emoji-icon-font subset)" +author = "John Slegers" +source = "https://github.com/jslegers/emoji-icon-font" +licence = "MIT" +kind = "font" +usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE." +sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8" + +[[asset]] +path = "vendor/hayro-cmap/assets/cmaps.brotli" title = "Adobe CMap resources (predefined CJK CMaps)" author = "Adobe" -source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap)" +source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/)" licence = "BSD-3-Clause" +licence_file = "vendor/hayro-cmap/assets/LICENSE.txt" kind = "data" adobe_data = true usage = "Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1" @@ -2802,57 +2873,57 @@ sha256 = "f87af81b6fa411da8820eed2bfdcc54c0ee9cd453b4e868c64f56c69db142d0e" [[asset]] path = "assets/app-icon/pdfcraft.svg" title = "PdfCraft app icon (master vector)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Master artwork; every other app-icon file is rendered from it (packaging/icons.sh)" -sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d" +sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da" [[asset]] path = "assets/app-icon/pdfcraft-small.svg" title = "PdfCraft app icon (light vector)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "README header and assets/app-icon/README.md" -sha256 = "b036304c11ef5dcad36636d06ca0bf331b51e47650d25d3c916a0e1eabf544c7" +sha256 = "b524df1a583f8034a5a23836955e4fb423e0149429d695e80d95e5056f408678" [[asset]] path = "assets/app-icon/pdfcraft-1024.png" title = "PdfCraft app icon, 1024 px on Apple's icon grid" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs)" -sha256 = "da577d3f21fbcf5c739067787a9e683be2b064d76813b5d5fec9074a7a106538" +sha256 = "68ac6730b8990d07503351d32eacea81e9ec171a7a6f09daf52d6555d8245c8a" [[asset]] path = "assets/app-icon/pdfcraft.icns" title = "PdfCraft app icon (macOS .icns)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "macOS app bundle icon" -sha256 = "2425148222873de241d267aba667a8b234ecdd0149f2ca8bb6e79443b743b1e8" +sha256 = "fca7e7a0e34cc5c5ecae5bca97ee896aac68c4a34b088bc3a0b3271484f23974" [[asset]] path = "assets/app-icon/pdfcraft.ico" title = "PdfCraft app icon (Windows .ico, 16-256 px)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab)" -sha256 = "56daaaae9ae5389794909570885809adf5e1d9183bef2d9669ed2f9bba3ed6f5" +sha256 = "709271d66e07710a3f785ea21b0c85c5228eaf8bebe5058bc0f71f9ef20749eb" [[asset]] path = "assets/app-icon/pdfcraft-document.svg" @@ -2863,7 +2934,7 @@ licence = "ISC AND (MIT OR Apache-2.0)" licence_file = "assets/app-icon/LICENSE-document.txt" kind = "icon" usage = "Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images" -sha256 = "8da8278c2b89362be0a960879d453ff879d7544d6d58e47d5389574e8dfff77b" +sha256 = "c129f1b538ce16e3c3fa0d89d3301038c51249ba5d61bfd98c46e69cfdf5c316" [[asset]] path = "assets/app-icon/pdfcraft-document.ico" @@ -2874,106 +2945,106 @@ licence = "ISC AND (MIT OR Apache-2.0)" licence_file = "assets/app-icon/LICENSE-document.txt" kind = "icon" usage = "Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations" -sha256 = "ba3f9128b368fc6d17d376cf62dbdbc22455451c5c8c4779210d7a2a7409b9e6" +sha256 = "910be5e0ecdc94ce00368077ca1bef449bd5d62a75516feeb37884296c6fac4d" [[asset]] path = "assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 16 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "9b77095cbe75dcba6b74a1fdcd4064812850efaf7a12edebefc9ded5eabb49da" +sha256 = "660d2aace45ec1a28bc7e3708eab71ffee5cb1203f57a6256f7bd7c727193567" [[asset]] path = "assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 24 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "a4cd466687f6c5cb59eef3fc564bdba1c8f0d9e324754eeebab82f18823efae7" +sha256 = "1989441bdcc8d1df0d73cfe7eb836e9211abaa0fd7fd6ec7e4634bfcd472995d" [[asset]] path = "assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 32 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "e22279013a5ed5c02571e1e4e9bc4cef9aa0847fcb733a285b2916fabd16bf78" +sha256 = "7fd1ac555f7f6b13ef3fba2696dfb86900cfcb7f7b669aece38590bf2306cf2e" [[asset]] path = "assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 48 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "55daf2d5575f6c4e4c4975c9f1c74231178f1801963a8b19f68dfa9bc50cb22f" +sha256 = "784ef3bd9f9e479cb2df22a0aee259a441a9c358390e646639c3a0ed79d41288" [[asset]] path = "assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 64 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "2975c200f341e247b5fa18f568fb27d390e3fa01e9b2ad37acb48d0e1cc8a124" +sha256 = "deb1a9f849549d3f0652fb6d3c30b6eb6ea54498eaea8c1007d901bbafe7067c" [[asset]] path = "assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 128 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "6ef927ca252b40bc9b5d8843d84137334362f8aa1acbb1bf8c15a02affd25b2b" +sha256 = "1933bbeaea290497facd328e917dafcc433d0c0caa9f1e207aa45ac1e430898c" [[asset]] path = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 256 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs)" -sha256 = "cde7ee8c5ee38f9fc40a429fd9e6d99a035bcd321701fce331bb13bac126614d" +sha256 = "47094c09fb77eb35d465eee46445626a4d78c7ef50629cc7d04a602de3e144e9" [[asset]] path = "assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 512 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "5ede733e93b96f4eb8d94299232cf2f9605fa19b0d913f95fe9828c316a2d066" +sha256 = "dea8010a8b41011aba054d3bfb8b790da57bc958c60869f508e1bbd288d3f280" [[asset]] path = "assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg" title = "PdfCraft app icon (Linux scalable)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d" +sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da" [[asset]] path = "docs/images/pdfcraft-bookmarks.png" @@ -3203,7 +3274,7 @@ source = "Contributor-original: hand-written PDF objects, no third-party content licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "document" -usage = "Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs)" +usage = "Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs)" sha256 = "c6f8b656e225101129aa36bd5467adc8dba16730915f19caca17f6a546d0b040" [[asset]] @@ -3236,7 +3307,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "document" -usage = "Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make" +usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make" sha256 = "8b91c3b4e39c4a31717a0dd0685bd7d0468671df32aee5b9818d44dc72c2bc3c" [[asset]] @@ -3270,7 +3341,7 @@ licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "image" usage = "Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py" -sha256 = "e8b9ea9d5e64b7b741c977543c1693825e77d5c4c579eeb674bf5dcd3764cd8a" +sha256 = "182663e4d1c4fabbb7ab2e1f0d4e991a8e0859b4a8dee6934d3e25d77ea020af" [[asset]] @@ -3282,44 +3353,44 @@ licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "image" usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile" -sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c" +sha256 = "bedf1b23707b9e34157440e83f65464ff314fa9f6b1d39c7a8930d1645c2f8be" [[fetched]] -file = "text-detection.rten" -url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten" +file = "text-detection.onnx" +url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx" title = "Ocrs text detection model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)" -source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)" +source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: finds words on page images (cargo xtask models)" -sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca" +sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe" [[fetched]] -file = "text-recognition.rten" -url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten" +file = "text-recognition.onnx" +url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx" title = "Ocrs text recognition model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)" -source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)" +source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: reads the words it finds (cargo xtask models)" -sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e" +sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5" [[build_input]] name = "craft-fonts" option = "CRAFT_FONTS_DIR" -title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic" -author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic)" -source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)" +title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic" +author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic)" +source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9)" licence = "OFL-1.1" attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md" kind = "font" -usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1)" +usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1" [[asset]] path = "crates/sign/tests/data/x509-rsa-sha1.pdf" @@ -3329,7 +3400,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "document" -usage = "Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format" +usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format" sha256 = "b77bfb21166f4bd043597e45a1ea28b9d9f4ba6df0142b3a0fc8b7f8e0fc111b" [[asset]] @@ -3347,23 +3418,21 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45" +usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" title = "Slovene interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" -usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92" +usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs" [[asset]] @@ -3376,3 +3445,87 @@ licence_file = "vendor/vello_common/LICENSE-MIT" kind = "image" usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file" sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" + +[[asset]] +path = "crates/ui-egui/src/i18n/nb.tsv" +title = "Norwegian Bokmål interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs" + +[[asset]] +path = "assets/icons/superscript.svg" +title = "Lucide icon \"superscript\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0" + +[[asset]] +path = "assets/icons/subscript.svg" +title = "Lucide icon \"subscript\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4" + +[[asset]] +path = "assets/icons/pilcrow.svg" +title = "Lucide icon \"pilcrow\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8" + +[[asset]] +path = "assets/icons/move-horizontal.svg" +title = "Lucide icon \"move-horizontal\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" + +[[asset]] +path = "crates/ui-egui/src/i18n/sq.tsv" +title = "Albanian interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs" + +[[asset]] +path = "crates/ui-egui/src/i18n/th.tsv" +title = "Thai interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs" + +[[asset]] +path = "crates/ui-egui/src/i18n/ca.tsv" +title = "Catalan interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs" diff --git a/CLAUDE.md b/CLAUDE.md index 12779b1e35..5e9328f8d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado ## Start every session here 1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` is the one-page summary (stage, numbers, progress log) and `docs/roadmap.md` the milestone table; at the end of every session update the milestone row and add a line to the ROADMAP.md progress log. - - Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs follow craftrules `standards/progress-docs.md`: status line, revision history). + - Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs carry a status line and a revision history). - "Shipped" in `parity/` means "exists and tested", not "as good as Acrobat". Don't mark a feature shipped on a generic test, and say in its notes what is still missing. 2. Read that task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README of the crate you're touching. 3. Follow the **autonomous operation protocol** in `plan/execution-plan.md` §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's. @@ -13,7 +13,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado ## Non-negotiables - **Assets: read `AGENTS.md` §1 before adding or showing any icon, image, font or document.** No assets from Adobe products, ever. Only openly licensed or contributor-original assets are allowed, each with an entry in `ATTRIBUTION.toml`. `cargo xtask assets` enforces this. `AGENTS.md` overrides this file. -- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty. +- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty. - **Clean-room.** - Never read, disassemble or copy anything inside the Acrobat bundle (names and listings only). **Never open `Contents/Resources/JavaScripts/`.** - Behaviour comes from public docs, specs (ISO 32000-2, the Arlington model) and black-box observation (`plan/acrobat/`). @@ -21,6 +21,9 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - See `plan/README.md` §Clean-room and `plan/adr/0001`. - **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data. - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. +- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. +- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. @@ -29,15 +32,18 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - Cap allocations sized by input, and bound recursion with depth limits or seen-sets. - Handle lock poisoning. - No `unsafe` (`unsafe_code = "forbid"`). - - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`). + - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4. - **Rust only** in the product and build (`xtask`). No handwritten JS/TS. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening. - Tests mirror the zooms: property → unit → crate integration → end-to-end. - **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path. - **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch. - **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge. - **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0). + - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free. +- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search ""` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`). - **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green. - **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once. @@ -52,3 +58,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado ## Current bootstrap debt (tracked in STATUS.md) - `pdfcraft-render` renders through the `hayro` crate directly and inspects documents through `lopdf`. Both get replaced by `cos` / `model` / the DisplayList device (M1–M2, ADR-0004). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 53e0f097d5..fd186d995c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "enumn", "serde", @@ -15,57 +15,51 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf 0.13.1", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -73,9 +67,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -91,13 +85,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -105,11 +99,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -220,21 +215,22 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", + "wl-clipboard-rs", "x11rb", ] @@ -447,9 +443,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -458,9 +454,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -493,15 +489,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -512,10 +499,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -523,6 +513,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -580,7 +580,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -649,7 +649,7 @@ dependencies = [ "oneshot", "pastey", "portable-atomic", - "rand 0.10.3", + "rand", "regress", "rustc-hash 2.1.3", "ryu-js", @@ -740,6 +740,30 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bp256" version = "0.14.0" @@ -999,6 +1023,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1145,15 +1178,68 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] + +[[package]] +name = "craft-keymap" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "egui", + "serde", + "serde_json", +] + +[[package]] +name = "craft-menu" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "craft-keymap", + "craft-quit", + "egui", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ - "miniz_oxide 0.8.9", - "roxmltree", + "craft-winprint-gdi", + "log", + "miniz_oxide 0.9.1", + "roxmltree 0.21.2", "tempfile", "winprint", "winsafe", @@ -1162,22 +1248,67 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "dispatch2", "egui", - "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "image", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", ] [[package]] @@ -1243,9 +1374,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0" dependencies = [ "cpubits", "ctutils", @@ -1291,9 +1422,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", "subtle", @@ -1439,7 +1570,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1535,9 +1666,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1561,16 +1691,15 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "sha2 0.11.0", + "sha2 0.11.1", "subtle", "zeroize", ] [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1585,7 +1714,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1594,6 +1723,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1606,9 +1736,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1628,7 +1757,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1646,32 +1776,35 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "serde", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1684,9 +1817,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1698,9 +1830,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "eframe", "egui", @@ -1742,9 +1873,8 @@ dependencies = [ [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1772,22 +1902,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1824,9 +1954,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1843,16 +1972,17 @@ dependencies = [ "serde", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu 0.2.0", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equator" @@ -1887,7 +2017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1990,6 +2120,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2057,7 +2198,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2068,6 +2209,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2099,7 +2246,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2318,7 +2465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -2330,18 +2477,18 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", "skrifa 0.44.0", "smallvec", - "vello_common 0.1.0", + "vello_common 0.2.0", ] [[package]] @@ -2370,7 +2517,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2505,6 +2652,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2513,7 +2669,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2524,7 +2680,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2549,8 +2705,6 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" dependencies = [ "brotli", "hayro-postscript", @@ -2579,6 +2733,7 @@ version = "0.3.0" dependencies = [ "fearless_simd", "hayro-ccitt", + "image", ] [[package]] @@ -2593,8 +2748,6 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" @@ -2788,6 +2941,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2796,11 +2966,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2813,6 +3004,7 @@ dependencies = [ "icu_provider", "smallvec", "utf16_iter", + "utf8_iter", "write16", "zerovec", ] @@ -2823,6 +3015,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2852,6 +3070,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2859,6 +3080,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -3069,12 +3314,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3157,6 +3402,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3197,10 +3445,10 @@ dependencies = [ "log", "md-5", "nom", - "rand 0.10.3", + "rand", "rangemap", "rayon", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", "weezl 0.2.1", @@ -3302,13 +3550,13 @@ dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3385,6 +3633,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3495,9 +3755,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3526,7 +3786,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3576,7 +3836,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3587,7 +3847,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3643,7 +3903,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3654,7 +3914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3690,7 +3950,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3714,7 +3974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3759,7 +4019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3850,6 +4110,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.14.0" @@ -3860,7 +4130,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3874,7 +4144,7 @@ dependencies = [ "fiat-crypto", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3888,7 +4158,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3938,9 +4208,10 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.9.0" +version = "0.16.1" dependencies = [ - "craft-quit", + "chrono", + "craft-update", "eframe", "egui", "fmv-macos-events", @@ -3949,10 +4220,8 @@ dependencies = [ "pdfcraft-ui-egui", "pollster 1.0.1", "raw-window-handle", - "rustls-native-certs", "serde_json", "uds_windows", - "ureq", "wayland-client", "wayland-protocols", "windows", @@ -3963,7 +4232,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.9.0" +version = "0.16.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3975,7 +4244,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.9.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3986,7 +4255,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.9.0" +version = "0.16.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3997,9 +4266,9 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.9.0" +version = "0.16.1" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "image", "pdfcraft-cos", @@ -4011,16 +4280,17 @@ dependencies = [ "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", + "rustix 1.1.5", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", ] [[package]] name = "pdfcraft-cli" -version = "0.9.0" +version = "0.16.1" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "pdfcraft-audit", "pdfcraft-automation", "pdfcraft-cos", @@ -4028,6 +4298,7 @@ dependencies = [ "pdfcraft-platform", "pdfcraft-preflight", "pdfcraft-render", + "png 0.18.1", "rustix 1.1.5", "serde_json", "winresource", @@ -4035,19 +4306,21 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.9.0" +version = "0.16.1" [[package]] name = "pdfcraft-content" -version = "0.9.0" +version = "0.16.1" dependencies = [ + "hayro-syntax", "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.9.0" +version = "0.16.1" dependencies = [ + "chrono", "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", @@ -4060,14 +4333,18 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.9.0" +version = "0.16.1" dependencies = [ "encoding_rs", + "hayro-ccitt", + "hayro-jbig2", "hayro-syntax", "image", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-xfa", "png 0.18.1", "thiserror 2.0.21", "tiff", @@ -4076,19 +4353,19 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.9.0" +version = "0.16.1" dependencies = [ "aes", "md-5", "proptest", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-edit" -version = "0.9.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4097,16 +4374,18 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "sha2 0.11.1", "thiserror 2.0.21", "unicode-bidi", ] [[package]] name = "pdfcraft-engine" -version = "0.9.0" +version = "0.16.1" dependencies = [ "chrono", "craft-docpath", + "getrandom 0.4.3", "icu_properties", "idna", "image", @@ -4132,6 +4411,7 @@ dependencies = [ "pdfcraft-redact", "pdfcraft-render", "pdfcraft-sign", + "pdfcraft-testkit", "pdfcraft-xfa", "pdfcraft-xfdf", "png 0.18.1", @@ -4143,16 +4423,19 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.9.0" +version = "0.16.1" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.9.0" +version = "0.16.1" dependencies = [ + "fax", "flate2", + "hayro-ccitt", + "hayro-jbig2", "proptest", "thiserror 2.0.21", "weezl 0.2.1", @@ -4160,7 +4443,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.9.0" +version = "0.16.1" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4170,30 +4453,32 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.9.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "proptest", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-geom" -version = "0.9.0" +version = "0.16.1" [[package]] name = "pdfcraft-js" -version = "0.9.0" +version = "0.16.1" dependencies = [ "boa_engine", + "chrono", ] [[package]] name = "pdfcraft-measure" -version = "0.9.0" +version = "0.16.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4206,41 +4491,45 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.9.0" +version = "0.16.1" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.9.0" +version = "0.16.1" dependencies = [ "ocrs", "pdfcraft-fonts", "rten", + "tempfile", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-optimize" -version = "0.9.0" +version = "0.16.1" dependencies = [ "image", "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-model", "pdfcraft-render", + "proptest", "thiserror 2.0.21", + "zune-jpeg", ] [[package]] name = "pdfcraft-oracle" -version = "0.9.0" +version = "0.16.1" [[package]] name = "pdfcraft-organize" -version = "0.9.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4251,7 +4540,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.9.0" +version = "0.16.1" dependencies = [ "async-channel", "futures-lite", @@ -4261,32 +4550,36 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.9.0" +version = "0.16.1" dependencies = [ + "flate2", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "roxmltree 0.21.2", ] [[package]] name = "pdfcraft-print" -version = "0.9.0" +version = "0.16.1" dependencies = [ "craft-print", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "pdfcraft-render", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-redact" -version = "0.9.0" +version = "0.16.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-edit", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", @@ -4297,7 +4590,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.9.0" +version = "0.16.1" dependencies = [ "bytemuck", "flate2", @@ -4316,14 +4609,15 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.9.0" +version = "0.16.1" dependencies = [ "aes", "aws-lc-rs", - "base64 0.22.1", + "base64 0.23.1", "bp256", "bp384", "cbc", + "chrono", "crypto-bigint", "des", "ecdsa", @@ -4337,30 +4631,36 @@ dependencies = [ "pdfcraft-annot", "pdfcraft-cos", "pdfcraft-fonts", + "pdfcraft-model", "rc2", "ripemd", "rsa", "rustls-cng", "security-framework", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "sha3", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-testkit" -version = "0.9.0" +version = "0.16.1" [[package]] name = "pdfcraft-ui-egui" -version = "0.9.0" +version = "0.16.1" dependencies = [ "arboard", - "base64 0.22.1", + "base64 0.23.1", + "block2 0.6.2", "bytemuck", + "chrono", + "craft-keymap", + "craft-menu", "craft-quit", "craft-ui", + "craft-update", "eframe", "egui", "egui_extras", @@ -4370,7 +4670,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "pdfcraft-cos", @@ -4380,6 +4680,7 @@ dependencies = [ "pdfcraft-platform", "pdfcraft-render", "pdfcraft-xfa", + "plist", "png 0.18.1", "pollster 1.0.1", "rfd", @@ -4393,12 +4694,14 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "web-time", "winsafe", + "xattr", ] [[package]] name = "pdfcraft-web" -version = "0.9.0" +version = "0.16.1" dependencies = [ "eframe", "egui", @@ -4410,27 +4713,27 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.9.0" +version = "0.16.1" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "image", "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", - "roxmltree", - "sha2 0.11.0", + "roxmltree 0.21.2", + "sha2 0.11.1", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-xfdf" -version = "0.9.0" +version = "0.16.1" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", "pdfcraft-forms", "pdfcraft-model", - "roxmltree", + "roxmltree 0.21.2", "thiserror 2.0.21", ] @@ -4481,6 +4784,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -4489,7 +4803,6 @@ checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros 0.13.1", "phf_shared 0.13.1", - "serde", ] [[package]] @@ -4500,6 +4813,7 @@ checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros 0.14.0", "phf_shared 0.14.0", + "serde", ] [[package]] @@ -4651,6 +4965,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.17.16" @@ -4727,12 +5054,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4742,15 +5081,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -4834,16 +5164,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -4878,6 +5209,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -4908,7 +5248,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.3", + "rand", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -4932,7 +5272,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4956,16 +5296,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -4977,31 +5307,12 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -5019,11 +5330,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5050,7 +5361,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5243,7 +5554,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5311,6 +5622,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + [[package]] name = "rsa" version = "0.10.0-rc.18" @@ -5340,6 +5660,7 @@ dependencies = [ "rten-base", "rten-gemm", "rten-model-file", + "rten-onnx", "rten-parallel", "rten-shape-inference", "rten-simd", @@ -5389,6 +5710,12 @@ dependencies = [ "rten-base", ] +[[package]] +name = "rten-onnx" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e" + [[package]] name = "rten-parallel" version = "0.26.0" @@ -5469,7 +5796,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5482,7 +5809,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5514,9 +5841,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5552,7 +5879,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5563,9 +5890,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -5681,6 +6008,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -5714,9 +6051,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5813,9 +6150,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -5971,7 +6308,7 @@ dependencies = [ "const-oid 0.9.6", "der 0.7.10", "hex", - "rand 0.10.3", + "rand", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -6207,7 +6544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6372,7 +6709,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ + "js-sys", "libc", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -6426,10 +6766,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6567,6 +6907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -6763,6 +7104,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6778,6 +7130,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typeid" version = "1.0.3" @@ -6798,7 +7156,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6866,24 +7224,26 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64 0.23.1", + "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "ureq-proto", "utf8-zero", ] [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -6916,7 +7276,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -6988,9 +7348,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -7004,8 +7364,6 @@ dependencies = [ [[package]] name = "vello_cpu" version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" dependencies = [ "bytemuck", "glifo 0.1.1", @@ -7016,14 +7374,24 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", - "glifo 0.2.0", + "glifo 0.3.0", "hashbrown 0.17.1", - "vello_common 0.1.0", + "vello_common 0.2.0", +] + +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", ] [[package]] @@ -7250,7 +7618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -7295,7 +7663,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", @@ -7304,9 +7672,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] @@ -7451,7 +7819,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -7512,7 +7880,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7633,16 +8001,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -7660,31 +8028,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -7702,99 +8053,52 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -7872,10 +8176,20 @@ dependencies = [ ] [[package]] -name = "winresource" -version = "0.1.31" +name = "winreg" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -7893,6 +8207,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wnaf" version = "0.14.1" @@ -7916,6 +8248,9 @@ name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -8032,13 +8367,14 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "lopdf", "pdfcraft-cos", "pdfcraft-oracle", "png 0.18.1", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "toml", ] @@ -8239,8 +8575,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -8249,6 +8588,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -8265,6 +8605,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -8277,6 +8634,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" @@ -8332,3 +8701,18 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index ec89098021..85d02570ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.9.0" +version = "0.16.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -13,47 +13,52 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +# One menu definition for the native macOS menu bar and the in-window bar elsewhere. +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } # Whether a path stored in a document may be opened (GoToR link targets). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.9.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.9.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.9.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.9.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.9.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.9.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.9.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" } -pdfcraft-model = { path = "crates/model", version = "0.9.0" } -pdfcraft-content = { path = "crates/content", version = "0.9.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.9.0" } -pdfcraft-print = { path = "crates/print", version = "0.9.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.9.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" } -pdfcraft-js = { path = "crates/js", version = "0.9.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.9.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" } -pdfcraft-export = { path = "crates/export", version = "0.9.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.9.0" } -pdfcraft-create = { path = "crates/create", version = "0.9.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.9.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" } -pdfcraft-render = { path = "crates/render", version = "0.9.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.9.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.9.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.16.1" } +pdfcraft-filters = { path = "crates/filters", version = "0.16.1" } +pdfcraft-cos = { path = "crates/cos", version = "0.16.1" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.16.1" } +pdfcraft-organize = { path = "crates/organize", version = "0.16.1" } +pdfcraft-measure = { path = "crates/measure", version = "0.16.1" } +pdfcraft-annot = { path = "crates/annot", version = "0.16.1" } +pdfcraft-audit = { path = "crates/audit", version = "0.16.1" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.16.1" } +pdfcraft-model = { path = "crates/model", version = "0.16.1" } +pdfcraft-content = { path = "crates/content", version = "0.16.1" } +pdfcraft-redact = { path = "crates/redact", version = "0.16.1" } +pdfcraft-print = { path = "crates/print", version = "0.16.1" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.16.1" } +pdfcraft-sign = { path = "crates/sign", version = "0.16.1" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.16.1" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.16.1" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.16.1" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.16.1" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.16.1" } +pdfcraft-js = { path = "crates/js", version = "0.16.1" } +pdfcraft-compare = { path = "crates/compare", version = "0.16.1" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.16.1" } +pdfcraft-export = { path = "crates/export", version = "0.16.1" } +pdfcraft-edit = { path = "crates/edit", version = "0.16.1" } +pdfcraft-create = { path = "crates/create", version = "0.16.1" } +pdfcraft-forms = { path = "crates/forms", version = "0.16.1" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.16.1" } +pdfcraft-render = { path = "crates/render", version = "0.16.1" } +pdfcraft-engine = { path = "crates/engine", version = "0.16.1" } +pdfcraft-platform = { path = "crates/platform", version = "0.16.1" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.16.1" } +pdfcraft-automation = { path = "crates/automation", version = "0.16.1", default-features = false } # pdf hayro = "0.7.1" @@ -61,6 +66,11 @@ hayro-syntax = "0.7.2" lopdf = "0.45" flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } weezl = "0.2" +# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff. +fax = "0.2.7" +# Independent CCITT and JBIG2 codecs, already in the tree through hayro. +hayro-ccitt = "0.3" +hayro-jbig2 = "0.3" # foundation thiserror = "2" @@ -68,39 +78,46 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" log = "0.4" kurbo = "0.13" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } # testing proptest = "1" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" +# Monotonic time for logic-only work on native and web (`App::logic` runs without a UI pass). +web-time = "1.1" [workspace.lints.rust] unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" # `"*"` matches every non-workspace package: crates.io dependencies and the -# vendored crates under `vendor/` (excluded from the workspace). Our own crates -# keep full debug info. Dependencies keep only line tables, so backtraces still -# show file and line, but debug info for the whole dependency graph stays out of -# every test binary (each UI test binary had a 500-600 MB PDB on Windows, #454). +# vendored crates under `vendor/` (excluded from the workspace). Dependencies +# carry no debug info and our own crates only line tables: backtraces and +# panics still show file and line, while test binaries and the target dir stay +# small (each UI test binary had a 500-600 MB PDB on Windows, #454). [profile.dev.package."*"] opt-level = 2 -debug = "line-tables-only" +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.low-disk] inherits = "dev" -debug = "line-tables-only" incremental = false [profile.release] @@ -112,8 +129,29 @@ codegen-units = 1 hayro-interpret = { path = "vendor/hayro-interpret" } hayro = { path = "vendor/hayro" } hayro-syntax = { path = "vendor/hayro-syntax" } +hayro-cmap = { path = "vendor/hayro-cmap" } +hayro-postscript = { path = "vendor/hayro-postscript" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } -winit = { path = "vendor/winit" } -egui-wgpu = { path = "vendor/egui-wgpu" } vello_common = { path = "vendor/vello_common" } +vello_cpu = { path = "vendor/vello_cpu" } +# The egui and winit crates below are pinned to immutable storytold git revisions (egui +# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc). +# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's +# earlier patches and their source guards, not path patches; the other `vendor/` entries above +# remain real path patches. See vendor/README.md and the migration plan. +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 8cacb0b585..63a8fe672c 100644 --- a/NOTICE +++ b/NOTICE @@ -58,3 +58,21 @@ dependencies change (plan/adr/0002, 0006). Vendored (vendor/, patched; see vendor/README.md): - hayro 0.7.1 — Apache-2.0 OR MIT, Copyright the hayro authors (licence files kept in vendor/hayro). - hayro-interpret 0.7.0 — Apache-2.0 OR MIT, Copyright the hayro authors (LICENSE-APACHE, LICENSE-MIT kept in vendor/hayro-interpret). + +Bundled egui font icons + +The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' +emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is +included through monochrome_emoji_fonts. Their upstream project is +https://github.com/jslegers/emoji-icon-font; both use the same exact copyright +and permission notice below. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 1ea7f4f692..ce9e71d4a9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@

+

+ PdfCraft icon: an engraved lion on a red tile +

+

PdfCraft

@@ -17,10 +21,13 @@

- License: MIT OR Apache-2.0 - Written in Rust - Platforms: macOS, Windows, Linux, FreeBSD, web - No account, no telemetry + License: MIT OR Apache-2.0 + Written in Rust + Platforms: macOS, Windows, Linux, FreeBSD, web + No account, no telemetry +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -139,6 +146,8 @@ Right-click a PDF tab to close it, reopen the last closed file-backed tab, or re Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state. +In **Inspector > Pages**, use **Thumbnail size** and the smaller/larger buttons to show more pages or inspect larger previews (50–150%). This changes only the thumbnails, keeping multi-page selection and the current page in view. The size belongs to the open document view and survives reloading its file; it is not remembered after restarting. Agents can set it with `ui.set {"key":"thumbnail-zoom","value":"75"}` and read `active.thumbnail_zoom` from `ui.state`. + @@ -325,15 +334,13 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true pdfcraft-cli ui --control ~/.pdfcraft-control.json quit ``` -`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view. -Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`, -`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and -`zone: center|left|right|top|bottom`. For example, -`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools. -`ui.dock.reset` restores the default -arrangement. The control channel accepts these methods directly; `ui.command` also accepts -`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility -and floating geometry. Invalid requests return an error without changing the document or layout. +`ui.panel` opens, closes or sizes a side panel: `{"panel":"tools"|"side","open":true|false,"width":320}`. +`tools` is the Tools panel and `side` the panel the rail opens (reopening it shows the one open last). +`width` is clamped to the panel's limits (Tools 240–520, side panel 260–520 points). `ui.dock.reset` +resets the side panels: Tools open on the left, default widths. The control channel accepts these +methods directly; `ui.command` also accepts `{"id":"ui.panel","params":{...}}`. Saved preferences keep +the Tools side, the open panels and their widths. Invalid requests return an error without changing +the document or the panels. `resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only. @@ -454,11 +461,13 @@ The portable zip runs from any folder, a USB stick included. Its `portable.txt` logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to `%APPDATA%`; delete that file to use the normal per-user folders. -The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft" +The MSI installs for your account only by default, without administrator privileges. Choose +"Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install +for all users. It adds "Edit with PdfCraft" to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under "Show more options"), without changing which app opens them by default. For unattended deployment -without a desktop shortcut, run from an elevated terminal: +without a desktop shortcut (add `ALLUSERS=1`, from an elevated terminal, for an all-users install): ```powershell msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0 @@ -466,7 +475,7 @@ msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU | Property | `=0` leaves out | |---|---| -| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut | +| `INSTALLDESKTOPSHORTCUT` | the desktop shortcut | | `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files | | `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images | @@ -588,13 +597,4 @@ Forks and modified versions must remove them. Made by the ArtCraft team and community.

-Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window. - -Advanced `ui.dock` operations use the same validated transaction as pointer gestures: -`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and -`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), -`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. -A center `move` can include `before` (panel ID or null to append) for tab order. -The equivalent `{"action": ...}` form accepts a serialized shared docking action; -this is also the form emitted by the renderer. Protected-canvas and app-specific panel -rules apply equally to both forms. +The Tools panel sits on the left, or on the right with Preferences ▸ Tools panel side. The rail stays at the right edge and opens one side panel beside it. Drag a panel's inner edge to resize it; widths are remembered. F4 shows or hides the side panel and Shift+F4 the Tools panel (also in the View menu); View ▸ Reset side panels restores the default arrangement. diff --git a/ROADMAP.md b/ROADMAP.md index 5e3d79b40d..00622a79b7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,39 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) +> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) +> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 1dd2737b; preserve viewer-only CJK fallback and regression coverage across engine, renderer and Combine module splits; estimates unchanged) +> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and preserve viewer-only CJK fallback through the engine module split; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font resolution helpers and regression contracts; behavior and estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print preview excludes installed CJK fonts and isolates its font cache from the viewer; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged) + **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) +> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged) +> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) +> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) +> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) +> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged) +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged) +> **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged) +> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged) +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged) +> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) @@ -15,10 +47,12 @@ > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-16 · **Change:** minor (M14: Czech catalog completed and reviewed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M10 #2156 candidate: remember selected print paper by canonical name; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -40,6 +74,14 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M4 #3309: bounded iterative named-destination pruning on page deletion; estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged) PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI @@ -66,8 +108,8 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i | | Value | Kind | |---|---|---| -| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` | -| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | +| **Feature breadth** | **≈ 67%** tier-weighted (57.0% unweighted: 423 shipped + 75 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` | +| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.3% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) | | **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) | @@ -122,7 +164,7 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef | H Digital signatures | 62.2% | 45% | 40–70 | | I Scan and OCR | 23.1% | 15% | 40–75 | | J Create and export | 39.7% | 25% | 55–100 | -| K Optimize and standards | 34.5% | 20% | 65–115 | +| K Optimize and standards | 35.3% | 20% | 65–115 | | L Print | 39.7% | 35% | 30–55 | | M Accessibility | 63.1% | 30% | 40–70 | | N Automation, preferences, misc | 30.0% | 35% | 25–45 | @@ -131,18 +173,18 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity ## Languages -Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese vs the current -union). Detail and script notes: [localization-parity.md](docs/localization-parity.md). +Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian +Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md). | Language | Code | Strings | Status | |---|---|---|---| | English | `en` | 100% | full | -| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) | -| Spanish | `es` | 95.5% | partial | +| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) | +| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) | | Hindi | `hi` | 0% | none | -| Arabic | `ar` | 100% | partial (layout not mirrored) | +| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) | | French | `fr` | 99.3% | partial | -| Portuguese (Brazil) | `pt-br` | 99.4% | partial | +| Portuguese (Brazil) | `pt-br` | 100% of current union | partial | | Indonesian | `id` | 0% | none | | Japanese | `ja` | 99.1% | partial | | German | `de` | 99.3% | partial | @@ -150,8 +192,8 @@ union). Detail and script notes: [localization-parity.md](docs/localization-pari | Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) | Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian, -Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian and Czech (menus only): **14 more catalogs** -(23 interface languages including English). +Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs** +(24 interface languages including English). ## Upcoming @@ -160,7 +202,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | Rank | Milestone | Estimate (h) | |---|---|---| | 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 | -| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 | +| 1 | Backlog and platform: crash and wrong-result reports, Chinese web UI face (#826) | 50–90 | | 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 | | 3 | Editing existing content (M7) | 100–180 | | 4 | OCR languages and Office export/import (M10) | 95–175 | @@ -172,10 +214,121 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged. +- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged. + +- **2026-10-31 (M2, PR #4172):** Integrated main fa5f9f8c, retaining both roadmap histories and raster regression groups. Restored display_config and thumbnail_config imports after the upstream explicit-import migration. Validation: 422 renderer unit tests, engine/UI all-target checks, scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Existing optional-patch/WASM warnings and parity notes remain; no full engine/UI runtime test rerun. Display-only font policy and estimates unchanged. + +- **2026-10-28 (M2, PR #4172):** Integrated main 5af10276, preserving both roadmap histories and reviewing auto-merged export tests and accessibility UI changes. Validation: 488 scoped tests passed (18 engine, 412 renderer unit, 43 renderer integration, 15 Combine; one existing renderer integration test ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Viewer-only CJK fallback and print/output font policy remain unchanged; estimates unchanged. + +- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged. + +- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. + +- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged. + +- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged. +- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. +- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. +- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. +- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. +- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. +- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged. +- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged. + +- **2026-10-24 (M11, more black-and-white images):** The optimizer re-stores Group 3 fax images as Group 4 when that is smaller, instead of leaving any CCITT image it doesn't resample as it is. Black-and-white images stored as eight-bit RGB (as some phone and scanner apps write them) become one-bit grey, and indexed images that use only their first two palette entries become one bit with their palette, so Monochrome Images compresses and downsamples them too. +- **2026-10-24 (M11, monochrome downsampling):** The PDF Optimizer and Reduce File Size average black-and-white images above 450 ppi down to 300 ppi (Monochrome Images ▸ Downsample to), Group 4 scans included, so a 600 ppi text page goes from 79 KB to 40 KB. CCITT decoding moved into `pdfcraft-filters` (`decode_ccitt`), shared by Export ▸ Images and the optimizer through `Document::decoded_image`. +- **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller, as they can a dithered scan, soft mask counted, and Export ▸ Images reads CCITT fax images. +- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next. +- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next. +- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script. +- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. +- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. +- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. +- **2026-10-23 (M11, optimizer resolution):** Image downsampling includes each page's `/UserUnit` scale, preserving images already below the physical resolution threshold, including shared nested forms. + Synthetic measurement and optimization regressions cover direct and indirect units and page-local defaults. + Estimates unchanged. +- **2026-10-23 (M10, Word table widths):** Word export sizes each cell from the grid columns it spans, keeping unequal columns and cells after a merged cell consistent with the table grid. +- **2026-10-23 (M10, table continuation text):** Office exporters retain text from table slots covered by another cell's span as paragraph text. + Synthetic regressions check every input fragment survives exactly once in Word, HTML, RTF and PowerPoint, while ordinary superscripts stay in their table cells. +- **2026-10-23 (M10, image export):** Soft-mask Decode intervals preserve partial opacity in Export all images and Save Image As. + Synthetic regressions cover direct and indirect ranges, inverted/subrange alpha, packed masks, JPEG masks and unchanged image component Decode. +- **2026-10-23 (M10, print resources):** Vector printing preserves original page artwork when existing XObjects use annotation resource names. + The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data. + Estimates unchanged. +- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. +- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. +- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. +- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. + +- **2026-10-23 (M2, #234, continuation of #1567):** Reconciled installed CJK viewer fallback with current rendering changes; Windows preserves installed Song/Hei/Kai families and bounds font reads. GBK-EUC-H CIDFontType2 regression coverage includes text extraction; viewer fallback also survives reload with retained preferences. System fonts remain display-only. No overall readiness estimate changed. +- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. +- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. + +- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged. +- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. +- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. +- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. +- **2026-10-22 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies, with the number of replies in small text under it (in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. Estimates unchanged. +- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. +- **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated. +- **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window. + +- **2026-10-19 (M11, #2178):** Optimize checks image and soft-mask dimensions and byte counts before decoding; hostile-dimension regressions pass. Estimates unchanged. + +- **2026-10-20 (M11, #3507):** Image merging keeps trailing comments, split operands and graphics state across content streams. Synthetic tool tests check saved text, rendered regions and exact image rows. + +- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged +- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects. +- **2026-10-22 (M6, calculation scripts):** The Calculate tab and a rename read simplified field notation only from a comment, where Acrobat writes it: a string that spells `BVCALC … EVCALC` is no longer taken for the notation, or rewritten by a rename. A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, so a rename no longer rewrites a name inside one, and a quote inside one no longer hides the `getField` calls after it. Both found in review of #3550. A `//` comment ends at `\r`, U+2028 or U+2029 as well as `\n`, so in a script with `\r` line endings the code after one is no longer hidden from a rename, or taken for the notation. Estimates unchanged. +- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged. +- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged. +- **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged. +- **2026-10-20 (M2, PR #3348 review):** Tracking samples retain short repeated punctuation (up to four glyphs), keeping tracked ellipses intact and searchable while longer TOC leaders still preserve title word spaces. Synthetic regressions cover isolated and surrounded ellipses. Estimates unchanged. +- **2026-10-20 (M2, #3601):** A missing spacing glyph in an OpenType substitute uses its ordinary-space glyph. Synthetic Japanese CID renderer tests check blank spaces, `/W` and `/DW` placement, and unchanged Unicode extraction. Font selection and embedded fonts are unchanged. Estimates unchanged. +- **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged. +- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. +- **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged. + +- **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508). +- **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged. +- **2026-10-19 (M2, radial luminosity masks):** Added synthetic radial-fade coverage for the existing masked-gradient fix (#2132): centre, midpoint, antialiased clip and per-pixel shading reference, including previews, scales and tiles. Removing the existing native-gradient mask guard reproduces an opaque midpoint; restoring it passes. No new renderer behavior or estimate change. +- **2026-10-20 (M6, #3502):** Form detection preserves comments across content streams and does not add fields over rectangles hidden by those comments. Synthetic tests cover visible controls, graphics state, and saved field inventories. Estimates unchanged. +- **2026-10-20 (M6, #2273 / PR #2279):** Rebased the field-highlight default and Appearance preference onto main, retaining secured-form parity evidence and updating secured AcroForm/XFA toggle expectations. Build and test validation delegated to GitHub CI. Estimates unchanged. +- **2026-10-20 (M5, reshape lines):** A line or arrow could only be moved after it was drawn; its length and angle stayed. A selected line, connected lines, polygon or cloud now has a handle on each end or corner: dragging one reshapes it (Shift holds it to 45°), as one undo step, with the new shape drawn while dragging. `pdfcraft_annot::set_points`, `Edit::ReshapeAnnotation`, `comment_edit points`; comment lists carry the points. Estimates unchanged. +- **2026-10-20 (M3, #3620):** Overlapping comment hover uses the original annotation index independently of spatial inspection order. A synthetic UI regression covers different annotation upper edges. Estimates unchanged. + +- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged. +- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged. + +- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged. + +- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. + +- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. + +- **2026-10-18 (M3, PR #3101 review):** Gap changes in Organize Pages preserve document scroll position; menu and control changes apply to both comparison panes without changing unrelated tabs. Three synthetic UI regressions were observed failing before the fixes. Readiness estimates unchanged. + +- **2026-10-18 (M3, PR #3101):** Optional gapless continuous page display with persisted defaults and UI control support; both new labels translated in all 22 catalogs. Rebased onto current main, preserving navigation and rendering fixes. Readiness estimates unchanged. + +- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged. + +- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged. +- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged. +- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged. +- **2026-10-18 (M2, #3086):** Text extraction estimates letter tracking without repeated punctuation gaps so dotted TOC leaders do not swallow word spaces. Synthetic PDF regressions cover leader lengths, phrase search and selection; strongly tracked text stays intact. Estimates unchanged. - **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged. - **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved. +- **2026-10-18 (M12, #2589):** Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged. +- **2026-10-18 (M7, #2592):** Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged. +- **2026-10-18 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged. +- **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged. +- **2026-10-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged. +- **2026-10-18 (M12, #2570):** Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged. +- **2026-10-19 (M4, #3309 review):** Page deletion keeps working with 60,000 named destinations, without a name-count traversal limit. Synthetic regressions verify every surviving target after deletion and restoration of the deleted page and its destinations by undo/redo, full/incremental save/reopen and unchanged rendering. Estimates unchanged. +- **2026-10-18 (M4, #3309):** Deleting a page prunes named destinations without recursive calls. Cyclic and shared indirect nodes are visited once. Synthetic deep/cyclic graph, save/reopen and automation regressions cover the change. Estimates unchanged. +- **2026-10-18 (M1, #2658):** macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged. - **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged. - **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged. - **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged. @@ -183,31 +336,52 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. - **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. +- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged. - **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. - **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged. +- **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background turns the fill back on, after edits and redactions too, unless the operators before it changed or it would no longer be painted first; then it says which pages keep it off. The copy's tag holds no reference to the original stream, which had kept text a redaction or Remove Hidden Information removed in the saved file. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. - **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged. - **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged. +- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged. +- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged. +- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged. +- **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged. +- **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged. +- **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. Estimates unchanged. +- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. -- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged. +- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged. +- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged. - **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. - **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. - **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged. - **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged. +- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged. +- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. +- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). +- **2026-10-15 (M14, Norwegian Bokmål localization):** Added Norwegian Bokmål (`nb`) as an interface and date language: a catalog covering every key the other catalogs have, Norwegian key names, month and weekday names for Fill & Sign dates, and `no`/`nn` locales resolving to it until a Nynorsk catalog exists. Written from the meaning of the English labels; terminology reviewed by a native-speaking contributor, not yet every line. Estimates unchanged. - **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged. - **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test. +- **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged. - **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged. - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. +- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged. +- **2026-10-15 (G14, Romanian localization):** Added Romanian (`ro`, Română) as an interface and date language with a 2,673-entry catalog covering every bundled key, and a CLDR plural rule (`plural_ro`: one / few / other, "20 de pagini"). Locale tags `ro` / `ro-RO` / `ro_RO.UTF-8` / `ro-MD` resolve, so Auto picks it on Romanian Windows. Coverage and plural tests added. Status `partial` like the other catalogs (no translated help). +- **2026-10-22 (M14, JPEG and TIFF export):** Export ▸ Image as JPEG or TIFF refuses a raster that is not exactly width × height pixels, as PNG export has since #2425. A raster a pixel short panicked inside the JPEG and TIFF encoders, and a partial pixel at the end was dropped and the rest written as a valid image. Rendered pages are always the right size, so what exports is unchanged. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with real text (a flyer with a background picture), read-only files and other tools are left alone. The photographs on a scan are still part of the one page image. + +- **2026-10-12 (M2, installed CJK fonts):** A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF doesn't embed, such as the Kozuka Gothic of an Acrobat text box or the SongtiTC of a Pages export, drew nothing unless craft-fonts had a Japanese face. After craft-fonts, it now draws with a face already installed on the machine (PingFang, Heiti, Microsoft JhengHei / YaHei, Malgun and the like; read at runtime, never bundled, and used only for the on-screen viewer, never for exports, printing or OCR; Noto CJK and Source Han excluded; `PDFCRAFT_SYSTEM_FONTS=0` turns it off). The face of a collection with several regions (PingFang SC, TC) is chosen by region. Checked on macOS with a synthetic Traditional Chinese, Simplified Chinese and Korean page; the Windows and Linux font lists are untested, and a machine with none of the listed faces still draws nothing. - **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. - **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged. - **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. @@ -248,6 +422,16 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged. - **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged. - **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before. +- **2026-10-14 (M3, full screen as presentation, #379/#1459/#1353):** Full Screen Mode shows one page (or one spread) at a time fitted to the screen on black, with no tools; click, Space, Enter and the arrows turn pages; Esc restores the display from before. Transitions, auto-advance and loop remain planned. +- **2026-10-14 (M10, #1818):** Properties… reads a CUPS printer's driver options off the GUI thread, the dialog saying so meanwhile, and `lpoptions` is given ten seconds before it is killed and the PPD's defaults stand; a read that dies leaves the panel with nothing rather than a frozen window. The bounded wait is tested with a stand-in command that never answers. +- **2026-10-14 (M10 #2156 candidate):** Print paper selection survives Cancel and settings restoration; canonical names avoid preset-index dependence. Other print preferences and estimates unchanged. + +- **2026-10-15 (community, M14, Polish signature panel):** A Polish signature panel listed its details in English: the catalog lacked every signature wording, the changes after signing they name and 34 UI literals. All 101 are translated; `polish_covers_ui_literals` and `polish_says_what_a_signature_says` keep them so. Estimates unchanged. +- **2026-10-14 (M14, Finnish localization):** Added Finnish as another interface and date language, with a 2,674-entry catalog covering every key of the other bundled catalogs and two-form plurals (partitive after numbers other than 1). Calendar dates use partitive month names (`14. lokakuuta 2026`). Coverage tests check commands/tools, scanned UI literals and bundled catalog keys. AI-assisted; native proofreading pending. +- **2026-10-14 (M14, Catalan localization):** Added Catalan as another interface and date language, with a 2,592-entry clean-room catalog, one/other plurals and coverage tests for commands, tools, UI literals and every key in the other catalogs. A native Catalan speaker (the contributor) has reviewed the translation. Estimates unchanged. + +- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. +- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. @@ -307,6 +491,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged. - **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged. - **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged. - **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged. @@ -369,10 +554,10 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-10 (L Print, #756):** Windows prints. The Print dialog lists the system's printers and its default from `Win32_Printer` (through PowerShell's CIM; `wmic` is gone from Windows 11), and Print spools the print-ready PDF: the in-box Windows.Data.Pdf renders each sheet at the printer's own resolution and System.Drawing.Printing carries copies, collation, duplex and colour as the job's settings — both reached through `powershell`, so the Rust code touches no Win32 API and the machine needs no PDF reader. This closes #756 and the "printer not showing" duplicates #160, #244, #293, #465 and #644, using the CIM approach community-tested in #452; the tests render the job's sheets through the whole WinRT path on CI (dry run, no printer needed) and, where a printer can be added, catch a real job in its paused queue. Web printing is still missing; milestone and effort estimates are unchanged. - **2026-10-10 (M3, #740):** Column select, as in Acrobat: holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle, with the Select tool, the text markup tools and Redact; Edit ▸ Column select does it with a plain drag, for Linux desktops whose window manager takes Alt-drag (Xfce, Cinnamon, MATE, KDE Plasma 5 by default). Copy gives rows top to bottom with tab-separated cells (right to left in right-to-left rows, reading order for vertical text); `text_extract` takes the same rectangle as `rect`. The new label is in all 15 catalogs. Also repaired `parity/acrobat-features.toml`, which #650 had left unparseable (duplicate keys on `form.field-list`). Still missing: column selection across pages and ⇧-click extension. Estimates unchanged. -- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h. +- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the additive weighted sum over the dimensions (written weights), 47% → 49%; no new evidence. Beta ≈ 26 points, ≈ 400–700 h. - **2026-10-10 (progress docs, gate consistency check):** Re-judged the convert-and-share row with the rule applied across all Crafting Apps (a workflow blocks only when it can't be completed at all; lossy exchange and missing sub-cases are beta items, as for FilmCraft's .prproj or VectorCraft's .ai). Unencrypted sharing works, encryption interop (#774) and Office→PDF are sub-cases, Word export is lossy rather than absent: partial, not blocking. **Stage pre-alpha → alpha**; numbers unchanged (47%, mainstream 55%; beta ≈ 400–700 h). Added an essentials-user score: ≈ 66% for someone who only opens, reads, comments, fills, signs, prints and rearranges pages. -- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate (craftrules progress-docs) fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h. -- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured to the craftrules progress-docs standard; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`. +- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h. +- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`. - **2026-10-10 (M8, redact word lists):** Find text and redact gains "Multiple words or phrases": a list with one word or phrase per line, typed or imported from a `.txt` file, whose every match is marked in one undo step. Lists are trimmed, deduplicated and bounded (1,000 entries of 256 characters, files up to 1 MB). `redact_mark` takes `words` and reports how many marks each made, so an agent sees which found nothing. Tests cover the list parsing, the tool end to end (mark, undo, apply, invalid lists) and the dialog with an imported list; the new labels are translated in all 13 UI catalogs. `protect.redact-word-lists` is shipped; whole-word and case-sensitive matching and exporting the list are still missing. Estimates unchanged (about 30–35%). - **2026-10-10 (M4, bookmarks from structure):** Bookmark options ▸ New bookmarks from structure (command `bookmark.from_structure`, tool `bookmark_from_structure`) turns a tagged document's headings into bookmarks: H1–H6 through the role map, a plain H by its section nesting, nested by level under a new first "Untitled" bookmark as in Acrobat, each linked to its structure element (/SE). Titles come from /ActualText, the marked text on the page (decoded through the font's ToUnicode or encoding), /Alt or /T. The walk is bounded (depth, visits, heading and title caps, cycle-safe), an untagged document gets a clear error, and the edit is one undo step. Tests cover role maps, nesting, ToUnicode text, a self-referencing heading, the tool, undo and the panel. Still missing: Acrobat's element-type choice and destinations at the heading's position. The M4 row no longer lists Replace pages as missing (it shipped earlier). Overall estimate unchanged (about 30–35%). - **2026-10-10 (M9, signing encrypted documents):** Digital signatures, certification and document timestamps work on password-protected PDFs instead of failing with "not supported: signing encrypted documents". `pdfcraft-cos` no longer encrypts or decrypts a signature dictionary's `/Contents` (§7.6.2; by `/Type /Sig|/DocTimeStamp` or `/Contents` + `/ByteRange`), so the placeholder is patched after hashing as in a plain file and existing signatures in encrypted files read their real CMS (which also fixes their DSS `/VRI` keys and keeps them intact in a full rewrite). Opened with the user password, the signer applies the document's permissions (fill-forms for existing fields and timestamps, annotate + modify for new fields, modify for certifying) and names the permissions password when it refuses. Synthetic tests cover all four algorithms, counter-signing, tampering, permissions and the automation tools. Overall estimate unchanged (about 30–35%). @@ -536,16 +721,34 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. + **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | +| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | +| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | +| 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. | +| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | +| 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. | +| 2026-10-23 | minor | M10: Export all images and Save Image As apply soft-mask Decode intervals to PNG alpha before resizing, including inverted/subrange, indirect, packed and JPEG masks. Estimates unchanged. | +| 2026-10-23 | minor | M10: annotation appearances use unoccupied XObject names, preserving original page artwork and distinct appearances through inherited resources and print/reopen. Estimates unchanged. | +| 2026-10-22 | minor | M4, #3916 partial: document edits and Undo/Redo cancel Organize drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | +| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | +| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. | +| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. | +| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. | +| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | +| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. | | 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. | | 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | | 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. | | 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged | | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | +| 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. | | 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged | | 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | | 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | @@ -563,11 +766,11 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. | | 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged | | 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged | -| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) | +| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; no new evidence) | | 2026-10-10 | minor | Essentials-user score added (≈ 66%) | | 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha | | 2026-10-10 | minor | Second look: ready 40% → 47% (cross-cutting double count removed), mainstream-practitioner numbers and user sentiment added; core-workflow alpha gate applied, stage alpha → pre-alpha | -| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated | +| 2026-10-10 | major | Progress docs restructured (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated | | 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h | | 2026-10-05 | major | Honest assessment and "Where we're lacking" added | | 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. | @@ -577,3 +780,29 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192). - **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged. + +- 2026-10-18: M11 / #3227: mark target-version compatibility partial; document the unchecked newer-content limitation. + +- **2026-10-18 (PR #3227 integration):** M11: integrate upstream b0e5c6eb; retain partial target-version compatibility claims and effective-version reporting, correcting the borrowed version parser lifetime. Scope and estimates unchanged. + +- **2026-10-12 (i18n):** Albanian (`sq`, Shqip) added: 2,343-entry catalog, month and weekday names, Preferences entry; not yet reviewed by a native speaker. + +- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`). + +- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. + +- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged. + +- **2026-10-23 (M2, PR #4172):** Combine thumbnails and accessibility figure previews use the viewer's display-only CJK fallback. Windows family matching includes FangSong and subset-prefixed Founder names. Export, print, OCR and automation remain off by default; estimates unchanged. + +- **2026-10-26 (M2, PR #4172 print-preview review):** Print-preview requests disable installed CJK fallback and use an isolated cache so preview font availability matches output. Viewer fallback survives switching in either direction. Synthetic GBK regressions cover inline and single-worker rendering, full pages and tiles; estimates unchanged. + +- **2026-10-26 (M2, PR #4172 documentation review):** Added function documentation for installed-font script selection, bounded candidate reads, platform paths, resolver precedence and regression contracts. No runtime behavior or estimate change. + +- **2026-10-27 (M2, PR #4172 conflict resolution):** Integrated main `9a9813bf` and its engine module split. Opening, layer changes and session renderer rebuilds retain display-only CJK fallback; output configuration and print-preview cache isolation are preserved. Validation passed: 16 scoped engine regressions, 412 renderer unit tests, 43 renderer integration tests (one existing corpus case ignored), 15 Combine tests, scoped Clippy, formatting, layers/assets/parity and renderer/engine WASM checks. Estimates unchanged. +## Pages sidebar keyboard refresh (2026-10-30) + +PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape after the UI module split. The selection does not navigate. The focused regression is in `crates/ui-egui/tests/it/view.rs`; current-head hosted validation is pending. Feature counts and estimates are unchanged. +## Button export-value refresh (2026-10-30) + +PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged. diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index bab8c28a88..3d9d87a812 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -21,7 +21,11 @@ pdfcraft-cos = { workspace = true } pdfcraft-preflight = { workspace = true } pdfcraft-automation = { workspace = true } pdfcraft-platform = { workspace = true } -base64 = "0.22" +base64 = "0.23" + +[dev-dependencies] +# Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine. +png = "0.18" [target.'cfg(unix)'.dependencies] # `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here. diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs new file mode 100644 index 0000000000..62993ad6dc --- /dev/null +++ b/apps/pdfcraft-cli/src/check.rs @@ -0,0 +1,208 @@ +use super::commands::{parse_dpi, read}; +use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options}; +use super::{ + Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line, +}; + +/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are +/// found so scripts and CI can gate on it, and when the audit itself cannot run. +pub(crate) fn audit(args: &[String]) -> Result<(), CliError> { + validate_options("audit", args, AUDIT_OPTIONS)?; + let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; + let bytes = read(path)?; + let password = flag(args, "--password"); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; + let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; + // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` + // reports them; the page geometry is read only when there is something to place. + let info = if findings.is_empty() { + None + } else { + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?) + }; + let items: Vec = findings + .into_iter() + .map(|f| { + let rect = f.rect.map(|v| v as f32); + serde_json::json!({ + "page": f.page + 1, + "kind": f.kind.id(), + "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), + "covered_text": f.covered_text, + }) + }) + .collect(); + println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() })); + if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) } +} + +/// Child-process body for `check`: prints one JSON line. +pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { + validate_options("check-one", args, CHECK_ONE_OPTIONS)?; + let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; + let start = Instant::now(); + let bytes = read(path)?; + let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { + Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), + Ok(info) => { + let mut r = PageRenderer::new(bytes, RenderConfig::default()); + let mut failed = Vec::new(); + for i in 0..info.pages.len().min(500) { + let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); + if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error { + failed.push(format!("p{} text: {e}", i + 1)); + } + if let Some(e) = p.error { + failed.push(format!("p{}: {e}", i + 1)); + } + } + let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; + (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) + } + }; + // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, + // so a panic in cos shows up as a crash of this process. + if args.iter().any(|a| a == "--edit") { + edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); + } + let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, + "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); + stdout_line(format_args!("{line}"))?; + Ok(()) +} + +/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both +/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. +fn edit_round_trip(bytes: &[u8]) { + use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; + let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; + for num in doc.object_numbers().into_iter().take(20_000) { + if let Ok(o) = doc.try_get(num) + && let Object::Stream(s) = &*o + { + let _ = s.decoded(); + } + } + if let Some(root) = doc.root() { + let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true))); + } + let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; + for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)] + .into_iter() + .flatten() + { + if let Ok(again) = Document::open(Arc::new(out)) { + let _ = again.root().map(|r| again.get(r)); + } + } +} + +fn short(s: &str) -> String { + s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() +} + +fn collect(paths: &[&str]) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); + let mut visited = std::collections::HashSet::new(); + while let Some(p) = stack.pop() { + if p.is_dir() { + // Directory aliases and symlink cycles must not scan the same tree repeatedly. + let identity = match p.canonicalize() { + Ok(identity) => identity, + Err(error) => { + eprintln!("{}: could not resolve directory: {error}", p.display()); + continue; + } + }; + if !visited.insert(identity) { + continue; + } + if let Ok(rd) = std::fs::read_dir(&p) { + stack.extend(rd.flatten().map(|e| e.path())); + } + } else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { + out.push(p); + } + } + out.sort(); + out +} + +pub(crate) fn check(args: &[String]) -> Result<(), CliError> { + validate_options("check", args, CHECK_OPTIONS)?; + // Every option is checked before the inputs are scanned, so a bad value is one message, + // not one problem per file: a zero timeout used to make every file time out, and a bad + // dpi used to reach each child, which printed nothing and was reported as a crash. + let timeout_value = flag(args, "--timeout").unwrap_or("20"); + let timeout = Duration::from_secs( + timeout_value + .parse::() + .ok() + .filter(|secs| *secs > 0) + .ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?, + ); + let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); + parse_dpi(&dpi)?; + let files = collect(&positional(args, CHECK_OPTIONS)); + if files.is_empty() { + return Err("check: no PDF files found".into()); + } + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let mut results = Vec::new(); + let mut counts: std::collections::BTreeMap = Default::default(); + for (n, f) in files.iter().enumerate() { + let r = run_child(&exe, f, &dpi, timeout); + let status = r["status"].as_str().unwrap_or("?").to_string(); + *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; + if status != "ok" { + eprintln!( + "[{}/{}] {status:<28} {} {}", + n + 1, + files.len(), + f.display(), + r["detail"].as_str().unwrap_or("").chars().take(160).collect::() + ); + } + results.push(r); + } + eprintln!("\nchecked {} files: {:?}", files.len(), counts); + if let Some(out) = flag(args, "--json") { + std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { + let file_s = file.to_string_lossy().to_string(); + let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); + let mut child = match child { + Ok(c) => c, + Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), + }; + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut out = String::new(); + if let Some(mut so) = child.stdout.take() { + use std::io::Read; + let _ = so.read_to_string(&mut out); + } + return match serde_json::from_str::(out.trim()) { + Ok(v) => v, + Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), + }; + } + Ok(None) if start.elapsed() > timeout => { + let _ = child.kill(); + let _ = child.wait(); + return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); + } + Ok(None) => std::thread::sleep(Duration::from_millis(20)), + Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), + } + } +} diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs new file mode 100644 index 0000000000..9d0b2057f0 --- /dev/null +++ b/apps/pdfcraft-cli/src/commands.rs @@ -0,0 +1,413 @@ +use super::options::{ + COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional, + validate_options, +}; +use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line}; + +pub(crate) fn read(path: &str) -> Result>, String> { + std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) +} + +pub(crate) fn info(args: &[String]) -> Result<(), CliError> { + validate_options("info", args, INFO_OPTIONS)?; + let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; + let bytes = read(path)?; + let password = flag(args, "--password"); + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; + let pdf_pages = info.pages.len(); + let acroform_fields = info.fields.len(); + // Dynamic XFA forms: say what laying the template out gives (what the app shows). + let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) + .then(|| { + let mut s = pdfcraft_engine::Session::new(); + let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; + let d = s.get(id)?; + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) + }) + }) + .flatten(); + // What the document declares as a standard, read the way the Standards panel reads it, so + // `info` and the app agree. A file that declares nothing leaves these null and empty. + let declared = + pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); + let json = serde_json::json!({ + "file": path, + "pdf_version": info.pdf_version, + "file_size": info.file_size, + "pages": info.pages.len(), + "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, + "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), + "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, + "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, + "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())), + "pdfua": declared.pdfua, + "output_intents": declared.output_intents, + "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "xfa_layout": xfa_layout, + "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), + "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), + "transitions": info.transitions.len(), + "custom_properties": info.custom.len(), + "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), + "warnings": info.warnings, + }); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; + Ok(()) +} + +pub(crate) fn text(args: &[String]) -> Result<(), CliError> { + validate_options("text", args, TEXT_OPTIONS)?; + let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?; + let mut selected_page = None; + // Validate every supplied value before reading, retaining the first valid selection. + let mut options = args.iter(); + while let Some(option) = options.next() { + if option == "--page" { + let page = options + .next() + .and_then(|value| value.parse::().ok()) + .filter(|page| *page > 0) + .ok_or("bad --page: expected a positive page number")?; + selected_page.get_or_insert(page - 1); + } else if option.starts_with("--") { + // Match positional(): an option's operand is not itself another option. + options.next(); + } + } + let password = flag(args, "--password"); + let mut bytes = read(path)?; + // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. + // Worked out from the file as read: `bytes` may become the session's display copy below. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the + // permissions password, a document that doesn't allow copying gives no text. + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + // The engine's hint is about editing in the app; on the command line the way in is + // `--password`. + doc.check_copying() + .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; + if doc.xfa.is_some() { + bytes = doc.display.clone(); + } + } + let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }); + // A document the renderer could not open (wrong or missing password, unparseable file) has + // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask + // `inspect` why instead, so a protected file fails the same way `info` does. + if r.page_count() == 0 { + let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?; + if !info.pages.is_empty() { + return Err("text: the document could not be parsed".into()); + } + } + let pages: Vec = match selected_page { + Some(page) => vec![page], + None => (0..r.page_count()).collect(), + }; + let mut failed: Vec = Vec::new(); + for (n, p) in pages.iter().enumerate() { + let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + if let Some(e) = out.error { + let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1); + failed.push(p + 1); + continue; + } + if n > 0 { + stdout_line(format_args!("\u{c}"))?; + } + stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?; + } + if failed.is_empty() { + Ok(()) + } else { + Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into()) + } +} + +/// The most pages one `A-B` range may name: a bound on what a typed range can allocate. +const MAX_RANGE: usize = 1_000_000; + +/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based +/// indices (#1923). +fn page_list(s: &str) -> Result, String> { + let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}")); + let mut out = Vec::new(); + for item in s.split(',').map(str::trim) { + match item.split_once('-') { + Some((from, to)) => { + let (from, to) = (number(from, item)?, number(to, item)?); + if to < from { + return Err(format!("bad page range {item:?}: it ends before it starts")); + } + if to - from >= MAX_RANGE { + return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages")); + } + out.extend(from - 1..to); + } + None => out.push(number(item, item)? - 1), + } + } + Ok(out) +} + +/// The options `edit` understands, for its "unknown option" message. +const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author"; + +/// Apply page and metadata edits through the engine and save (incrementally unless `--full`). +/// +/// Every argument is parsed before the file is opened: an option this command does not know +/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an +/// unchanged copy and exit 0, which looks like a successful edit (#133). +pub(crate) fn edit(args: &[String]) -> Result<(), CliError> { + use pdfcraft_engine::{Edit, Session}; + let mut path: Option<&str> = None; + let mut out: Option<&str> = None; + let mut password: Option<&str> = None; + let mut full = false; + let mut edits = Vec::new(); + let mut i = 0; + while let Some(arg) = args.get(i).map(String::as_str) { + // Options that take no value, and the input file. + match arg { + "--full" => { + full = true; + i += 1; + continue; + } + a if a.starts_with("--") => {} + a => { + if path.is_some() { + return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into()); + } + path = Some(a); + i += 1; + continue; + } + } + // `edit` parses its own options (they run in order), so `--out` is checked here in the + // same way: it wrote a PDF to a file called `--full`. + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(arg, value) { + return Err(needs_value("edit", arg, value).into()); + } + let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?; + match arg { + "--out" => out = Some(value), + "--password" => password = Some(value), + "--rotate" => { + let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; + let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?; + // The same rule `rotate_pages` enforces, checked here so a bad angle is named + // with its option before any file is opened. + if degrees % 90 != 0 { + return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into()); + } + edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees }); + } + "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), + "--move" => { + let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; + let target: usize = to + .parse() + .ok() + .filter(|target| *target > 0) + .ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?; + edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 }); + } + "--insert-blank" => { + let position: usize = value + .parse() + .ok() + .filter(|position| *position > 0) + .ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?; + edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 }); + } + "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), + "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), + other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()), + } + i += 2; + } + let path = path.ok_or("edit: missing file")?; + let out = out.ok_or("edit: missing --out")?; + let mut session = Session::new(); + let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?; + for e in edits { + // A destination past the end must fail like `page_move` / `page_insert_blank` do, not + // silently become an append (#1330). Checked per edit: earlier edits change the count. + let n = session.get(id).map_or(0, |d| d.info.pages.len()); + let destination = match &e { + Edit::MovePages { to, .. } => Some(("--move target", *to)), + Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)), + _ => None, + }; + if let Some((option, at)) = destination.filter(|(_, at)| *at > n) { + return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into()); + } + // Pages count from the document as the earlier options left it, so a page past the end is + // reported with its own option and the current page count (#1550). + let listed = match &e { + Edit::RotatePages { pages, .. } => Some(("--rotate", pages)), + Edit::DeletePages { pages } => Some(("--delete", pages)), + Edit::MovePages { pages, .. } => Some(("--move", pages)), + _ => None, + }; + if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) { + return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into()); + } + session.apply(id, e).map_err(|e| e.to_string())?; + } + let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; + // As the app's Save: the file must reopen with its pages before it replaces anything (#1698). + session.check_save(id, &bytes).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +/// Write a PDF a command produced, through a staging file beside the target (#2166): a write +/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was. +fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> { + use pdfcraft_platform::staging::{StagingName, write_atomically}; + write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into()) +} + +fn file_stem(path: &str) -> String { + Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) +} + +pub(crate) fn combine(args: &[String]) -> Result<(), CliError> { + validate_options("combine", args, COMBINE_OPTIONS)?; + let out = flag(args, "--out").ok_or("combine: missing --out")?; + let inputs = positional(args, COMBINE_OPTIONS); + if inputs.len() < 2 { + return Err("combine: give at least two input files".into()); + } + let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?; + let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +pub(crate) fn extract(args: &[String]) -> Result<(), CliError> { + validate_options("extract", args, EXTRACT_OPTIONS)?; + let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?; + let out = flag(args, "--out").ok_or("extract: missing --out")?; + let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; + let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +pub(crate) fn split(args: &[String]) -> Result<(), CliError> { + validate_options("split", args, SPLIT_OPTIONS)?; + use pdfcraft_engine::SplitBy; + let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?; + let by = match (flag(args, "--every"), flag(args, "--before")) { + (Some(n), None) => { + // A count the splitter cannot hold is refused here, before the file is opened, + // rather than silently clamped to 1 further down. + let count = n + .parse::() + .ok() + .filter(|count| *count > 0) + .ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?; + SplitBy::PageCount(count) + } + (None, Some(list)) => SplitBy::Before(page_list(list)?), + _ => return Err("split: give either --every N or --before PAGES".into()), + }; + let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or(".")); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; + let stem = file_stem(path); + let parts = session.split(id, &by).map_err(|e| e.to_string())?; + // An output folder that does not exist yet is created (#249); one that can't be is reported + // as the folder's problem, not as the first part's. + std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?; + for (a, b, bytes) in parts { + let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") }); + write_pdf(&name, &bytes)?; + stdout_line(format_args!("{}", name.display()))?; + } + Ok(()) +} + +/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value +/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own +/// internal message (and in `check`, passed on as text, they would turn every file into a +/// child that printed nothing), so the CLI names the option and the value instead. +pub(crate) fn parse_dpi(value: &str) -> Result { + value + .parse::() + .ok() + .filter(|dpi| dpi.is_finite() && *dpi > 0.0) + .ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}")) +} + +pub(crate) fn render(args: &[String]) -> Result<(), CliError> { + validate_options("render", args, RENDER_OPTIONS)?; + let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?; + let page: usize = + flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?; + let page_index = page - 1; + let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?; + let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; + // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. + let format = match Path::new(out).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).as_deref() { + Some("png") => Some(ImageFormat::Png), + Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), + Some("tif" | "tiff") => Some(ImageFormat::Tiff), + Some("pam") => None, + _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), + }; + // The app renders a display copy that draws comments missing their /AP streams; the CLI + // must agree with it (and with independent viewers), while keeping the file it reads intact. + let bytes = read(path)?; + let password = flag(args, "--password"); + // The renderer takes only the user password of R2–R4 files; `Session::open` and the display + // copy take either. + let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; + session.get(id).ok_or("render: the document could not be opened")?.display.clone() + } else { + pdfcraft_engine::display_bytes_for(bytes, password) + }; + let mut r = + PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() }); + let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); + if let Some(e) = p.error { + return Err(e.into()); + } + let bytes = match format { + // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. + Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, + None => { + // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. + let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); + pam.extend_from_slice(&p.rgba); + pam + } + }; + std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; + let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); + for warning in &p.warnings { + match warning { + pdfcraft_render::RenderWarning::ContentTruncated => { + let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); + } + pdfcraft_render::RenderWarning::ColourFallback => { + let _ = + writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); + } + } + } + Ok(()) +} diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 5888e51114..0c7fa9ba22 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -53,6 +53,20 @@ macro_rules! mcp_usage { }; } +mod check; +mod commands; +mod options; +mod run; +mod ui; + +use check::{audit, check, check_one}; +use commands::{combine, edit, extract, info, render, split, text}; +use options::{OptionSpec, flag, validate_options}; +#[cfg(feature = "mcp")] +use run::mcp; +use run::{automation, print_output, run, tools}; +use ui::ui; + /// The full usage, printed by `--help` (also after a command, e.g. `render --help`). const USAGE: &str = concat!( "\ @@ -84,7 +98,24 @@ help and feedback: https://discord.gg/artcraft" ); fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); + // `std::env::args` panics on an argument that is not UTF-8 ("called `Result::unwrap()` on an + // `Err` value"), which a Latin-1 file name is, and it panics while collecting them: even + // `--help` ended that way. Such a name cannot be passed on either, since the paths here are + // `str`, so it is a usage error (photocraft#1108). + let mut args: Vec = Vec::new(); + for arg in std::env::args_os().skip(1) { + match arg.into_string() { + Ok(a) => args.push(a), + Err(bad) => { + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: `{}` is not valid UTF-8, so it cannot be used as an argument", + bad.to_string_lossy() + ); + return ExitCode::FAILURE; + } + } + } // ` --help` too; only right after the command, so a later `-h` (a value) still reaches it. let command = match args.get(1).map(String::as_str) { Some("--help" | "-h") => Some("--help"), @@ -164,947 +195,3 @@ fn version() -> Result<(), CliError> { stdout_line(format_args!("Source: {}", pdfcraft_engine::links::GITHUB))?; Ok(()) } - -/// Audit a document for faux redactions. Prints the findings as JSON; fails when any are -/// found so scripts and CI can gate on it, and when the audit itself cannot run. -fn audit(args: &[String]) -> Result<(), CliError> { - validate_options("audit", args, AUDIT_OPTIONS)?; - let path = *positional(args).first().ok_or("audit: missing file")?; - let bytes = read(path)?; - let password = flag(args, "--password"); - let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; - let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?; - // Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions` - // reports them; the page geometry is read only when there is something to place. - let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) }; - let items: Vec = findings - .into_iter() - .map(|f| { - let rect = f.rect.map(|v| v as f32); - serde_json::json!({ - "page": f.page + 1, - "kind": f.kind.id(), - "rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)), - "covered_text": f.covered_text, - }) - }) - .collect(); - println!("{}", serde_json::json!({ "file": path, "findings": items, "count": items.len() })); - if items.is_empty() { Ok(()) } else { Err(format!("audit: {} faux redaction(s) found", items.len()).into()) } -} - -fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { - args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) -} - -#[derive(Clone, Copy)] -struct OptionSpec { - name: &'static str, - takes_value: bool, -} - -/// Refuse unknown options and malformed confinement flags before any file is read. -fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { - if specs.iter().any(|spec| spec.name == "--root") { - // `flag` searches every token, including another option's value. Match that lookup so - // a malformed confinement flag cannot be hidden there and fall back to no root. - let mut root_seen = false; - for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") { - if root_seen { - return Err(format!("{command}: --root may only be supplied once").into()); - } - root_seen = true; - if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) { - return Err(format!("{command}: --root needs a directory value").into()); - } - } - } - let mut i = 0; - while let Some(arg) = args.get(i) { - if let Some(name) = arg.strip_prefix("--") { - let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { - return Err(format!("{command}: unknown option --{name}").into()); - }; - // Other missing or malformed values are left to the command, which reports them in its own - // terms (`text` and `extract` explain what a page value must be). - i += if spec.takes_value { 2 } else { 1 }; - } else { - i += 1; - } - } - Ok(()) -} - -const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; -const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; -const TEXT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }]; -const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }]; -const EXTRACT_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--pages", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const SPLIT_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--every", takes_value: true }, - OptionSpec { name: "--before", takes_value: true }, - OptionSpec { name: "--out-dir", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const RENDER_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--page", takes_value: true }, - OptionSpec { name: "--dpi", takes_value: true }, - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--password", takes_value: true }, -]; -const CHECK_ONE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }]; -const CHECK_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--timeout", takes_value: true }, - OptionSpec { name: "--dpi", takes_value: true }, - OptionSpec { name: "--json", takes_value: true }, -]; -const RUN_OPTIONS: &[OptionSpec] = &[ - OptionSpec { name: "--root", takes_value: true }, - OptionSpec { name: "--script", takes_value: true }, - OptionSpec { name: "--out", takes_value: true }, - OptionSpec { name: "--report", takes_value: false }, -]; -const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }]; - -#[cfg(feature = "mcp")] -const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; - -fn positional(args: &[String]) -> Vec<&str> { - let mut out = Vec::new(); - let mut skip = false; - for a in args { - if skip { - skip = false; - continue; - } - if a.starts_with("--") { - skip = true; - continue; - } - out.push(a.as_str()); - } - out -} - -fn read(path: &str) -> Result>, String> { - std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) -} - -fn info(args: &[String]) -> Result<(), CliError> { - validate_options("info", args, INFO_OPTIONS)?; - let path = *positional(args).first().ok_or("info: missing file")?; - let bytes = read(path)?; - let password = flag(args, "--password"); - let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?; - let pdf_pages = info.pages.len(); - let acroform_fields = info.fields.len(); - // Dynamic XFA forms: say what laying the template out gives (what the app shows). - let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) - .then(|| { - let mut s = pdfcraft_engine::Session::new(); - let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; - let d = s.get(id)?; - d.xfa.as_ref().map(|x| { - info = d.info.clone(); - // The in-memory layout is an appended revision, not the file being inspected. - info.file_size = bytes.len(); - serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) - }) - }) - .flatten(); - // What the document declares as a standard, read the way the Standards panel reads it, so - // `info` and the app agree. A file that declares nothing leaves these null and empty. - let declared = - pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); - let json = serde_json::json!({ - "file": path, - "pdf_version": info.pdf_version, - "file_size": info.file_size, - "pages": info.pages.len(), - "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, - "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), - "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, - "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, - "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())), - "pdfua": declared.pdfua, - "output_intents": declared.output_intents, - "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), - "xfa_layout": xfa_layout, - "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), - "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), - "transitions": info.transitions.len(), - "custom_properties": info.custom.len(), - "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), - "warnings": info.warnings, - }); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?; - Ok(()) -} - -fn text(args: &[String]) -> Result<(), CliError> { - validate_options("text", args, TEXT_OPTIONS)?; - let path = *positional(args).first().ok_or("text: missing file")?; - let mut selected_page = None; - // Validate every supplied value before reading, retaining the first valid selection. - let mut options = args.iter(); - while let Some(option) = options.next() { - if option == "--page" { - let page = options - .next() - .and_then(|value| value.parse::().ok()) - .filter(|page| *page > 0) - .ok_or("bad --page: expected a positive page number")?; - selected_page.get_or_insert(page - 1); - } else if option.starts_with("--") { - // Match positional(): an option's operand is not itself another option. - options.next(); - } - } - let password = flag(args, "--password"); - let mut bytes = read(path)?; - // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the - // permissions password, a document that doesn't allow copying gives no text. - let mut session = pdfcraft_engine::Session::new(); - if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { - // The engine's hint is about editing in the app; on the command line the way in is - // `--password`. - doc.check_copying() - .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; - if doc.xfa.is_some() { - bytes = doc.display.clone(); - } - } - let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() }); - // A document the renderer could not open (wrong or missing password, unparseable file) has - // no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask - // `inspect` why instead, so a protected file fails the same way `info` does. - if r.page_count() == 0 { - let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?; - if !info.pages.is_empty() { - return Err("text: the document could not be parsed".into()); - } - } - let pages: Vec = match selected_page { - Some(page) => vec![page], - None => (0..r.page_count()).collect(), - }; - let mut failed: Vec = Vec::new(); - for (n, p) in pages.iter().enumerate() { - let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); - if let Some(e) = out.error { - let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1); - failed.push(p + 1); - continue; - } - if n > 0 { - stdout_line(format_args!("\u{c}"))?; - } - stdout_line(format_args!("{}", out.text.map(|t| t.plain_text()).unwrap_or_default()))?; - } - if failed.is_empty() { - Ok(()) - } else { - Err(format!("text: page(s) {} could not be read", failed.iter().map(usize::to_string).collect::>().join(", ")).into()) - } -} - -/// The most pages one `A-B` range may name: a bound on what a typed range can allocate. -const MAX_RANGE: usize = 1_000_000; - -/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based -/// indices (#1923). -fn page_list(s: &str) -> Result, String> { - let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}")); - let mut out = Vec::new(); - for item in s.split(',').map(str::trim) { - match item.split_once('-') { - Some((from, to)) => { - let (from, to) = (number(from, item)?, number(to, item)?); - if to < from { - return Err(format!("bad page range {item:?}: it ends before it starts")); - } - if to - from >= MAX_RANGE { - return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages")); - } - out.extend(from - 1..to); - } - None => out.push(number(item, item)? - 1), - } - } - Ok(out) -} - -/// The options `edit` understands, for its "unknown option" message. -const EDIT_OPTIONS: &str = "--out, --password, --full, --rotate, --delete, --move, --insert-blank, --title, --author"; - -/// Apply page and metadata edits through the engine and save (incrementally unless `--full`). -/// -/// Every argument is parsed before the file is opened: an option this command does not know -/// (a typo like `--rotat`) is an error, not something to skip — skipping it would write an -/// unchanged copy and exit 0, which looks like a successful edit (#133). -fn edit(args: &[String]) -> Result<(), CliError> { - use pdfcraft_engine::{Edit, Session}; - let mut path: Option<&str> = None; - let mut out: Option<&str> = None; - let mut password: Option<&str> = None; - let mut full = false; - let mut edits = Vec::new(); - let mut i = 0; - while let Some(arg) = args.get(i).map(String::as_str) { - // Options that take no value, and the input file. - match arg { - "--full" => { - full = true; - i += 1; - continue; - } - a if a.starts_with("--") => {} - a => { - if path.is_some() { - return Err(format!("edit: unexpected argument {a:?} (one input file, then options)").into()); - } - path = Some(a); - i += 1; - continue; - } - } - let value = args.get(i + 1).map(String::as_str).ok_or_else(|| format!("edit: {arg} needs a value"))?; - match arg { - "--out" => out = Some(value), - "--password" => password = Some(value), - "--rotate" => { - let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?; - edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? }); - } - "--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }), - "--move" => { - let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?; - let to: usize = to.parse().map_err(|_| "bad target")?; - edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? }); - } - "--insert-blank" => { - let at: usize = value.parse().map_err(|_| "bad position")?; - edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 }); - } - "--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }), - "--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }), - other => return Err(format!("edit: unknown option {other:?} (expected one of: {EDIT_OPTIONS})").into()), - } - i += 2; - } - let path = path.ok_or("edit: missing file")?; - let out = out.ok_or("edit: missing --out")?; - let mut session = Session::new(); - let id = session.open(path, Some(path.to_string()), read(path)?, password).map_err(|e| e.to_string())?; - for e in edits { - // A destination past the end must fail like `page_move` / `page_insert_blank` do, not - // silently become an append (#1330). Checked per edit: earlier edits change the count. - let n = session.get(id).map_or(0, |d| d.info.pages.len()); - let destination = match &e { - Edit::MovePages { to, .. } => Some(("--move target", *to)), - Edit::InsertBlankPage { at, .. } => Some(("--insert-blank", *at)), - _ => None, - }; - if let Some((option, at)) = destination.filter(|(_, at)| *at > n) { - return Err(format!("{option} {} is past the end: it must be between 1 and {} (the document has {n} pages)", at + 1, n + 1).into()); - } - // Pages count from the document as the earlier options left it, so a page past the end is - // reported with its own option and the current page count (#1550). - let listed = match &e { - Edit::RotatePages { pages, .. } => Some(("--rotate", pages)), - Edit::DeletePages { pages } => Some(("--delete", pages)), - Edit::MovePages { pages, .. } => Some(("--move", pages)), - _ => None, - }; - if let Some((option, bad)) = listed.and_then(|(option, pages)| pages.iter().find(|p| **p >= n).map(|p| (option, *p))) { - return Err(format!("{option}: page {} does not exist; after the earlier options the document has {n} pages", bad + 1).into()); - } - session.apply(id, e).map_err(|e| e.to_string())?; - } - let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; - // As the app's Save: the file must reopen with its pages before it replaces anything (#1698). - session.check_save(id, &bytes).map_err(|e| e.to_string())?; - write_pdf(Path::new(out), &bytes) -} - -/// Write a PDF a command produced, through a staging file beside the target (#2166): a write -/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was. -fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> { - use pdfcraft_platform::staging::{StagingName, write_atomically}; - write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into()) -} - -fn file_stem(path: &str) -> String { - Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()) -} - -fn combine(args: &[String]) -> Result<(), CliError> { - validate_options("combine", args, COMBINE_OPTIONS)?; - let out = flag(args, "--out").ok_or("combine: missing --out")?; - let inputs = positional(args); - if inputs.len() < 2 { - return Err("combine: give at least two input files".into()); - } - let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?; - let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?; - write_pdf(Path::new(out), &bytes) -} - -fn extract(args: &[String]) -> Result<(), CliError> { - validate_options("extract", args, EXTRACT_OPTIONS)?; - let path = *positional(args).first().ok_or("extract: missing file")?; - let out = flag(args, "--out").ok_or("extract: missing --out")?; - let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; - let mut session = pdfcraft_engine::Session::new(); - let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; - let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?; - write_pdf(Path::new(out), &bytes) -} - -fn split(args: &[String]) -> Result<(), CliError> { - validate_options("split", args, SPLIT_OPTIONS)?; - use pdfcraft_engine::SplitBy; - let path = *positional(args).first().ok_or("split: missing file")?; - let by = match (flag(args, "--every"), flag(args, "--before")) { - (Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?), - (None, Some(list)) => SplitBy::Before(page_list(list)?), - _ => return Err("split: give either --every N or --before PAGES".into()), - }; - let dir = PathBuf::from(flag(args, "--out-dir").unwrap_or(".")); - let mut session = pdfcraft_engine::Session::new(); - let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; - let stem = file_stem(path); - let parts = session.split(id, &by).map_err(|e| e.to_string())?; - // An output folder that does not exist yet is created (#249); one that can't be is reported - // as the folder's problem, not as the first part's. - std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?; - for (a, b, bytes) in parts { - let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") }); - write_pdf(&name, &bytes)?; - stdout_line(format_args!("{}", name.display()))?; - } - Ok(()) -} - -fn render(args: &[String]) -> Result<(), CliError> { - validate_options("render", args, RENDER_OPTIONS)?; - let path = *positional(args).first().ok_or("render: missing file")?; - let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?; - let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?; - let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?; - let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?; - // The file is what its name says (#248): a `.png` used to get a netpbm PAM stream. - let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() { - Some("png") => Some(ImageFormat::Png), - Some("jpg" | "jpeg") => Some(ImageFormat::Jpeg { quality: 90 }), - Some("tif" | "tiff") => Some(ImageFormat::Tiff), - Some("pam") => None, - _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), - }; - // The app renders a display copy that draws comments missing their /AP streams; the CLI - // must agree with it (and with independent viewers), while keeping the file it reads intact. - let bytes = read(path)?; - let password = flag(args, "--password"); - let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { - let mut session = pdfcraft_engine::Session::new(); - let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; - session.get(id).ok_or("render: the document could not be opened")?.display.clone() - } else { - pdfcraft_engine::display_bytes_for(bytes, password) - }; - let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() }); - let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); - if let Some(e) = p.error { - return Err(e.into()); - } - let bytes = match format { - // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. - Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, - None => { - // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. - let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); - pam.extend_from_slice(&p.rgba); - pam - } - }; - std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?; - let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis); - for warning in &p.warnings { - match warning { - pdfcraft_render::RenderWarning::ContentTruncated => { - let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); - } - } - } - Ok(()) -} - -/// Child-process body for `check`: prints one JSON line. -fn check_one(args: &[String]) -> Result<(), CliError> { - validate_options("check-one", args, CHECK_ONE_OPTIONS)?; - let path = *positional(args).first().ok_or("check-one: missing file")?; - let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?; - let start = Instant::now(); - let bytes = read(path)?; - let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { - Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), - Ok(info) => { - let mut r = PageRenderer::new(bytes, RenderConfig::default()); - let mut failed = Vec::new(); - for i in 0..info.pages.len().min(500) { - let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); - if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error { - failed.push(format!("p{} text: {e}", i + 1)); - } - if let Some(e) = p.error { - failed.push(format!("p{}: {e}", i + 1)); - } - } - let status = if failed.is_empty() { "ok".to_string() } else { "page-errors".to_string() }; - (status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len()) - } - }; - // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, - // so a panic in cos shows up as a crash of this process. - if args.iter().any(|a| a == "--edit") { - edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); - } - let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, - "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); - stdout_line(format_args!("{line}"))?; - Ok(()) -} - -/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both -/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. -fn edit_round_trip(bytes: &[u8]) { - use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; - let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; - for num in doc.object_numbers().into_iter().take(20_000) { - if let Ok(o) = doc.try_get(num) - && let Object::Stream(s) = &*o - { - let _ = s.decoded(); - } - } - if let Some(root) = doc.root() { - let _ = doc.update_dict(root, |d| d.set(b"PdfCraftFuzz".to_vec(), Object::Bool(true))); - } - let classic = SaveOptions { object_streams: false, ..SaveOptions::default() }; - for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)] - .into_iter() - .flatten() - { - if let Ok(again) = Document::open(Arc::new(out)) { - let _ = again.root().map(|r| again.get(r)); - } - } -} - -fn short(s: &str) -> String { - s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::>().join("-").to_lowercase() -} - -fn collect(paths: &[&str]) -> Vec { - let mut out = Vec::new(); - let mut stack: Vec = paths.iter().map(PathBuf::from).collect(); - let mut visited = std::collections::HashSet::new(); - while let Some(p) = stack.pop() { - if p.is_dir() { - // Directory aliases and symlink cycles must not scan the same tree repeatedly. - let identity = match p.canonicalize() { - Ok(identity) => identity, - Err(error) => { - eprintln!("{}: could not resolve directory: {error}", p.display()); - continue; - } - }; - if !visited.insert(identity) { - continue; - } - if let Ok(rd) = std::fs::read_dir(&p) { - stack.extend(rd.flatten().map(|e| e.path())); - } - } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { - out.push(p); - } - } - out.sort(); - out -} - -fn check(args: &[String]) -> Result<(), CliError> { - validate_options("check", args, CHECK_OPTIONS)?; - let files = collect(&positional(args)); - if files.is_empty() { - return Err("check: no PDF files found".into()); - } - let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?); - let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - let mut results = Vec::new(); - let mut counts: std::collections::BTreeMap = Default::default(); - for (n, f) in files.iter().enumerate() { - let r = run_child(&exe, f, &dpi, timeout); - let status = r["status"].as_str().unwrap_or("?").to_string(); - *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; - if status != "ok" { - eprintln!( - "[{}/{}] {status:<28} {} {}", - n + 1, - files.len(), - f.display(), - r["detail"].as_str().unwrap_or("").chars().take(160).collect::() - ); - } - results.push(r); - } - eprintln!("\nchecked {} files: {:?}", files.len(), counts); - if let Some(out) = flag(args, "--json") { - std::fs::write(out, serde_json::to_string_pretty(&results).unwrap_or_default()).map_err(|e| e.to_string())?; - } - Ok(()) -} - -fn run_child(exe: &Path, file: &Path, dpi: &str, timeout: Duration) -> serde_json::Value { - let file_s = file.to_string_lossy().to_string(); - let child = Command::new(exe).args(["check-one", &file_s, "--dpi", dpi]).stdout(Stdio::piped()).stderr(Stdio::null()).spawn(); - let mut child = match child { - Ok(c) => c, - Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), - }; - let start = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(status)) => { - let mut out = String::new(); - if let Some(mut so) = child.stdout.take() { - use std::io::Read; - let _ = so.read_to_string(&mut out); - } - return match serde_json::from_str::(out.trim()) { - Ok(v) => v, - Err(_) => serde_json::json!({ "file": file_s, "status": "crash", "detail": format!("child exited with {status}") }), - }; - } - Ok(None) if start.elapsed() > timeout => { - let _ = child.kill(); - let _ = child.wait(); - return serde_json::json!({ "file": file_s, "status": "timeout", "detail": format!("exceeded {}s", timeout.as_secs()) }); - } - Ok(None) => std::thread::sleep(Duration::from_millis(20)), - Err(e) => return serde_json::json!({ "file": file_s, "status": "harness-error", "detail": e.to_string() }), - } - } -} - -// ---- automation -------------------------------------------------------------------------------- - -fn automation(args: &[String]) -> Result { - let a = pdfcraft_automation::Automation::new(); - match flag(args, "--root") { - Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")), - None => Ok(a), - } -} - -fn tools() -> Result<(), CliError> { - let list: Vec = pdfcraft_automation::tools() - .iter() - .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema })) - .collect(); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?; - Ok(()) -} - -/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is -/// written like a tool's own output, so `--root` confines it too. -fn print_output(auto: &pdfcraft_automation::Automation, content: Vec, out: Option<&str>) -> Result<(), CliError> { - for c in content { - match c { - pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?, - pdfcraft_automation::Content::Png { data, width, height } => match out { - Some(path) => { - let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; - stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?; - } - None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?, - }, - } - } - Ok(()) -} - -/// What a PNG result is reported as when it isn't written to a file. -fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value { - serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) -} - -/// A tool's result as JSON values, for the `--report` of a script. Same content as -/// [`print_output`], collected instead of printed. -fn output_values( - auto: &pdfcraft_automation::Automation, - content: Vec, - out: Option<&str>, -) -> Result, String> { - let mut values = Vec::new(); - for c in content { - values.push(match c { - pdfcraft_automation::Content::Json(v) => v, - pdfcraft_automation::Content::Png { data, width, height } => match out { - Some(path) => { - let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; - serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height }) - } - None => png_summary(&data, width, height), - }, - }); - } - Ok(values) -} - -/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped` -/// with the result or the message. The first failing step ends the run, as without `--report`; -/// the steps after it are listed as skipped and the exit status is the same. -fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> { - let mut report = Vec::with_capacity(steps.len()); - let mut failure: Option = None; - for (i, step) in steps.iter().enumerate() { - let n = i + 1; - let tool = step["tool"].as_str(); - if failure.is_some() { - report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" })); - continue; - } - let outcome = match tool { - None => Err(format!("step {n}: missing \"tool\"")), - Some(t) => auto - .call(t, &step["args"]) - .map_err(|e| e.to_string()) - .and_then(|content| output_values(auto, content, step["out"].as_str())) - .map_err(|e| format!("step {n} ({t}): {e}")), - }; - match outcome { - Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })), - Err(message) => { - report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message })); - failure = Some(message); - } - } - } - let document = serde_json::json!({ "ok": failure.is_none(), "steps": report }); - stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?; - failure.map_or(Ok(()), |message| Err(CliError::Message(message))) -} - -fn run(args: &[String]) -> Result<(), CliError> { - validate_options("run", args, RUN_OPTIONS)?; - let report = args.iter().any(|a| a == "--report"); - if report && flag(args, "--script").is_none() { - return Err("run: --report needs --script".to_string().into()); - } - let mut auto = automation(args)?; - if let Some(script) = flag(args, "--script") { - let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; - let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?; - if report { - return run_script_report(&mut auto, &steps); - } - for (i, step) in steps.iter().enumerate() { - let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?; - let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?; - print_output(&auto, content, step["out"].as_str()).map_err(|e| match e { - CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)), - stdout => stdout, - })?; - } - return Ok(()); - } - let tool = *positional(args).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; - let mut obj = serde_json::Map::new(); - for kv in positional(args).iter().skip(1) { - let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; - let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); - obj.insert(k.to_string(), value); - } - let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?; - print_output(&auto, content, flag(args, "--out")) -} - -/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`, -/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments -/// that are left. A malformed root flag is an error, never a root silently left out. -#[cfg(feature = "mcp")] -fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> { - let mut found = automation_args::AutomationArgs::new(None, None); - let mut left = Vec::new(); - let mut it = args.iter().cloned(); - while let Some(arg) = it.next() { - if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? { - left.push(arg); - } - } - let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref()) - .map_err(|e| format!("{command}: {e}"))?; - Ok((roots, left)) -} - -#[cfg(feature = "mcp")] -fn mcp(args: &[String]) -> Result<(), CliError> { - let (roots, args) = automation_roots("mcp", args)?; - let args = args.as_slice(); - validate_options("mcp", args, MCP_OPTIONS)?; - let compact = args.iter().any(|a| a == "--compact"); - let mut auto = automation(args)?; - if let Some(roots) = roots { - let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); - let _ = writeln!( - std::io::stderr().lock(), - "pdfcraft-cli: files confined: read root {}, write root {}", - show(roots.read_root()), - show(roots.write_root()) - ); - auto = auto.with_automation_roots(Some(roots)); - } - let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact); - let _ = writeln!( - std::io::stderr().lock(), - "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop", - pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0], - if compact { ", compact tool list" } else { "" } - ); - server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string())) -} - -// ---- UI control channel client ----------------------------------------------------------------- - -/// One request to a running app's control channel (`pdfcraft --control FILE`). -fn ui(args: &[String]) -> Result<(), CliError> { - validate_options("ui", args, UI_OPTIONS)?; - use std::io::{BufRead, BufReader, Write as _}; - let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?; - let info = read_control_file(file)?; - let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?; - // Refused rather than truncated, which would quietly reach some other port. - let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; - let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; - let pos = positional(args); - let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; - let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; - let mut params = serde_json::Map::new(); - for kv in pos.iter().skip(1) { - let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?; - params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()))); - } - let stream = - std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?; - stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?; - let mut write = stream.try_clone().map_err(|e| e.to_string())?; - let mut lines = BufReader::new(stream).lines(); - let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result { - writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?; - let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?; - let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; - match reply.get("error") { - Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()), - None => Ok(reply["result"].clone()), - } - }; - rpc(1, "auth", serde_json::json!({ "token": token }))?; - let mut result = rpc(2, &method, serde_json::Value::Object(params))?; - if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) { - use base64::Engine as _; - let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?; - std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?; - result["png_base64"] = serde_json::json!(format!("written to {out}")); - } - stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?; - Ok(()) -} - -/// Most a control file may hold. The app writes under 100 bytes. -const CONTROL_FILE_MAX: u64 = 64 * 1024; - -const CONTROL_FILE_ADVICE: &str = - "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; - -/// Read the file written by `pdfcraft --control FILE`. -/// -/// It names the port that receives the token and every command, typed text included, so it must -/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file -/// first; the app then can't write it, and the CLI would talk to the other user's listener. -/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone -/// else or open to other users (the app writes it with mode 600). -fn read_control_file(file: &str) -> Result { - use std::io::Read as _; - let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; - if at_path.file_type().is_symlink() { - return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); - } - if !at_path.is_file() { - return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); - } - let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. - let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; - if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { - return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); - } - if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { - return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); - } - } - let mut text = String::new(); - f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; - if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { - return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); - } - serde_json::from_str(&text).map_err(|e| format!("{file}: {e}")) -} - -/// Why user `me` can't trust a control file with this owner and mode, if they can't. -#[cfg(any(test, unix))] -fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option { - if owner != me { - return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands")); - } - let permissions = mode & 0o7777; - (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)")) -} - -/// The effective user id: the owner of the files this process creates, and so of a control file -/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is -/// safe and can't fail. -#[cfg(unix)] -fn current_uid() -> u32 { - rustix::process::geteuid().as_raw() -} - -#[cfg(test)] -mod tests { - use super::control_file_problem; - - #[test] - fn a_control_file_must_be_yours_and_private() { - // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. - assert_eq!(control_file_problem(1000, 0o100600, 1000), None); - assert_eq!(control_file_problem(1000, 0o600, 1000), None); - assert_eq!(control_file_problem(1000, 0o400, 1000), None); - let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default(); - assert!(other.contains("owned by another user") && other.contains("1001"), "{other}"); - // Root gets no exception: a file a user planted is still theirs. - assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user"))); - for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] { - let open = control_file_problem(1000, mode, 1000).unwrap_or_default(); - assert!(open.contains("other users"), "{mode:o}: {open}"); - } - assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644")); - } -} diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs new file mode 100644 index 0000000000..1ddbfabcd5 --- /dev/null +++ b/apps/pdfcraft-cli/src/options.rs @@ -0,0 +1,148 @@ +use super::CliError; + +pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +#[derive(Clone, Copy)] +pub(crate) struct OptionSpec { + pub(crate) name: &'static str, + pub(crate) takes_value: bool, +} + +/// The options whose value is a file or folder. Only these refuse a value that is another +/// option: a file named `--password` is never what was meant, while a password can be anything +/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title. +/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered. +const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"]; + +/// Whether a token can be the value of a path option. Another option cannot: `--out --password` +/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944). +pub(crate) fn can_be_path_value(name: &str, value: Option<&str>) -> bool { + !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--")) +} + +/// The complaint about a path option whose value is missing or is another option. +pub(crate) fn needs_value(command: &str, name: &str, value: Option<&str>) -> String { + match value { + Some(v) if !v.is_empty() => { + format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name") + } + _ => format!("{command}: {name} needs a value"), + } +} + +/// Refuse unknown options and malformed confinement flags before any file is read. +pub(crate) fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { + if specs.iter().any(|spec| spec.name == "--root") { + // `flag` searches every token, including another option's value. Match that lookup so + // a malformed confinement flag cannot be hidden there and fall back to no root. + let mut root_seen = false; + for (i, _) in args.iter().enumerate().filter(|(_, arg)| arg.as_str() == "--root") { + if root_seen { + return Err(format!("{command}: --root may only be supplied once").into()); + } + root_seen = true; + if args.get(i + 1).is_none_or(|value| value.is_empty() || value.starts_with("--")) { + return Err(format!("{command}: --root needs a directory value").into()); + } + } + } + let mut i = 0; + while let Some(arg) = args.get(i) { + if let Some(name) = arg.strip_prefix("--") { + let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { + return Err(format!("{command}: unknown option --{name}").into()); + }; + // A file's name is not another option, which also closes the gap the walk left for + // those: an unknown option in that value position was skipped along with it. Other + // values, missing or malformed, are left to the command, which reports them in its + // own terms (`text` and `extract` explain what a page value must be). + i += if spec.takes_value { + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(spec.name, value) { + return Err(needs_value(command, spec.name, value).into()); + } + 2 + } else { + 1 + }; + } else { + i += 1; + } + } + Ok(()) +} + +pub(crate) const INFO_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const AUDIT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const TEXT_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--password", takes_value: true }]; + +pub(crate) const COMBINE_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--out", takes_value: true }]; + +pub(crate) const EXTRACT_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--pages", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const SPLIT_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--every", takes_value: true }, + OptionSpec { name: "--before", takes_value: true }, + OptionSpec { name: "--out-dir", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const RENDER_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--page", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, +]; + +pub(crate) const CHECK_ONE_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--dpi", takes_value: true }, OptionSpec { name: "--edit", takes_value: false }]; + +pub(crate) const CHECK_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--timeout", takes_value: true }, + OptionSpec { name: "--dpi", takes_value: true }, + OptionSpec { name: "--json", takes_value: true }, +]; + +pub(crate) const RUN_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--root", takes_value: true }, + OptionSpec { name: "--script", takes_value: true }, + OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--report", takes_value: false }, +]; + +pub(crate) const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }]; + +#[cfg(feature = "mcp")] +pub(crate) const MCP_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; + +/// The arguments that are not options or their values. Which tokens are values is the spec's +/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing +/// file", the file having been read as `--edit`'s value. +pub(crate) fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(a) = args.get(i) { + match specs.iter().find(|spec| spec.name == a.as_str()) { + Some(spec) if spec.takes_value => i += 2, + // An option that takes no value, and an unknown one, which `validate_options` has + // already refused by the time this runs. + Some(_) => i += 1, + None if a.starts_with("--") => i += 1, + None => { + out.push(a.as_str()); + i += 1; + } + } + } + out +} diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs new file mode 100644 index 0000000000..0726087d9d --- /dev/null +++ b/apps/pdfcraft-cli/src/run.rs @@ -0,0 +1,188 @@ +#[cfg(feature = "mcp")] +use super::options::MCP_OPTIONS; +use super::options::{RUN_OPTIONS, flag, positional, validate_options}; +#[cfg(feature = "mcp")] +use super::{Arc, Path, Write, automation_args}; +use super::{CliError, stdout_line}; + +// ---- automation -------------------------------------------------------------------------------- + +pub(crate) fn automation(args: &[String]) -> Result { + let a = pdfcraft_automation::Automation::new(); + match flag(args, "--root") { + Some(root) => a.with_root(root).map_err(|e| format!("--root {root}: {e}")), + None => Ok(a), + } +} + +pub(crate) fn tools() -> Result<(), CliError> { + let list: Vec = pdfcraft_automation::tools() + .iter() + .map(|t| serde_json::json!({ "name": t.name, "description": t.description, "read_only": t.read_only, "command": t.command, "input_schema": t.input_schema })) + .collect(); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()))?; + Ok(()) +} + +/// Print a tool's result: JSON as JSON; images go to `--out` (or are summarised). The image is +/// written like a tool's own output, so `--root` confines it too. +pub(crate) fn print_output( + auto: &pdfcraft_automation::Automation, + content: Vec, + out: Option<&str>, +) -> Result<(), CliError> { + for c in content { + match c { + pdfcraft_automation::Content::Json(v) => stdout_line(format_args!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()))?, + pdfcraft_automation::Content::Png { data, width, height } => match out { + Some(path) => { + let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; + stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?; + } + None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?, + }, + } + } + Ok(()) +} + +/// What a PNG result is reported as when it isn't written to a file. +fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value { + serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) +} + +/// A tool's result as JSON values, for the `--report` of a script. Same content as +/// [`print_output`], collected instead of printed. +fn output_values( + auto: &pdfcraft_automation::Automation, + content: Vec, + out: Option<&str>, +) -> Result, String> { + let mut values = Vec::new(); + for c in content { + values.push(match c { + pdfcraft_automation::Content::Json(v) => v, + pdfcraft_automation::Content::Png { data, width, height } => match out { + Some(path) => { + let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; + serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height }) + } + None => png_summary(&data, width, height), + }, + }); + } + Ok(values) +} + +/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped` +/// with the result or the message. The first failing step ends the run, as without `--report`; +/// the steps after it are listed as skipped and the exit status is the same. +fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> { + let mut report = Vec::with_capacity(steps.len()); + let mut failure: Option = None; + for (i, step) in steps.iter().enumerate() { + let n = i + 1; + let tool = step["tool"].as_str(); + if failure.is_some() { + report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" })); + continue; + } + let outcome = match tool { + None => Err(format!("step {n}: missing \"tool\"")), + Some(t) => auto + .call(t, &step["args"]) + .map_err(|e| e.to_string()) + .and_then(|content| output_values(auto, content, step["out"].as_str())) + .map_err(|e| format!("step {n} ({t}): {e}")), + }; + match outcome { + Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })), + Err(message) => { + report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message })); + failure = Some(message); + } + } + } + let document = serde_json::json!({ "ok": failure.is_none(), "steps": report }); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?; + failure.map_or(Ok(()), |message| Err(CliError::Message(message))) +} + +pub(crate) fn run(args: &[String]) -> Result<(), CliError> { + validate_options("run", args, RUN_OPTIONS)?; + let report = args.iter().any(|a| a == "--report"); + if report && flag(args, "--script").is_none() { + return Err("run: --report needs --script".to_string().into()); + } + let mut auto = automation(args)?; + if let Some(script) = flag(args, "--script") { + let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; + let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?; + if report { + return run_script_report(&mut auto, &steps); + } + for (i, step) in steps.iter().enumerate() { + let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?; + let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?; + print_output(&auto, content, step["out"].as_str()).map_err(|e| match e { + CliError::Message(message) => CliError::Message(format!("step {} ({tool}): {message}", i + 1)), + stdout => stdout, + })?; + } + return Ok(()); + } + let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; + let mut obj = serde_json::Map::new(); + for kv in positional(args, RUN_OPTIONS).iter().skip(1) { + let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; + let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); + obj.insert(k.to_string(), value); + } + let content = auto.call(tool, &serde_json::Value::Object(obj)).map_err(|e| e.to_string())?; + print_output(&auto, content, flag(args, "--out")) +} + +/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`, +/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments +/// that are left. A malformed root flag is an error, never a root silently left out. +#[cfg(feature = "mcp")] +fn automation_roots(command: &str, args: &[String]) -> Result<(Option>, Vec), String> { + let mut found = automation_args::AutomationArgs::new(None, None); + let mut left = Vec::new(); + let mut it = args.iter().cloned(); + while let Some(arg) = it.next() { + if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? { + left.push(arg); + } + } + let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref()) + .map_err(|e| format!("{command}: {e}"))?; + Ok((roots, left)) +} + +#[cfg(feature = "mcp")] +pub(crate) fn mcp(args: &[String]) -> Result<(), CliError> { + let (roots, args) = automation_roots("mcp", args)?; + let args = args.as_slice(); + validate_options("mcp", args, MCP_OPTIONS)?; + let compact = args.iter().any(|a| a == "--compact"); + let mut auto = automation(args)?; + if let Some(roots) = roots { + let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: files confined: read root {}, write root {}", + show(roots.read_root()), + show(roots.write_root()) + ); + auto = auto.with_automation_roots(Some(roots)); + } + let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact); + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop", + pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0], + if compact { ", compact tool list" } else { "" } + ); + server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string())) +} diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs new file mode 100644 index 0000000000..dc419171b0 --- /dev/null +++ b/apps/pdfcraft-cli/src/ui.rs @@ -0,0 +1,131 @@ +use super::options::{UI_OPTIONS, flag, positional, validate_options}; +use super::{CliError, Duration, stdout_line}; + +// ---- UI control channel client ----------------------------------------------------------------- + +/// One request to a running app's control channel (`pdfcraft --control FILE`). +pub(crate) fn ui(args: &[String]) -> Result<(), CliError> { + validate_options("ui", args, UI_OPTIONS)?; + use std::io::{BufRead, BufReader, Write as _}; + let file = flag(args, "--control").ok_or("ui: missing --control FILE (start the app with `pdfcraft --control FILE`)")?; + let info = read_control_file(file)?; + let port = info["port"].as_u64().ok_or(format!("{file}: no port"))?; + // Refused rather than truncated, which would quietly reach some other port. + let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; + let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; + let pos = positional(args, UI_OPTIONS); + let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; + let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; + let mut params = serde_json::Map::new(); + for kv in pos.iter().skip(1) { + let (k, v) = kv.split_once('=').ok_or(format!("ui: expected key=value, got {kv:?}"))?; + params.insert(k.to_string(), serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string()))); + } + let stream = + std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("can't reach the app on port {port}: {e} (is it still running?)"))?; + stream.set_read_timeout(Some(Duration::from_secs(40))).map_err(|e| e.to_string())?; + let mut write = stream.try_clone().map_err(|e| e.to_string())?; + let mut lines = BufReader::new(stream).lines(); + let mut rpc = |id: u64, method: &str, params: serde_json::Value| -> Result { + writeln!(write, "{}", serde_json::json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).map_err(|e| e.to_string())?; + let line = lines.next().ok_or("the app closed the connection")?.map_err(|e| e.to_string())?; + let reply: serde_json::Value = serde_json::from_str(&line).map_err(|e| e.to_string())?; + match reply.get("error") { + Some(e) => Err(e["message"].as_str().unwrap_or("error").to_string()), + None => Ok(reply["result"].clone()), + } + }; + rpc(1, "auth", serde_json::json!({ "token": token }))?; + let mut result = rpc(2, &method, serde_json::Value::Object(params))?; + if let (Some(out), Some(data)) = (flag(args, "--out"), result.get("png_base64").and_then(|d| d.as_str())) { + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(data).map_err(|e| e.to_string())?; + std::fs::write(out, png).map_err(|e| format!("{out}: {e}"))?; + result["png_base64"] = serde_json::json!(format!("written to {out}")); + } + stdout_line(format_args!("{}", serde_json::to_string_pretty(&result).unwrap_or_default()))?; + Ok(()) +} + +/// Most a control file may hold. The app writes under 100 bytes. +const CONTROL_FILE_MAX: u64 = 64 * 1024; + +const CONTROL_FILE_ADVICE: &str = + "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; + +/// Read the file written by `pdfcraft --control FILE`. +/// +/// It names the port that receives the token and every command, typed text included, so it must +/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file +/// first; the app then can't write it, and the CLI would talk to the other user's listener. +/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone +/// else or open to other users (the app writes it with mode 600). +fn read_control_file(file: &str) -> Result { + use std::io::Read as _; + let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; + if at_path.file_type().is_symlink() { + return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); + } + if !at_path.is_file() { + return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); + } + let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. + let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; + if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { + return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); + } + if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { + return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); + } + } + let mut text = String::new(); + f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; + if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { + return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); + } + serde_json::from_str(&text).map_err(|e| format!("{file}: {e}")) +} + +/// Why user `me` can't trust a control file with this owner and mode, if they can't. +#[cfg(any(test, unix))] +fn control_file_problem(owner: u32, mode: u32, me: u32) -> Option { + if owner != me { + return Some(format!("owned by another user (uid {owner}; you are uid {me}), who may be the one listening for your commands")); + } + let permissions = mode & 0o7777; + (permissions & 0o077 != 0).then(|| format!("other users have access to it (mode {permissions:o}; the app writes it with mode 600)")) +} + +/// The effective user id: the owner of the files this process creates, and so of a control file +/// the app wrote for this user. std has no `geteuid` and this crate forbids `unsafe`; rustix's is +/// safe and can't fail. +#[cfg(unix)] +fn current_uid() -> u32 { + rustix::process::geteuid().as_raw() +} + +#[cfg(test)] +mod tests { + use super::control_file_problem; + + #[test] + fn a_control_file_must_be_yours_and_private() { + // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. + assert_eq!(control_file_problem(1000, 0o100600, 1000), None); + assert_eq!(control_file_problem(1000, 0o600, 1000), None); + assert_eq!(control_file_problem(1000, 0o400, 1000), None); + let other = control_file_problem(1001, 0o600, 1000).unwrap_or_default(); + assert!(other.contains("owned by another user") && other.contains("1001"), "{other}"); + // Root gets no exception: a file a user planted is still theirs. + assert!(control_file_problem(1000, 0o600, 0).is_some_and(|p| p.contains("owned by another user"))); + for mode in [0o644, 0o640, 0o604, 0o660, 0o606, 0o620, 0o602, 0o610, 0o601, 0o100666] { + let open = control_file_problem(1000, mode, 1000).unwrap_or_default(); + assert!(open.contains("other users"), "{mode:o}: {open}"); + } + assert!(control_file_problem(1000, 0o100644, 1000).unwrap_or_default().contains("mode 644")); + } +} diff --git a/apps/pdfcraft-cli/tests/audit.rs b/apps/pdfcraft-cli/tests/it/audit.rs similarity index 100% rename from apps/pdfcraft-cli/tests/audit.rs rename to apps/pdfcraft-cli/tests/it/audit.rs diff --git a/apps/pdfcraft-cli/tests/automation_roots.rs b/apps/pdfcraft-cli/tests/it/automation_roots.rs similarity index 100% rename from apps/pdfcraft-cli/tests/automation_roots.rs rename to apps/pdfcraft-cli/tests/it/automation_roots.rs diff --git a/apps/pdfcraft-cli/tests/batch.rs b/apps/pdfcraft-cli/tests/it/batch.rs similarity index 100% rename from apps/pdfcraft-cli/tests/batch.rs rename to apps/pdfcraft-cli/tests/it/batch.rs diff --git a/apps/pdfcraft-cli/tests/check.rs b/apps/pdfcraft-cli/tests/it/check.rs similarity index 79% rename from apps/pdfcraft-cli/tests/check.rs rename to apps/pdfcraft-cli/tests/it/check.rs index a206df5912..7a6236419c 100644 --- a/apps/pdfcraft-cli/tests/check.rs +++ b/apps/pdfcraft-cli/tests/it/check.rs @@ -1,4 +1,5 @@ //! The robustness harness visits each directory once, including symlink aliases and cycles. +//! Its `--timeout` and `--dpi` options are checked before anything is scanned. use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -109,3 +110,22 @@ fn check_follows_directory_and_file_symlinks() { assert_eq!(results[0]["status"], "ok"); assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref()); } + +/// `--timeout` and `--dpi` are checked before any input is scanned: a zero timeout used to +/// make every file time out at once, and a dpi that is not a positive finite number used to +/// reach each child, which printed nothing and was reported as a crash per file. +#[test] +fn a_bad_timeout_or_dpi_is_refused_before_scanning() { + let out = Command::new(BIN).args(["check", "no-such-input", "--timeout", "0"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert_eq!(out.stderr, b"pdfcraft-cli: bad --timeout: expected a positive whole number of seconds, got \"0\"\n"); + for value in ["abc", "0", "-3", "NaN"] { + let out = Command::new(BIN).args(["check", "no-such-input", "--dpi", value]).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "--dpi {value}"); + assert_eq!( + out.stderr, + format!("pdfcraft-cli: bad --dpi: expected a positive number of dots per inch, got {value:?}\n").as_bytes(), + "--dpi {value}" + ); + } +} diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/it/edit.rs similarity index 88% rename from apps/pdfcraft-cli/tests/edit.rs rename to apps/pdfcraft-cli/tests/it/edit.rs index b35b71dddc..3cd1442d81 100644 --- a/apps/pdfcraft-cli/tests/edit.rs +++ b/apps/pdfcraft-cli/tests/it/edit.rs @@ -141,9 +141,9 @@ mod one_based_arguments { for destination in ["output.pam", "new.pam", "input.pdf"] { std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap(); for (command, option, value, diagnostic) in [ - ("render", "--page", "0", "--page must be at least 1"), - ("edit", "--move", "1:0", "--move target must be at least 1"), - ("edit", "--insert-blank", "0", "--insert-blank must be at least 1"), + ("render", "--page", "0", "bad --page: expected a positive page number"), + ("edit", "--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""), + ("edit", "--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""), ] { let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap(); assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}"); @@ -194,6 +194,33 @@ mod one_based_arguments { } } +/// A value the option cannot hold names the option and the value, and fails before the input +/// file is opened: a missing input still gets the option's own message. +#[test] +fn a_value_an_option_cannot_hold_is_named_before_the_file_is_opened() { + let dir = tmp("values", ""); + for (option, value, diagnostic) in [ + ("--rotate", "1:abc", "bad --rotate degrees: expected a whole number of degrees, got \"abc\""), + ("--rotate", "1:45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got 45"), + ("--rotate", "1:-45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got -45"), + ("--move", "1:abc", "bad --move target: expected a page number of at least 1, got \"abc\""), + ("--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""), + ("--insert-blank", "abc", "bad --insert-blank position: expected a page position of at least 1, got \"abc\""), + ("--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""), + ] { + let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", option, value]).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "{option} {value}"); + assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}"); + assert!(out.stdout.is_empty(), "{option} {value}"); + assert!(!dir.join("out.pdf").exists(), "{option} {value}: no output may be written"); + } + // A negative multiple of 90 is still a rotation the engine accepts: the refusal above is + // the multiple-of-90 rule, not a sign problem (the missing input is the next error). + let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", "--rotate", "1:-90"]).output().unwrap(); + assert_eq!(out.status.code(), Some(1)); + assert!(!String::from_utf8_lossy(&out.stderr).contains("--rotate degrees"), "{}", String::from_utf8_lossy(&out.stderr)); +} + /// A destination past the end must fail, not silently become an append (#1330). #[test] fn a_destination_past_the_end_fails_and_writes_nothing() { diff --git a/apps/pdfcraft-cli/tests/extract.rs b/apps/pdfcraft-cli/tests/it/extract.rs similarity index 100% rename from apps/pdfcraft-cli/tests/extract.rs rename to apps/pdfcraft-cli/tests/it/extract.rs diff --git a/apps/pdfcraft-cli/tests/help.rs b/apps/pdfcraft-cli/tests/it/help.rs similarity index 100% rename from apps/pdfcraft-cli/tests/help.rs rename to apps/pdfcraft-cli/tests/it/help.rs diff --git a/apps/pdfcraft-cli/tests/info_standards.rs b/apps/pdfcraft-cli/tests/it/info_standards.rs similarity index 100% rename from apps/pdfcraft-cli/tests/info_standards.rs rename to apps/pdfcraft-cli/tests/it/info_standards.rs diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs new file mode 100644 index 0000000000..f0b87c97a7 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/main.rs @@ -0,0 +1,24 @@ +//! The crate's integration tests, linked into one test binary. + +mod audit; +mod automation_roots; +mod batch; +mod check; +mod edit; +mod extract; +mod help; +mod info_standards; +mod mcp_compact; +mod options; +mod owner_password; +mod render; +mod root_arguments; +mod run_report; +mod run_root; +mod split; +mod stderr; +mod stdout; +mod text; +mod ui_control; +mod version_resource; +mod xfa; diff --git a/apps/pdfcraft-cli/tests/mcp_compact.rs b/apps/pdfcraft-cli/tests/it/mcp_compact.rs similarity index 100% rename from apps/pdfcraft-cli/tests/mcp_compact.rs rename to apps/pdfcraft-cli/tests/it/mcp_compact.rs diff --git a/apps/pdfcraft-cli/tests/it/options.rs b/apps/pdfcraft-cli/tests/it/options.rs new file mode 100644 index 0000000000..7955157e28 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/options.rs @@ -0,0 +1,115 @@ +//! Every command rejects misspelled long options before opening or writing a file. + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +#[test] +fn non_edit_commands_reject_unknown_options_before_io() { + for args in [ + vec!["info", "missing.pdf", "--passwrod", "secret"], + vec!["text", "missing.pdf", "--pgae", "1"], + vec!["combine", "a.pdf", "b.pdf", "--ot", "out.pdf"], + vec!["extract", "missing.pdf", "--pgaes", "1", "--out", "out.pdf"], + vec!["split", "missing.pdf", "--ever", "2"], + vec!["render", "missing.pdf", "--dppi", "96", "--out", "out.png"], + vec!["check-one", "missing.pdf", "--edti"], + vec!["check", "missing.pdf", "--timout", "2"], + vec!["audit", "missing.pdf", "--passwrod", "x"], + vec!["audit", "missing.pdf", "--nonsense", "1"], + vec!["run", "doc_open", "--roto", "/tmp"], + vec!["ui", "--contro", "control.json", "state"], + ] { + let unknown = args.iter().find(|arg| arg.starts_with("--")).expect("test option"); + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?} unexpectedly succeeded"); + assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}"); + } +} + +/// photocraft#6944: an option took the next token whatever it was, so a line with a value missing +/// in the middle of it did something else entirely — `extract in.pdf --pages 1 --out --password` +/// wrote the PDF to a file called `--password` and exited 0. The unknown option hidden in a value +/// position passed the check above too, since the walk skipped it along with the option. +/// +/// Only the options whose value is a file or folder refuse one: a password or a title can be any +/// string, `--page` included, which `text`'s own tests pin and this does not change. +#[test] +fn a_file_option_refuses_a_value_that_is_another_option() { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-values-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + for (option, args) in [ + ("--out", vec!["extract", "missing.pdf", "--pages", "1", "--out", "--password"]), + ("--out", vec!["combine", "a.pdf", "b.pdf", "--out", "--nonsense"]), + ("--out", vec!["render", "missing.pdf", "--out", "--dpi", "96"]), + ("--out-dir", vec!["split", "missing.pdf", "--every", "2", "--out-dir", "--password"]), + ("--json", vec!["check", "missing.pdf", "--json", "--dpi"]), + ("--control", vec!["ui", "--control", "--out", "state"]), + // `edit` parses its own options, in the order given, and had the same gap. + ("--out", vec!["edit", "missing.pdf", "--out", "--full"]), + ] { + let out = Command::new(BIN).current_dir(&dir).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?} unexpectedly succeeded"); + assert!(stderr.contains(&format!("{option} needs a value")), "{args:?}: {stderr}"); + assert!(stderr.contains("another option"), "{args:?}: {stderr}"); + } + assert_eq!(std::fs::read_dir(&dir).expect("read dir").count(), 0, "nothing was written"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// And a file whose name really does start with `-` is still reachable, the way the shell reaches +/// one and `--root` already documented: `./--name`. The error is then about the input file, which +/// is as far as these lines get here. +#[test] +fn a_file_whose_name_starts_with_dashes_is_still_reachable() { + // The error is about the file the command could not open, so the test asks which file it + // names -- not how the OS words "not found", which differs on Windows (#3849 review). + for (names, args) in [ + ("missing.pdf", vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"]), + ("missing.pdf", vec!["edit", "missing.pdf", "--out", "./--dash.pdf"]), + ("./--dash.pdf", vec!["info", "./--dash.pdf"]), + ] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?}: no such file either way"); + assert!(stderr.contains(names), "{args:?}: the error is about {names}: {stderr}"); + // Not a usage error: the `./--dash.pdf` reached the command as a file name. + assert!(!stderr.contains("needs a value") && !stderr.contains("unknown option"), "{args:?}: {stderr}"); + } +} + +/// The token after an option that takes no value is not its value: `check-one --edit file.pdf` +/// reported "check-one: missing file", the file having been read as `--edit`'s operand. +#[test] +fn a_flag_that_takes_no_value_does_not_swallow_the_file() { + for args in [vec!["check-one", "--edit", "missing.pdf"], vec!["check-one", "missing.pdf", "--edit"]] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?}"); + // Which file the error is about, not how the OS says it is missing (#3849 review). + assert!(stderr.contains("missing.pdf"), "{args:?}: the error is about the file: {stderr}"); + assert!(!stderr.contains("missing file"), "the file was found, and then not found: {stderr}"); + assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}"); + } +} + +/// photocraft#1108: an argument that is not UTF-8 panicked in `std::env::args` ("called +/// `Result::unwrap()` on an `Err` value"), while collecting the arguments — so it panicked even +/// for `--help`. Such a name cannot be used here, where paths are `str`, so it is a usage error. +#[cfg(unix)] +#[test] +fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + for args in [vec![OsStr::new("info"), OsStr::from_bytes(b"/tmp/caf\xe9.pdf")], vec![OsStr::new("--help"), OsStr::from_bytes(b"caf\xe9")]] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "an error, not a panic (101): {stderr}"); + assert!(stderr.contains("not valid UTF-8"), "{stderr}"); + assert!(!stderr.contains("panicked"), "{stderr}"); + } +} diff --git a/apps/pdfcraft-cli/tests/it/owner_password.rs b/apps/pdfcraft-cli/tests/it/owner_password.rs new file mode 100644 index 0000000000..6796980982 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/owner_password.rs @@ -0,0 +1,154 @@ +//! `info`, `text` and `render` take the owner password of revision 2–4 files, as `run doc_open` +//! does (#2670, item 8). The renderer authenticates only user passwords of those revisions, so +//! the CLI must hand it the user password that owner authentication recovers. + +use std::process::Command; +use std::sync::Arc; + +use pdfcraft_cos::{Algorithm, NewEncryption}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A one-page 200×300 pt PDF reading "Page 1", drawn by `content`. +fn fixture(content: &str) -> Vec { + let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); + let objs: [&[u8]; 5] = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>", + stream.as_bytes(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +const PAGE: &str = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"; + +/// `fixture` protected with user password "user" and owner password "owner", allowing nothing, +/// so copying its text needs the owner password. +fn protected(algorithm: Algorithm) -> Vec { + protected_with(algorithm, PAGE) +} + +fn protected_with(algorithm: Algorithm, content: &str) -> Vec { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(content))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm, + user_password: "user", + owner_password: "owner", + permissions: 0, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-cli-owner-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name) +} + +const ALGORITHMS: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256]; + +#[test] +fn info_accepts_the_owner_password() { + for alg in ALGORITHMS { + let path = tmp(&format!("info-{alg:?}.pdf")); + std::fs::write(&path, protected(alg)).unwrap(); + for pw in ["owner", "user"] { + let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", pw]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}"); + let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(json["pages"], 1, "{alg:?} with {pw:?}"); + assert_eq!(json["encrypted"], true, "{alg:?} with {pw:?}"); + } + let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", "wrong"]).output().unwrap(); + assert!(!out.status.success(), "{alg:?}: a wrong password still fails"); + assert!(String::from_utf8_lossy(&out.stderr).contains("incorrect"), "{alg:?}"); + } +} + +#[test] +fn text_accepts_the_owner_password() { + for alg in ALGORITHMS { + let path = tmp(&format!("text-{alg:?}.pdf")); + std::fs::write(&path, protected(alg)).unwrap(); + let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "owner"]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{alg:?}: {stderr}"); + assert!(String::from_utf8_lossy(&out.stdout).contains("Page 1"), "{alg:?}"); + // The user password opens it, but the permissions still forbid copying. + let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "user"]).output().unwrap(); + assert!(!out.status.success(), "{alg:?}: copying needs the owner password"); + assert!(String::from_utf8_lossy(&out.stderr).contains("don't allow copying"), "{alg:?}"); + } +} + +/// Asserts that `data` is a PNG of the fixture's 200×300 pt page at `dpi` with the text drawn: +/// a blank page of the right size fails too. +fn assert_rendered_page(data: &[u8], dpi: u32, what: &str) { + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap_or_else(|e| panic!("{what}: {e}")); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut buf).unwrap(); + assert_eq!((frame.width, frame.height), (200 * dpi / 72, 300 * dpi / 72), "{what}"); + // A rendered page is opaque, so `render` writes it as 8-bit RGB (`pdfcraft_engine::export::encode_png`). + assert_eq!((frame.color_type, frame.bit_depth), (png::ColorType::Rgb, png::BitDepth::Eight), "{what}"); + // Pixels with every colour channel below half intensity: ink, not white paper. + let inked = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().filter(|px| px.iter().all(|c| *c < 128)).count(); + assert!(inked > 100, "{what}: only {inked} inked pixels, the page is blank"); +} + +#[test] +fn render_accepts_the_owner_password() { + const DPI: u32 = 144; + for alg in ALGORITHMS { + let path = tmp(&format!("render-{alg:?}.pdf")); + std::fs::write(&path, protected(alg)).unwrap(); + for pw in ["owner", "user"] { + let png = tmp(&format!("render-{alg:?}-{pw}.png")); + let _ = std::fs::remove_file(&png); + let out = Command::new(BIN) + .args(["render", path.to_str().unwrap(), "--page", "1", "--dpi", &DPI.to_string()]) + .args(["--password", pw, "--out", png.to_str().unwrap()]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}"); + assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}")); + } + } +} + +#[test] +fn audit_places_findings_with_the_owner_password() { + // A black box over the text: a faux redaction, so `audit` reads the page geometry too. + let covered = format!("{PAGE} 0 g 10 140 120 40 re f"); + for alg in ALGORITHMS { + let path = tmp(&format!("audit-{alg:?}.pdf")); + std::fs::write(&path, protected_with(alg, &covered)).unwrap(); + let out = Command::new(BIN).args(["audit", path.to_str().unwrap(), "--password", "owner"]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!stderr.contains("incorrect"), "{alg:?}: {stderr}"); + let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{alg:?}: {e}: {stderr}")); + assert_eq!(report["findings"][0]["covered_text"], "Page 1", "{alg:?}"); + // Top-left points on the 300 pt high page: the box spans y 140–180 from the bottom. + assert_eq!(report["findings"][0]["rect"], serde_json::json!([10.0, 120.0, 130.0, 160.0]), "{alg:?}"); + } +} diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/it/render.rs similarity index 68% rename from apps/pdfcraft-cli/tests/render.rs rename to apps/pdfcraft-cli/tests/it/render.rs index 695431b65b..561f9fa787 100644 --- a/apps/pdfcraft-cli/tests/render.rs +++ b/apps/pdfcraft-cli/tests/it/render.rs @@ -88,3 +88,27 @@ fn an_unknown_extension_fails_and_writes_nothing() { assert!(stderr.contains(".png, .jpg, .tif or .pam"), "{stderr}"); assert!(!out.exists()); } + +/// `--page` and `--dpi` are checked before the file is opened. A page that is not a positive +/// whole number names the same expectation `text` names; a dpi that parses as an `f32` but +/// cannot be rendered (`0`, a negative value, `NaN`) names the option and the value, instead +/// of reaching the renderer as a scale it refuses with its own internal message. +#[test] +fn a_page_or_dpi_the_renderer_cannot_use_is_refused_before_reading_the_file() { + for (option, value, diagnostic) in [ + ("--page", "abc", "bad --page: expected a positive page number"), + ("--page", "0", "bad --page: expected a positive page number"), + ("--dpi", "abc", "bad --dpi: expected a positive number of dots per inch, got \"abc\""), + ("--dpi", "0", "bad --dpi: expected a positive number of dots per inch, got \"0\""), + ("--dpi", "-3", "bad --dpi: expected a positive number of dots per inch, got \"-3\""), + ("--dpi", "NaN", "bad --dpi: expected a positive number of dots per inch, got \"NaN\""), + ("--dpi", "inf", "bad --dpi: expected a positive number of dots per inch, got \"inf\""), + ] { + let out = tmp("refuse", "out.png"); + // `missing.pdf` is never read: the option's own message is the error. + let r = Command::new(BIN).args(["render", "missing.pdf", "--out", out.to_str().unwrap(), option, value]).output().unwrap(); + assert_eq!(r.status.code(), Some(1), "{option} {value}"); + assert_eq!(r.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}"); + assert!(!out.exists(), "{option} {value}: no output may be written"); + } +} diff --git a/apps/pdfcraft-cli/tests/root_arguments.rs b/apps/pdfcraft-cli/tests/it/root_arguments.rs similarity index 100% rename from apps/pdfcraft-cli/tests/root_arguments.rs rename to apps/pdfcraft-cli/tests/it/root_arguments.rs diff --git a/apps/pdfcraft-cli/tests/run_report.rs b/apps/pdfcraft-cli/tests/it/run_report.rs similarity index 100% rename from apps/pdfcraft-cli/tests/run_report.rs rename to apps/pdfcraft-cli/tests/it/run_report.rs diff --git a/apps/pdfcraft-cli/tests/run_root.rs b/apps/pdfcraft-cli/tests/it/run_root.rs similarity index 100% rename from apps/pdfcraft-cli/tests/run_root.rs rename to apps/pdfcraft-cli/tests/it/run_root.rs diff --git a/apps/pdfcraft-cli/tests/split.rs b/apps/pdfcraft-cli/tests/it/split.rs similarity index 75% rename from apps/pdfcraft-cli/tests/split.rs rename to apps/pdfcraft-cli/tests/it/split.rs index 7a4a254f89..71523191d7 100644 --- a/apps/pdfcraft-cli/tests/split.rs +++ b/apps/pdfcraft-cli/tests/it/split.rs @@ -1,4 +1,5 @@ //! `split --out-dir DIR` creates DIR when it does not exist yet (#249), and says so when it can't. +//! `--every` must be a positive whole number of pages, checked before the file is opened. use std::path::PathBuf; use std::process::{Command, Output}; @@ -74,3 +75,22 @@ fn an_out_dir_that_cannot_be_created_is_reported_as_such() { assert_eq!(std::fs::read(¬_a_dir).unwrap(), b"in the way"); let _ = std::fs::remove_dir_all(&dir); } + +/// `--every` must be a positive whole number of pages. `0` parses, but the engine used to +/// clamp it to 1 silently; refused here, before the file is opened, it cannot surprise anyone. +#[test] +fn a_non_positive_or_unparseable_every_is_refused_before_the_file_is_opened() { + let dir = workdir("every"); + for value in ["0", "abc"] { + let out_dir = dir.join("parts"); + let out = Command::new(BIN).args(["split", "missing.pdf", "--every", value, "--out-dir", out_dir.to_str().unwrap()]).output().unwrap(); + assert_eq!(out.status.code(), Some(1), "--every {value}"); + assert_eq!( + out.stderr, + format!("pdfcraft-cli: bad --every: expected a positive whole number of pages, got {value:?}\n").as_bytes(), + "--every {value}" + ); + assert!(!out_dir.exists(), "--every {value}: nothing may be written"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/pdfcraft-cli/tests/stderr.rs b/apps/pdfcraft-cli/tests/it/stderr.rs similarity index 100% rename from apps/pdfcraft-cli/tests/stderr.rs rename to apps/pdfcraft-cli/tests/it/stderr.rs diff --git a/apps/pdfcraft-cli/tests/stdout.rs b/apps/pdfcraft-cli/tests/it/stdout.rs similarity index 100% rename from apps/pdfcraft-cli/tests/stdout.rs rename to apps/pdfcraft-cli/tests/it/stdout.rs diff --git a/apps/pdfcraft-cli/tests/text.rs b/apps/pdfcraft-cli/tests/it/text.rs similarity index 85% rename from apps/pdfcraft-cli/tests/text.rs rename to apps/pdfcraft-cli/tests/it/text.rs index 3d39a9e284..b7fb2511b7 100644 --- a/apps/pdfcraft-cli/tests/text.rs +++ b/apps/pdfcraft-cli/tests/it/text.rs @@ -243,4 +243,40 @@ mod page_argument_tests { assert_eq!(std::fs::read(&source).unwrap(), bytes); } } + + #[test] + fn text_clips_glyphs_outside_the_crop_box() { + let dir = PageDir::new(); + let path = dir.0.join("cropped.pdf"); + let body = "BT /F1 12 Tf 20 260 Td (Outside header) Tj 0 -110 Td (Visible body) Tj 0 -120 Td (Outside footer) Tj ET"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /CropBox [0 100 200 200] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(&path, pdf).unwrap(); + + for args in [vec![], vec!["--page", "1"]] { + let out = Command::new(BIN).arg("text").arg(&path).args(&args).output().unwrap(); + assert!(out.status.success(), "{args:?}: {out:?}"); + assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "Visible body", "{args:?}"); + } + } } diff --git a/apps/pdfcraft-cli/tests/ui_control.rs b/apps/pdfcraft-cli/tests/it/ui_control.rs similarity index 100% rename from apps/pdfcraft-cli/tests/ui_control.rs rename to apps/pdfcraft-cli/tests/it/ui_control.rs diff --git a/apps/pdfcraft-cli/tests/version_resource.rs b/apps/pdfcraft-cli/tests/it/version_resource.rs similarity index 100% rename from apps/pdfcraft-cli/tests/version_resource.rs rename to apps/pdfcraft-cli/tests/it/version_resource.rs diff --git a/apps/pdfcraft-cli/tests/xfa.rs b/apps/pdfcraft-cli/tests/it/xfa.rs similarity index 100% rename from apps/pdfcraft-cli/tests/xfa.rs rename to apps/pdfcraft-cli/tests/it/xfa.rs diff --git a/apps/pdfcraft-cli/tests/options.rs b/apps/pdfcraft-cli/tests/options.rs deleted file mode 100644 index 9576d59a8f..0000000000 --- a/apps/pdfcraft-cli/tests/options.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Every command rejects misspelled long options before opening or writing a file. - -use std::process::Command; - -const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); - -#[test] -fn non_edit_commands_reject_unknown_options_before_io() { - for args in [ - vec!["info", "missing.pdf", "--passwrod", "secret"], - vec!["text", "missing.pdf", "--pgae", "1"], - vec!["combine", "a.pdf", "b.pdf", "--ot", "out.pdf"], - vec!["extract", "missing.pdf", "--pgaes", "1", "--out", "out.pdf"], - vec!["split", "missing.pdf", "--ever", "2"], - vec!["render", "missing.pdf", "--dppi", "96", "--out", "out.png"], - vec!["check-one", "missing.pdf", "--edti"], - vec!["check", "missing.pdf", "--timout", "2"], - vec!["audit", "missing.pdf", "--passwrod", "x"], - vec!["audit", "missing.pdf", "--nonsense", "1"], - vec!["run", "doc_open", "--roto", "/tmp"], - vec!["ui", "--contro", "control.json", "state"], - ] { - let unknown = args.iter().find(|arg| arg.starts_with("--")).expect("test option"); - let out = Command::new(BIN).args(&args).output().expect("run cli"); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(!out.status.success(), "{args:?} unexpectedly succeeded"); - assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}"); - } -} diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index a64d729dcd..ad8243d594 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [dependencies] pdfcraft-ui-egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index a27401c700..966af354d1 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -11,27 +11,24 @@ workspace = true [dependencies] # Help ▸ Check for updates (issue #28): one HTTPS request to GitHub, over rustls with the OS's # trusted roots. No bundled root list (webpki-roots and webpki-root-certs are CDLA-licensed). -ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] } -rustls-native-certs = "0.8" pdfcraft-ui-egui = { workspace = true } # `build_info`: the version and compile-time build label for `--version` and the log. pdfcraft-engine = { workspace = true } # wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or # driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. -eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] } -egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). log = { workspace = true } +# The log timestamp goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited # crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids) # behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" -# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the -# window to close instead of winit's, which ends the process without asking. -craft-quit = { workspace = true, features = ["menu"] } [target.'cfg(target_os = "linux")'.dependencies] # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is @@ -54,8 +51,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] } [target.'cfg(target_os = "windows")'.dependencies] # Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that -# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it). -winsafe = { version = "0.0.29", default-features = false, features = ["user"] } +# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it), +# and `advapi` for the session id that names the single-instance socket (#3338). +winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] } # Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's # `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe # fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it. @@ -68,3 +66,6 @@ uds_windows = "=1.2.1" [build-dependencies] # Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs index 71ed1d2d2f..bef27de169 100644 --- a/apps/pdfcraft/src/logging.rs +++ b/apps/pdfcraft/src/logging.rs @@ -90,7 +90,10 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the +/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic +/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the +/// output never changes for a value the old formatter accepted and logging never panics. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = since.as_secs(); @@ -99,20 +102,25 @@ pub fn timestamp(t: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). `days` is at most -/// `u64::MAX / 86_400`, so none of the arithmetic below can overflow. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run +/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years +/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the +/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and +/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01 +/// is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -337,6 +345,18 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_767_225_599)), "2025-12-31T23:59:59.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date + // the old formatter gave, rather than falling back to the epoch. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index d7d4c1be6d..d2a75c5941 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1,6 +1,6 @@ //! PdfCraft desktop app. //! -//! Usage: `pdfcraft [options] [files…]` +//! Usage: `pdfcraft [options] [files…]` (`-h` / `--help` prints a short version of this, [`USAGE`]). //! `--two-paged` opens each command-line PDF in noncontinuous two-page view, without changing defaults. //! An explicit `--layout` takes precedence for the active document, regardless of argument order. //! `--create-images [images…]` stages the images in one PDF and asks for the page DPI. @@ -9,7 +9,8 @@ //! `--page N --zoom 150 --layout single|continuous|two-up|two-page --continuous on|off --panel comments|bookmarks|pages|fields|layers|attachments|none //! --theme light|dark|system --language auto| --mode all|read|edit|convert|sign --tool --left open|closed //! --organize on --fields on --dialog properties|shortcuts|about --palette --home on -//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| +//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --always-default-view on|off +//! --page-gaps on|off --default-page-gaps on|off //! --combine-view grid|list --combine-zoom 60..200` //! //! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files @@ -43,7 +44,6 @@ mod settings; mod single_instance; mod startup; mod text_scale; -mod updates; #[cfg(test)] #[path = "windows_manifest.rs"] mod windows_manifest; @@ -58,6 +58,33 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/pdfcraft-10 #[cfg(not(target_os = "macos"))] const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png"); +/// What `-h` / `--help` prints (#4076): a hint, not the full option list (that is this file's +/// doc comment and the README). Scripts want `pdfcraft-cli`, which never opens a window. +const USAGE: &str = "\ +Usage: pdfcraft [options] [files…] + +Opens the files in the PdfCraft desktop app. + +Options: + --two-paged open command-line PDFs in noncontinuous two-page view + --new-window open a window of its own instead of passing the files to a running PdfCraft + --create-images [images…] put the images in one PDF and ask for the page DPI + --control enable the UI control channel (pdfcraft-cli ui --control …) + --page N, --zoom 150, --layout continuous, --theme dark, … + view settings applied once the files are open + -h, --help print this and exit + --version print the version and exit + +To inspect, convert or edit PDFs from a script, use pdfcraft-cli (pdfcraft-cli --help)."; + +/// Print `text` for `--help` and `--version`. `println!` would panic on a full or closed +/// standard output; this exits normally instead. +fn print_and_exit(text: &str) -> eframe::Result { + use std::io::Write; + let _ = writeln!(std::io::stdout().lock(), "{text}"); + Ok(()) +} + /// The app was called PrintCraft before; settings saved then are under this key. const LEGACY_STORAGE_KEY: &str = "printcraft"; @@ -171,6 +198,11 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&pdfcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); // Last-resort guard (AGENTS.md §4): commands, edits, opens and saves catch panics and report @@ -192,8 +224,10 @@ fn main() -> eframe::Result { })); let mut startup = startup::Arguments::parse(std::env::args().skip(1)); if startup.version { - println!("pdfcraft {}", pdfcraft_engine::build_info::version()); - return Ok(()); + return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())); + } + if startup.help { + return print_and_exit(USAGE); } startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file()); let may_hand_off = startup.may_hand_off(); @@ -318,6 +352,9 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) let persistence_path = settings_dir().map(|d| d.join("app.ron")); // Set explicitly, so the renderer never depends on which one eframe defaults to. let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() }; + // PdfCraft installs its own last-resort system face (`PDFCRAFT_SYSTEM_FONTS`); eframe's + // provider would read every installed face, including proprietary ones, into every UI pass. + native.system_font_fallback = false; if renderer == eframe::Renderer::Wgpu { configure_gpu(&mut native); } @@ -378,16 +415,17 @@ fn app_creator<'a>( { app.motion.source = Some(motion::source()); } - app.update_source = Some(std::sync::Arc::new(updates::latest_release)); + app.set_startup_update_check(true); app.printer_source = Some(pdfcraft_ui_egui::system_printers()); app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); #[cfg(target_os = "macos")] { app.os_events = Some(apple_events.connect(&cc.egui_ctx)); - // winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This - // menu's Quit closes the window instead, so `guard_quit` asks, as for the close button. - if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") { - log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}"); + // The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as + // winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close + // button. + if !app.install_native_menu(&cc.egui_ctx) { + log::warn!("couldn't install the macOS menu bar; the menus stay in the window"); } } if let Some(server) = instance { @@ -545,6 +583,11 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> /// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver /// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before /// its window appeared. D3D12 is the native, best-supported backend there. +/// - On Windows, Direct3D 12 presents through a DirectComposition visual rather than a swap chain +/// on the window itself, unless `WGPU_DX12_PRESENTATION_SYSTEM` says otherwise. While the window +/// is being resized, Windows stretched the window's swap chain, still at the old size, to the +/// new one, so the text shimmered and the left panel's width bounced for as long as the drag +/// lasted (issue #2738). A visual's last frame is shown at its own size instead. fn configure_gpu(native: &mut eframe::NativeOptions) { let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return }; // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a @@ -592,6 +635,15 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { setup.instance_descriptor.backends = eframe::wgpu::Backends::DX12 | eframe::wgpu::Backends::GL; } + if cfg!(target_os = "windows") { + setup.instance_descriptor.backend_options.dx12.presentation_system = dx12_presentation(eframe::wgpu::Dx12SwapchainKind::from_env()); + } +} + +/// How Direct3D 12 presents on Windows: through a DirectComposition visual (issue #2738), unless +/// `WGPU_DX12_PRESENTATION_SYSTEM` asked for something else (`asked`). +fn dx12_presentation(asked: Option) -> eframe::wgpu::Dx12SwapchainKind { + asked.unwrap_or(eframe::wgpu::Dx12SwapchainKind::DxgiFromVisual) } /// Surface textures must fit the device's enabled limits, not just the physical GPU's limits. @@ -742,637 +794,4 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D } #[cfg(test)] -mod tests { - use pdfcraft_ui_egui::control::Endpoint; - - fn endpoint() -> std::io::Result { - Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() }) - } - - /// Tests that open a real GPU device take this first, so only one of them is inside the - /// graphics libraries at a time. The test threads otherwise run them together, and on - /// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: - /// the first device was still being set up when the second test began its own. The UI - /// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole - /// test (declared before the instance, so it's released after the device is dropped). - static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - fn gpu() -> std::sync::MutexGuard<'static, ()> { - // A test that panicked while holding it leaves nothing to clean up. - GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) - } - - #[test] - fn a_control_channel_that_cant_be_published_stops_the_app() { - let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = |name: &str| dir.join(name).to_str().unwrap().to_owned(); - - // A folder in the way stands in for a file another user created first: the write fails. - std::fs::create_dir(dir.join("taken.json")).unwrap(); - let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err(); - assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}"); - // No listener: nothing to publish. - let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err(); - assert!(err.contains("no loopback"), "{err}"); - assert!(!dir.join("pc.json").exists()); - - super::open_control_channel(&path("pc.json"), endpoint()).unwrap(); - let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap(); - assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef"))); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn path_from_arg_decodes_file_uris() { - assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); - assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf"); - assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf"); - // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI. - assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf"); - assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2"); - assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%"); - assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf"); - } - - #[test] - fn unquoted_paths_with_spaces_are_rejoined() { - let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"]; - let exists = |p: &str| have.contains(&p); - let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); - // The case an office program produced: one path, split at its space. - assert_eq!( - super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), - args(&["P:\\Archive\\00042A26 Contract.PDF"]) - ); - // The longest run wins; files that exist on their own are left alone. - assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"])); - assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"])); - // Nothing to join: kept as given, so the missing file is still reported by name. - assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"])); - assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty()); - } - - #[test] - fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() { - assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf"); - assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); - assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf"); - assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf"); - assert_eq!(super::path_from_arg("file://".to_string()), "file://"); - } - - #[test] - fn opengl_is_the_fallback_unless_a_renderer_was_chosen() { - use super::{RendererChoice, native_options, renderer_choice, retry_with_gl}; - assert_eq!(renderer_choice(None), RendererChoice::Auto); - assert_eq!(renderer_choice(Some("")), RendererChoice::Auto); - assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto); - assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu); - for gl in ["gl", "OpenGL", "glow"] { - assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl); - } - // Retried with OpenGL only when wgpu failed to start by default, never after the app ran. - assert!(retry_with_gl(RendererChoice::Auto, false)); - assert!(!retry_with_gl(RendererChoice::Auto, true)); - assert!(!retry_with_gl(RendererChoice::Wgpu, false)); - assert!(!retry_with_gl(RendererChoice::Gl, false)); - // Each run states its renderer rather than relying on eframe's default. - assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); - assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); - } - - #[test] - fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { - use super::native_options; - assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None); - assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); - } - - #[test] - fn a_software_renderer_is_shown_to_the_user() { - use eframe::wgpu::DeviceType; - // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log. - let mut app = super::PdfCraftApp::new(); - super::notify_software_renderer(&mut app, DeviceType::Cpu); - let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default(); - assert!(notice.contains("without a graphics processor"), "{notice:?}"); - for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] { - let mut app = super::PdfCraftApp::new(); - super::notify_software_renderer(&mut app, gpu); - assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast); - } - } - - #[test] - fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() { - let mut native = eframe::NativeOptions::default(); - super::configure_gpu(&mut native); - let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { - panic!("default setup creates its own instance") - }; - if std::env::var_os("WGPU_POWER_PREF").is_none() { - assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower); - } - if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { - let flags = setup.instance_descriptor.flags; - assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}"); - } - if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { - let backends = setup.instance_descriptor.backends; - assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}"); - assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}"); - } - } - - #[test] - fn restored_hidpi_window_fits_the_adapters_surface_limit() { - // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface. - let (width, height) = (8600, 3423); - let required = eframe::wgpu::Limits::default(); - assert!(width > required.max_texture_dimension_2d); - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() }; - let enabled = super::surface_texture_limits(required, &supported); - assert!(width <= enabled.max_texture_dimension_2d); - assert!(height <= enabled.max_texture_dimension_2d); - } - - #[test] - fn surface_limits_preserve_other_requirements_and_never_overrequest() { - for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { - for extent in [4096, 8192, 16384] { - let supported = eframe::wgpu::Limits { - max_texture_dimension_2d: extent, - max_bind_groups: 8, - min_uniform_buffer_offset_alignment: 128, - min_storage_buffer_offset_alignment: 128, - ..required.clone() - }; - let enabled = super::surface_texture_limits(required.clone(), &supported); - let mut expected = required.clone(); - expected.max_texture_dimension_2d = extent; - assert_eq!(enabled, expected); - assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); - assert!(enabled.check_limits(&supported)); - } - } - } - - #[test] - fn surface_limits_fit_v3d_shader_variable_limit() { - // Issue #2383: V3D supports 15 variables, below the default request of 16. - let required = eframe::wgpu::Limits::default(); - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; - let enabled = super::surface_texture_limits(required.clone(), &supported); - assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); - let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; - assert_eq!(enabled, expected); - } - - #[test] - fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { - let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); - let supported = eframe::wgpu::Limits { - max_texture_dimension_2d: 2048, - max_bind_groups: 2, - max_vertex_attributes: 8, - min_uniform_buffer_offset_alignment: 512, - min_storage_buffer_offset_alignment: 512, - ..required.clone() - }; - let enabled = super::surface_texture_limits(required, &supported); - assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); - // Larger minimum alignments are weaker capabilities, unlike maximum limits. - assert_eq!(enabled, supported); - } - - #[test] - fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { - use eframe::egui_wgpu::winit::surface_fit; - // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this. - let (width, height, scale) = surface_fit(8600, 3423, 8192); - assert_eq!((width, height), (8192, 3260)); - // Both sides shrink alike and egui draws at that factor: the whole window is drawn. - assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}"); - assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}"); - // Within the limit nothing changes, up to and including the limit itself. - assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0)); - assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0)); - assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0)); - // One side over the limit: the window as it would be stretched across monitors. - assert_eq!(surface_fit(8193, 600, 8192).0, 8192); - assert_eq!(surface_fit(600, 8193, 8192).1, 8192); - } - - #[test] - fn surface_fit_never_empties_or_overflows_a_surface() { - use eframe::egui_wgpu::winit::surface_fit; - for (w, h, max) in [ - (1, u32::MAX, 8192), - (u32::MAX, 1, 2048), - (u32::MAX, u32::MAX, 16384), - (0, u32::MAX, 8192), - (u32::MAX, 0, 8192), - (5, 7, 0), - (40_000, 3, 1), - ] { - let (fw, fh, scale) = surface_fit(w, h, max); - assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}"); - // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one), - // and a zero side stays zero (egui-wgpu skips configuring it). - assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}"); - assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}"); - } - // The longer side lands exactly on the limit (rounding never leaves it a pixel short), - // and the shorter side keeps the window's proportions to within a pixel. - for max in [2048, 8192, 16384] { - for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) { - for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] { - let (fw, fh, scale) = surface_fit(long, short, max); - assert_eq!(fw, max, "{long} x {short} in {max}"); - let expected = f64::from(short) * f64::from(max) / f64::from(long); - assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}"); - assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed"); - } - } - } - } - - #[test] - fn egui_wgpu_carries_the_surface_size_fit() { - // Issue #577: egui-wgpu 0.36.2 configures a window's surface at the window's size, and - // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` - // (emilk/egui#8361). vendor/egui-wgpu fits it within the limit. A dependency bump that - // resolves egui-wgpu from crates.io again, or a re-vendored copy without the patch, would - // bring the crash back: re-apply the patch, or drop the copy once an egui release has a - // fix (vendor/README.md). - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock"); - assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}"); - // `surface_fit` is tested above; these keep it in the paths that size a surface. - let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n"); - for patch in [ - "let (width, height, render_scale) = surface_fit(window_width, window_height, max_side);", - "let (fit_width, fit_height, _) = surface_fit(width, height, self.max_surface_side());", - "pixels_per_point: pixels_per_point * surface_state.render_scale,", - "old_state.window_width,\n old_state.window_height,", - ] { - assert!(painter.contains(patch), "vendor/egui-wgpu lost its surface size patch: {patch}"); - } - } - - #[test] - fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() { - // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's - // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was - // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new - // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings, - // and an error wgpu raises before anything reaches the driver: a texture one pixel wider - // than the device allows. - use eframe::wgpu; - let _gpu = gpu(); - let native = super::native_options(false, false, eframe::Renderer::Wgpu); - let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { - panic!("default setup creates its own instance") - }; - let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); - let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; - // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips. - let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { - eprintln!("skipping: no GPU adapter on this machine"); - return; - }; - let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else { - eprintln!("skipping: the adapter gives no device"); - return; - }; - let texture = |width: u32| { - device.create_texture(&wgpu::TextureDescriptor { - label: Some("issue-519"), - size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }) - }; - let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1); - let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); - assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}"); - // The device stays usable, and nothing is left behind to catch later errors by mistake. - let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16))); - assert!(fine.is_ok(), "{fine:?}"); - assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7)); - // Nested: the inner call keeps its own error, and the outer one still catches what follows. - let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || { - let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); - assert!(inner.is_err(), "inner: {inner:?}"); - texture(too_wide) - })); - assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); - } - - #[test] - fn egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error() { - // Issue #519: `catch_errors` is tested above; this keeps it around the first configure of - // every new window's surface, and its error reaching eframe as `WgpuError` (eframe then - // returns `Error::Wgpu`, which `main` retries with OpenGL). Without it a failed configure - // panics before the app is created, and the OpenGL retry never runs. - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock"); - assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}"); - let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n"); - let add_surface = painter.split("async fn add_surface(").nth(1).and_then(|s| s.split("\n fn ").next()).expect("add_surface"); - for patch in [ - "let installed = catch_errors(&device, || {\n self.install_surface(surface, viewport_id, size.width, size.height, false);\n })", - "return Err(crate::WgpuError::ConfigureSurface(error));", - ] { - assert!(add_surface.contains(patch), "vendor/egui-wgpu lost its surface configure patch: {patch}"); - } - } - - #[test] - fn pipeline_creation_error_is_returned_and_device_remains_usable() { - use eframe::{egui_wgpu, wgpu}; - let _gpu = gpu(); - let mut native = super::native_options(false, false, eframe::Renderer::Wgpu); - let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; - let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); - let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; - let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { - assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run"); - eprintln!("skipping pipeline regression: no GPU adapter"); - return; - }; - let (device, queue) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) - .expect("the selected adapter must provide a test device"); - native.wgpu_options.wgpu_setup = - egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue }); - // Three samples is invalid on every backend, so this reproduces a pipeline error - // without depending on a particular driver. The unpatched constructor panics. - for samples in [3, 1, 3, 1] { - let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() }; - let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options)); - if samples == 3 { - let error = result.err().expect("invalid pipeline must return an error"); - assert!(error.to_string().contains("egui_pipeline"), "{error}"); - } else { - assert!(result.is_ok(), "the device must remain usable: {:?}", result.err()); - } - } - } - - #[test] - fn winit_carries_the_windows_11_monitor_scale_fix() { - // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another - // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong - // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A - // dependency bump that resolves winit from crates.io again, or a re-vendored copy without - // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a - // winit 0.30 release has the fix (vendor/README.md). - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock"); - assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}"); - let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); - let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;"; - assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch"); - } - - const NVIDIA: (u32, u32) = (0x10de, 0x2684); - const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); - const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); - - fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> { - use eframe::wgpu::DeviceType; - vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] - } - - const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); - - fn monitor(pci: (u32, u32)) -> super::DisplayGpu { - super::DisplayGpu { pci, primary: false } - } - - fn panel(pci: (u32, u32)) -> super::DisplayGpu { - super::DisplayGpu { pci, primary: true } - } - - #[test] - fn pick_adapter_prefers_the_gpu_driving_the_monitors() { - // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); - // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed - // the display driver. Windows also says which display is the primary one. - assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); - } - - #[test] - fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { - // A Windows desktop with a monitor on each GPU: the window opens on the primary display. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); - } - - #[test] - fn pci_ids_come_from_windows_device_ids() { - assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); - assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); - assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); - // Remote Desktop and other non-PCI display devices, and malformed ids. - assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None); - assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); - assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); - assert_eq!(super::pci_ids("VEN_&DEV_"), None); - assert_eq!(super::pci_ids(""), None); - } - - #[cfg(target_os = "windows")] - #[test] - fn windows_display_gpus_lists_pci_gpus_with_a_monitor() { - // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary - // display, and no GPU is listed twice. (A headless CI runner may list none.) - let gpus = super::windows_display_gpus(); - assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); - for (i, g) in gpus.iter().enumerate() { - assert!(g.pci.0 != 0, "{gpus:?}"); - assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); - } - } - - #[test] - fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { - // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); - } - - #[test] - fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() { - // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel. - use eframe::wgpu::DeviceType; - let desktop = - [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; - assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); - assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); - // A GPU driving no display still loses to one that does. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); - } - - #[test] - fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { - // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with - // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the - // compositor shows nothing it draws, and reports no error either. - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); - // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); - // No GPU drives a display as far as sysfs says (a container), but the compositor knows. - assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); - // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); - } - - #[test] - fn pick_adapter_falls_back_to_low_power_without_a_match() { - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); - assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); - } - - #[test] - fn internal_panels_are_edp_lvds_and_dsi_connectors() { - for name in ["eDP-1", "LVDS-1", "DSI-1"] { - assert!(super::is_internal_panel(name), "{name}"); - } - for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { - assert!(!super::is_internal_panel(name), "{name}"); - } - } - - #[cfg(target_os = "linux")] - #[test] - fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { - let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name).join("device"))?; - std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; - std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) - }; - let connector = |name: &str, status: &str| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name))?; - std::fs::write(dir.join(name).join("status"), format!("{status}\n")) - }; - card("card1", NVIDIA)?; - card("card2", AMD_IGPU)?; - connector("card1-DP-3", "connected")?; - connector("card1-DP-4", "connected")?; - connector("card2-HDMI-A-1", "disconnected")?; - connector("card2-Writeback-1", "unknown")?; - let desktop = super::linux_display_gpus(&dir); - // Issue #445: a monitor on the integrated GPU too, still no built-in panel. - connector("card2-HDMI-A-1", "connected")?; - let mut both = super::linux_display_gpus(&dir); - // A hybrid laptop: the integrated GPU drives the built-in panel as well. - connector("card2-eDP-1", "connected")?; - let mut laptop = super::linux_display_gpus(&dir); - std::fs::remove_dir_all(&dir)?; - assert_eq!(desktop, vec![monitor(NVIDIA)]); - both.sort_by_key(|g| g.pci); - assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]); - laptop.sort_by_key(|g| g.pci); - assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]); - assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); - Ok(()) - } - - /// A fresh folder per call: tests run in parallel. - fn scratch() -> std::path::PathBuf { - static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// The control file's port and token, and every name in its folder. - fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) { - let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap(); - let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); - names.sort(); - (json, names) - } - - #[test] - fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() { - // The file used to be opened with create + truncate, so a link planted at the path had its - // target truncated and overwritten with the token. - let dir = scratch(); - std::fs::write(dir.join("victim.txt"), "keep me").unwrap(); - std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap(); - super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); - assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me"); - let (json, names) = written(&dir); - assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken"))); - assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() { - let dir = scratch(); - let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt")); - std::fs::write(&target, "keep me").unwrap(); - #[cfg(unix)] - let made = std::os::unix::fs::symlink(&target, &link); - #[cfg(windows)] - let made = std::os::windows::fs::symlink_file(&target, &link); - if let Err(e) = made { - // Windows needs Developer Mode (or admin) for symlinks. - eprintln!("skipped: can't create a symlink here: {e}"); - let _ = std::fs::remove_dir_all(&dir); - return; - } - super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap(); - assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me"); - assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); - assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_control_file_replaces_the_one_a_previous_run_left() { - let dir = scratch(); - std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap(); - super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); - let (json, names) = written(&dir); - assert_eq!(json["token"].as_str(), Some("t0ken")); - assert_eq!(names, ["ctl.json"]); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode(); - assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, - /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. - #[test] - fn the_desktop_entry_names_the_window_class() { - let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop"); - let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); - assert_eq!(class, Some(super::APP_ID)); - } -} +mod tests; diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index ecfa12f5c3..751785d0c5 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim { /// `%LOCALAPPDATA%` is unset. #[cfg(windows)] fn socket_path() -> Option { - let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref()); + let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref()); // %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this // user's file paths or send it files to open. A portable copy uses it too (#889), under its // own name so it never hands files to an installed copy (which keeps its settings elsewhere). @@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String { if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") } } -/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The -/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session, -/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept. +/// The Windows session this process runs in, from its access token: the console, each Remote +/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say. +#[cfg(windows)] +fn session_id() -> Option { + use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS}; + match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) { + Ok(TokenInfo::SessionId(id)) => Some(*id), + _ => None, + } +} + +/// The socket's file name for a Windows session. The same user signed in twice (at the console +/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the +/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler, +/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the +/// window of the session it runs in (#3338), and never one in another session (review on #3340). +/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters, +/// digits and `-` kept. #[cfg_attr(not(windows), allow(dead_code))] -fn socket_name(session: Option<&str>) -> String { - let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect(); - if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") } +fn socket_name(session: Option, session_name: Option<&str>) -> String { + if let Some(id) = session { + return format!("instance-s{id}.sock"); + } + let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect(); + if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") } } /// Held while a launch finds out whether it is the first, so launches that start together (several @@ -359,12 +377,20 @@ mod tests { #[test] fn each_windows_session_gets_its_own_socket_name() { - assert_eq!(socket_name(Some("Console")), "instance-Console.sock"); - assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock"); - assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4"))); - assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path"); - assert_eq!(socket_name(None), "instance.sock"); - assert_eq!(socket_name(Some("")), "instance.sock"); + // The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the + // console's session finds the window opened from Explorer there... + assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console"))); + // ...and (review on #3340) one in a Remote Desktop session never reaches the console's. + assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console"))); + assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3"))); + assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock"); + // Only without an id does SESSIONNAME name it. + assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock"); + assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock"); + assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4"))); + assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path"); + assert_eq!(socket_name(None, None), "instance.sock"); + assert_eq!(socket_name(None, Some("")), "instance.sock"); } #[test] @@ -394,11 +420,21 @@ mod tests { assert_eq!(display_socket_name(Some("")), "instance.sock"); } + /// The socket is named by the session the process runs in, which Windows always gives. + #[cfg(windows)] + #[test] + fn this_process_has_a_session_id() { + let id = super::session_id(); + assert!(id.is_some(), "the access token names the session"); + let name = socket_name(id, None); + assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}"); + } + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] fn socket(test: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - dir.join(socket_name(None)) + dir.join(socket_name(None, None)) } #[cfg(any(windows, all(unix, not(target_os = "macos"))))] diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs index 003c6639d4..be25da45b6 100644 --- a/apps/pdfcraft/src/startup.rs +++ b/apps/pdfcraft/src/startup.rs @@ -10,6 +10,7 @@ pub(super) struct Arguments { pub new_window: bool, pub two_paged: bool, pub version: bool, + pub help: bool, } impl Arguments { @@ -26,6 +27,10 @@ impl Arguments { parsed.version = true; break; } + "-h" | "--help" => { + parsed.help = true; + break; + } "--control" => parsed.control_file = args.next(), "--create-images" => parsed.create_images = true, "--new-window" => parsed.new_window = true, @@ -113,6 +118,14 @@ mod tests { assert!(version.version && version.files.is_empty()); } + #[test] + fn help_keeps_the_headless_early_exit() { + for flag in ["-h", "--help"] { + let args = parse(&["--two-paged", flag, "ignored.pdf"]); + assert!(args.help && args.files.is_empty()); + } + } + #[test] fn malformed_boolean_does_not_eat_a_file_or_option() { let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]); diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs new file mode 100644 index 0000000000..c6e6f36518 --- /dev/null +++ b/apps/pdfcraft/src/tests.rs @@ -0,0 +1,686 @@ +/// The lockfile package block for `name`, or a panic naming the missing package. +fn locked_package(root: &std::path::Path, name: &str) -> String { + let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock"); + lock.split("[[package]]") + .find(|p| p.contains(&format!("\nname = \"{name}\"\n"))) + .map(str::to_owned) + .unwrap_or_else(|| panic!("{name} is not in Cargo.lock")) +} + +/// Interim graph check for the window stack while the reviewed fork revision is not yet +/// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the +/// published manifest; local integration supplies the reviewed worktrees through an external +/// Cargo config patch. A git source must name the public storytold fork; a path source is the +/// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested +/// directly elsewhere; the retained vendor copies keep provenance until the fork revision is +/// pinned. No public revision is invented here. +fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String { + let package = locked_package(root, name); + assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}"); + if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) { + assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}"); + assert!( + source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40), + "{name} must pin the fork's 40-character revision, not {source}" + ); + } + // A path patch has no `source =` in Cargo.lock. The local integration config is the only + // allowed path source; a machine-local path must never be in the published manifest, + // which the root-manifest assertion below checks. A path entry carries no remote identity + // to verify, so this test does not claim the resolved code is the published fork. + let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch"); + package +} + +use pdfcraft_ui_egui::control::Endpoint; + +fn endpoint() -> std::io::Result { + Ok(Endpoint { port: 4321, token: "0123456789abcdef".into() }) +} + +/// Tests that open a real GPU device take this first, so only one of them is inside the +/// graphics libraries at a time. The test threads otherwise run them together, and on +/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: +/// the first device was still being set up when the second test began its own. The UI +/// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole +/// test (declared before the instance, so it's released after the device is dropped). +static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn gpu() -> std::sync::MutexGuard<'static, ()> { + // A test that panicked while holding it leaves nothing to clean up. + GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[test] +fn a_control_channel_that_cant_be_published_stops_the_app() { + let dir = std::env::temp_dir().join(format!("pdfcraft-app-control-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = |name: &str| dir.join(name).to_str().unwrap().to_owned(); + + // A folder in the way stands in for a file another user created first: the write fails. + std::fs::create_dir(dir.join("taken.json")).unwrap(); + let err = super::open_control_channel(&path("taken.json"), endpoint()).unwrap_err(); + assert!(err.contains("--control") && err.contains("taken.json") && err.contains("only you can write"), "{err}"); + // No listener: nothing to publish. + let err = super::open_control_channel(&path("pc.json"), Err(std::io::Error::other("no loopback"))).unwrap_err(); + assert!(err.contains("no loopback"), "{err}"); + assert!(!dir.join("pc.json").exists()); + + super::open_control_channel(&path("pc.json"), endpoint()).unwrap(); + let written: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("pc.json")).unwrap()).unwrap(); + assert_eq!((written["port"].as_u64(), written["token"].as_str()), (Some(4321), Some("0123456789abcdef"))); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn path_from_arg_decodes_file_uris() { + assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); + assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf"); + assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf"); + // Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI. + assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf"); + assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2"); + assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%"); + assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf"); +} + +#[test] +fn unquoted_paths_with_spaces_are_rejoined() { + let have = ["P:\\Archive\\00042A26 Contract.PDF", "C:\\a b c.pdf", "C:\\a.pdf", "C:\\x y.pdf"]; + let exists = |p: &str| have.contains(&p); + let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + // The case an office program produced: one path, split at its space. + assert_eq!(super::rejoin_split_paths(args(&["P:\\Archive\\00042A26", "Contract.PDF"]), exists), args(&["P:\\Archive\\00042A26 Contract.PDF"])); + // The longest run wins; files that exist on their own are left alone. + assert_eq!(super::rejoin_split_paths(args(&["C:\\a", "b", "c.pdf", "C:\\a.pdf"]), exists), args(&["C:\\a b c.pdf", "C:\\a.pdf"])); + assert_eq!(super::rejoin_split_paths(args(&["C:\\a.pdf", "C:\\x", "y.pdf"]), exists), args(&["C:\\a.pdf", "C:\\x y.pdf"])); + // Nothing to join: kept as given, so the missing file is still reported by name. + assert_eq!(super::rejoin_split_paths(args(&["missing.pdf", "other"]), exists), args(&["missing.pdf", "other"])); + assert!(super::rejoin_split_paths(Vec::new(), exists).is_empty()); +} + +#[test] +fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() { + assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf"); + assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf"); + assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf"); + assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf"); + assert_eq!(super::path_from_arg("file://".to_string()), "file://"); +} + +#[test] +fn opengl_is_the_fallback_unless_a_renderer_was_chosen() { + use super::{RendererChoice, native_options, renderer_choice, retry_with_gl}; + assert_eq!(renderer_choice(None), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu); + for gl in ["gl", "OpenGL", "glow"] { + assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl); + } + // Retried with OpenGL only when wgpu failed to start by default, never after the app ran. + assert!(retry_with_gl(RendererChoice::Auto, false)); + assert!(!retry_with_gl(RendererChoice::Auto, true)); + assert!(!retry_with_gl(RendererChoice::Wgpu, false)); + assert!(!retry_with_gl(RendererChoice::Gl, false)); + // Each run states its renderer rather than relying on eframe's default. + assert_eq!(native_options(false, false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); + assert_eq!(native_options(false, false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); +} + +#[test] +fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { + use super::native_options; + assert_eq!(native_options(false, false, eframe::Renderer::Glow).viewport.decorations, None); + assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); +} + +#[test] +fn a_software_renderer_is_shown_to_the_user() { + use eframe::wgpu::DeviceType; + // Issue #519: WARP or llvmpipe is announced in the app, not just in pdfcraft.log. + let mut app = super::PdfCraftApp::new(); + super::notify_software_renderer(&mut app, DeviceType::Cpu); + let notice = app.toast.as_ref().map(|(m, _)| m.as_str()).unwrap_or_default(); + assert!(notice.contains("without a graphics processor"), "{notice:?}"); + for gpu in [DeviceType::IntegratedGpu, DeviceType::DiscreteGpu, DeviceType::VirtualGpu, DeviceType::Other] { + let mut app = super::PdfCraftApp::new(); + super::notify_software_renderer(&mut app, gpu); + assert!(app.toast.is_none(), "{gpu:?}: {:?}", app.toast); + } +} + +#[test] +fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() { + let mut native = eframe::NativeOptions::default(); + super::configure_gpu(&mut native); + let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + if std::env::var_os("WGPU_POWER_PREF").is_none() { + assert_eq!(setup.power_preference, eframe::wgpu::PowerPreference::LowPower); + } + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + let flags = setup.instance_descriptor.flags; + assert!(!flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "Intel Metal can't compile its shader: {flags:?}"); + } + if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { + let backends = setup.instance_descriptor.backends; + assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}"); + assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}"); + } + use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual}; + assert_eq!(super::dx12_presentation(None), DxgiFromVisual); + assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides"); + assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual); + let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system; + if cfg!(target_os = "windows") { + // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise. + let asked = eframe::wgpu::Dx12SwapchainKind::from_env(); + assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain"); + } else { + assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it"); + } +} + +#[test] +fn restored_hidpi_window_fits_the_adapters_surface_limit() { + // The Outlook launch crash: 3440 x 1369 saved points at 250% DPI requested this surface. + let (width, height) = (8600, 3423); + let required = eframe::wgpu::Limits::default(); + assert!(width > required.max_texture_dimension_2d); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 16384, ..required.clone() }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(width <= enabled.max_texture_dimension_2d); + assert!(height <= enabled.max_texture_dimension_2d); +} + +#[test] +fn surface_limits_preserve_other_requirements_and_never_overrequest() { + for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { + for extent in [4096, 8192, 16384] { + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: extent, + max_bind_groups: 8, + min_uniform_buffer_offset_alignment: 128, + min_storage_buffer_offset_alignment: 128, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + let mut expected = required.clone(); + expected.max_texture_dimension_2d = extent; + assert_eq!(enabled, expected); + assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); + assert!(enabled.check_limits(&supported)); + } + } +} + +#[test] +fn surface_limits_fit_v3d_shader_variable_limit() { + // Issue #2383: V3D supports 15 variables, below the default request of 16. + let required = eframe::wgpu::Limits::default(); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; + assert_eq!(enabled, expected); +} + +#[test] +fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { + let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: 2048, + max_bind_groups: 2, + max_vertex_attributes: 8, + min_uniform_buffer_offset_alignment: 512, + min_storage_buffer_offset_alignment: 512, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + // Larger minimum alignments are weaker capabilities, unlike maximum limits. + assert_eq!(enabled, supported); +} + +#[test] +fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { + use eframe::egui_wgpu::winit::surface_fit; + // Issue #577: 3440 x 1369 points restored at 250% asked an 8192-pixel device for this. + let (width, height, scale) = surface_fit(8600, 3423, 8192); + assert_eq!((width, height), (8192, 3260)); + // Both sides shrink alike and egui draws at that factor: the whole window is drawn. + assert!((8600.0 * scale - 8192.0).abs() < 0.01, "{scale}"); + assert!((3423.0 * scale - height as f32).abs() < 1.0, "{scale}"); + // Within the limit nothing changes, up to and including the limit itself. + assert_eq!(surface_fit(8600, 3423, 16384), (8600, 3423, 1.0)); + assert_eq!(surface_fit(8192, 8192, 8192), (8192, 8192, 1.0)); + assert_eq!(surface_fit(0, 0, 8192), (0, 0, 1.0)); + // One side over the limit: the window as it would be stretched across monitors. + assert_eq!(surface_fit(8193, 600, 8192).0, 8192); + assert_eq!(surface_fit(600, 8193, 8192).1, 8192); +} + +#[test] +fn surface_fit_never_empties_or_overflows_a_surface() { + use eframe::egui_wgpu::winit::surface_fit; + for (w, h, max) in + [(1, u32::MAX, 8192), (u32::MAX, 1, 2048), (u32::MAX, u32::MAX, 16384), (0, u32::MAX, 8192), (u32::MAX, 0, 8192), (5, 7, 0), (40_000, 3, 1)] + { + let (fw, fh, scale) = surface_fit(w, h, max); + assert!(fw <= max.max(1) && fh <= max.max(1), "{w} x {h} in {max}: {fw} x {fh}"); + // A side that wasn't zero stays non-zero (`Surface::configure` rejects an empty one), + // and a zero side stays zero (egui-wgpu skips configuring it). + assert_eq!((fw == 0, fh == 0), (w == 0, h == 0), "{w} x {h} in {max}: {fw} x {fh}"); + assert!(scale.is_finite() && scale > 0.0 && scale <= 1.0, "{w} x {h} in {max}: {scale}"); + } + // The longer side lands exactly on the limit (rounding never leaves it a pixel short), + // and the shorter side keeps the window's proportions to within a pixel. + for max in [2048, 8192, 16384] { + for long in (max + 1..=max * 5).step_by(997).chain([max * 2, max * 4, u32::MAX]) { + for short in [1, 3, 600, max / 3, max - 1, max, long - 1, long] { + let (fw, fh, scale) = surface_fit(long, short, max); + assert_eq!(fw, max, "{long} x {short} in {max}"); + let expected = f64::from(short) * f64::from(max) / f64::from(long); + assert!((f64::from(fh) - expected).abs() <= 1.0, "{long} x {short} in {max}: {fh}"); + assert_eq!(surface_fit(short, long, max), (fh, fw, scale), "transposed"); + } + } + } +} + +/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest +/// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. +#[test] +fn vendor_provenance_egui_wgpu_surface_fit_identity() { + // Issue #577: egui-wgpu configured a window's surface at the window's size, and + // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` + // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the + // behavioural test above exercises it. The old 0.36 vendor copy is retained for + // provenance only and is no longer a published path patch, so reading it would not prove + // what the app runs. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); +} + +#[test] +fn a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic() { + // Issue #519: on a 2015 Intel GPU, wgpu's GL backend couldn't configure the window's + // surface (`GpuWaitTimeout`), and wgpu's default error handler panicked before the app was + // created, so PdfCraft never got to retry with OpenGL. vendor/egui-wgpu configures a new + // window's surface inside `catch_errors`. A real device on PdfCraft's own GPU settings, + // and an error wgpu raises before anything reaches the driver: a texture one pixel wider + // than the device allows. + use eframe::wgpu; + let _gpu = gpu(); + let native = super::native_options(false, false, eframe::Renderer::Wgpu); + let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); + let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; + // CI runners have a software adapter (WARP, llvmpipe); a machine without any skips. + let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { + eprintln!("skipping: no GPU adapter on this machine"); + return; + }; + let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))) else { + eprintln!("skipping: the adapter gives no device"); + return; + }; + let texture = |width: u32| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("issue-519"), + size: wgpu::Extent3d { width, height: 1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + }; + let too_wide = device.limits().max_texture_dimension_2d.saturating_add(1); + let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); + assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "{caught:?}"); + // The device stays usable, and nothing is left behind to catch later errors by mistake. + let fine = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(16))); + assert!(fine.is_ok(), "{fine:?}"); + assert_eq!(pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || 7)).ok(), Some(7)); + // Nested: the inner call keeps its own error, and the outer one still catches what follows. + let caught = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || { + let inner = pollster::block_on(eframe::egui_wgpu::winit::catch_errors(&device, || texture(too_wide))); + assert!(inner.is_err(), "inner: {inner:?}"); + texture(too_wide) + })); + assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); +} + +/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest +/// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. +#[test] +fn vendor_provenance_egui_wgpu_catch_errors_identity() { + // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 + // worktree carries it around the first configure of every new window's surface, and its + // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main` + // retries with OpenGL). Without it a failed configure panics before the app is created. + // `catch_errors` is exercised against a real device in the test above and below; the + // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor + // copy is not read as proof of behaviour). + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); +} + +#[test] +fn pipeline_creation_error_is_returned_and_device_remains_usable() { + use eframe::{egui_wgpu, wgpu}; + let _gpu = gpu(); + let mut native = super::native_options(false, false, eframe::Renderer::Wgpu); + let egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { panic!("default setup creates its own instance") }; + let instance = pollster::block_on(native.wgpu_options.wgpu_setup.new_instance()); + let options = wgpu::RequestAdapterOptions { power_preference: setup.power_preference, ..Default::default() }; + let Ok(adapter) = pollster::block_on(instance.request_adapter(&options)) else { + assert!(std::env::var_os("PDFCRAFT_TEST_REQUIRE_GPU").is_none(), "a GPU adapter is required for this run"); + eprintln!("skipping pipeline regression: no GPU adapter"); + return; + }; + let (device, queue) = + pollster::block_on(adapter.request_device(&(setup.device_descriptor)(&adapter))).expect("the selected adapter must provide a test device"); + native.wgpu_options.wgpu_setup = + egui_wgpu::WgpuSetup::Existing(egui_wgpu::WgpuSetupExisting { instance: instance.clone(), adapter, device, queue }); + // Three samples is invalid on every backend, so this reproduces a pipeline error + // without depending on a particular driver. The unpatched constructor panics. + for samples in [3, 1, 3, 1] { + let options = egui_wgpu::RendererOptions { msaa_samples: samples, ..Default::default() }; + let result = pollster::block_on(egui_wgpu::RenderState::create(&native.wgpu_options, &instance, None, options)); + if samples == 3 { + let error = result.err().expect("invalid pipeline must return an error"); + assert!(error.to_string().contains("egui_pipeline"), "{error}"); + } else { + assert!(result.is_ok(), "the device must remain usable: {:?}", result.err()); + } + } +} + +/// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy +/// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. +#[test] +fn winit_carries_the_windows_11_monitor_scale_fix() { + // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another + // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong + // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A + // dependency bump that resolves winit from crates.io again, or a re-vendored copy without + // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a + // winit 0.30 release has the fix (vendor/README.md). + // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in + // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no + // longer a published path patch, so this checks the actual graph, not the old copy. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_window_stack_graph(&root, "winit", "0.30.13"); + let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); + // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the + // rect Windows suggests, while Windows 10 keeps the old adjustment. + for marker in [ + "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests", + "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {", + "new_outer_rect = suggested_rect;", + ] { + assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}"); + } + let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap(); + assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance"); +} + +const NVIDIA: (u32, u32) = (0x10de, 0x2684); +const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); +const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); + +fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> { + use eframe::wgpu::DeviceType; + vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] +} + +const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); + +fn monitor(pci: (u32, u32)) -> super::DisplayGpu { + super::DisplayGpu { pci, primary: false } +} + +fn panel(pci: (u32, u32)) -> super::DisplayGpu { + super::DisplayGpu { pci, primary: true } +} + +#[test] +fn pick_adapter_prefers_the_gpu_driving_the_monitors() { + // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); + // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed + // the display driver. Windows also says which display is the primary one. + assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); +} + +#[test] +fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { + // A Windows desktop with a monitor on each GPU: the window opens on the primary display. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); +} + +#[test] +fn pci_ids_come_from_windows_device_ids() { + assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); + assert_eq!(super::pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); + assert_eq!(super::pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); + // Remote Desktop and other non-PCI display devices, and malformed ids. + assert_eq!(super::pci_ids("ROOT\\BasicDisplay\\0000"), None); + assert_eq!(super::pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); + assert_eq!(super::pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); + assert_eq!(super::pci_ids("VEN_&DEV_"), None); + assert_eq!(super::pci_ids(""), None); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_display_gpus_lists_pci_gpus_with_a_monitor() { + // Whatever this machine has: every entry is a PCI GPU, at most one drives the primary + // display, and no GPU is listed twice. (A headless CI runner may list none.) + let gpus = super::windows_display_gpus(); + assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); + for (i, g) in gpus.iter().enumerate() { + assert!(g.pci.0 != 0, "{gpus:?}"); + assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); + } +} + +#[test] +fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { + // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); +} + +#[test] +fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() { + // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel. + use eframe::wgpu::DeviceType; + let desktop = [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; + assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); + // A GPU driving no display still loses to one that does. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); +} + +#[test] +fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { + // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with + // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the + // compositor shows nothing it draws, and reports no error either. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); + // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); + // No GPU drives a display as far as sysfs says (a container), but the compositor knows. + assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); + // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); +} + +#[test] +fn pick_adapter_falls_back_to_low_power_without_a_match() { + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); + assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); +} + +#[test] +fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(super::is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!super::is_internal_panel(name), "{name}"); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card1", NVIDIA)?; + card("card2", AMD_IGPU)?; + connector("card1-DP-3", "connected")?; + connector("card1-DP-4", "connected")?; + connector("card2-HDMI-A-1", "disconnected")?; + connector("card2-Writeback-1", "unknown")?; + let desktop = super::linux_display_gpus(&dir); + // Issue #445: a monitor on the integrated GPU too, still no built-in panel. + connector("card2-HDMI-A-1", "connected")?; + let mut both = super::linux_display_gpus(&dir); + // A hybrid laptop: the integrated GPU drives the built-in panel as well. + connector("card2-eDP-1", "connected")?; + let mut laptop = super::linux_display_gpus(&dir); + std::fs::remove_dir_all(&dir)?; + assert_eq!(desktop, vec![monitor(NVIDIA)]); + both.sort_by_key(|g| g.pci); + assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]); + laptop.sort_by_key(|g| g.pci); + assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]); + assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); + Ok(()) +} + +/// A fresh folder per call: tests run in parallel. +fn scratch() -> std::path::PathBuf { + static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The control file's port and token, and every name in its folder. +fn written(dir: &std::path::Path) -> (serde_json::Value, Vec) { + let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap(); + let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + (json, names) +} + +#[test] +fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() { + // The file used to be opened with create + truncate, so a link planted at the path had its + // target truncated and overwritten with the token. + let dir = scratch(); + std::fs::write(dir.join("victim.txt"), "keep me").unwrap(); + std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me"); + let (json, names) = written(&dir); + assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken"))); + assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() { + let dir = scratch(); + let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt")); + std::fs::write(&target, "keep me").unwrap(); + #[cfg(unix)] + let made = std::os::unix::fs::symlink(&target, &link); + #[cfg(windows)] + let made = std::os::windows::fs::symlink_file(&target, &link); + if let Err(e) = made { + // Windows needs Developer Mode (or admin) for symlinks. + eprintln!("skipped: can't create a symlink here: {e}"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me"); + assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_control_file_replaces_the_one_a_previous_run_left() { + let dir = scratch(); + std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + let (json, names) = written(&dir); + assert_eq!(json["token"].as_str(), Some("t0ken")); + assert_eq!(names, ["ctl.json"]); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, +/// so it has to be the class the window carries: the app id, on Wayland and X11 alike. +#[test] +fn the_desktop_entry_names_the_window_class() { + let entry = include_str!("../../../packaging/linux/ai.storyteller.pdfcraft.desktop"); + let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); + assert_eq!(class, Some(super::APP_ID)); +} + +// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI +// launch on Windows), and a panic inside the panic hook aborts the process. The start-up +// path and the hook must write to standard error without it. +#[test] +fn start_up_and_the_panic_hook_never_use_eprintln() { + let source = include_str!("main.rs"); + // Everything before the top-level test module is product code. + let product = source.split("\n#[cfg(test)]\nmod tests;").next().unwrap_or(source); + assert!(product.len() < source.len(), "found the end of the product code"); + let offenders: Vec<_> = product + .lines() + .enumerate() + .filter(|(_, line)| !line.trim_start().starts_with("//") && line.contains("eprintln!")) + .map(|(n, line)| format!("main.rs:{}: {}", n + 1, line.trim())) + .collect(); + assert!( + offenders.is_empty(), + "`eprintln!` panics on a broken standard error pipe; use `log::warn!` or `write_fmt` on `stderr()` and ignore the error:\n{}", + offenders.join("\n") + ); +} diff --git a/apps/pdfcraft/src/updates.rs b/apps/pdfcraft/src/updates.rs deleted file mode 100644 index 686c8cee84..0000000000 --- a/apps/pdfcraft/src/updates.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Asks GitHub for the latest PdfCraft release (Help ▸ Check for updates, issue #28). - -use std::time::Duration; - -use pdfcraft_ui_egui::updates::{RELEASES_PAGE, Release}; - -const LATEST: &str = "https://api.github.com/repos/storytold/pdfcraft/releases/latest"; - -/// The latest release. The answer is untrusted: its size is capped, and only a page under -/// [`RELEASES_PAGE`] is ever offered for download (anything else falls back to that list). -pub fn latest_release() -> Result { - let agent = ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(10))) - .tls_config(ureq::tls::TlsConfig::builder().root_certs(os_roots()?).build()) - .build() - .new_agent(); - let mut response = agent - .get(LATEST) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", concat!("PdfCraft/", env!("CARGO_PKG_VERSION"))) - // Read error statuses ourselves: a 403 from GitHub usually means its anonymous - // rate limit ran out, and the retry time rides along in a response header. - .config() - .http_status_as_error(false) - .build() - .call() - .map_err(|e| format!("couldn't reach GitHub ({e})"))?; - let status = response.status().as_u16(); - if status == 403 { - let reset = response.headers().get("x-ratelimit-reset").and_then(|v| v.to_str().ok()); - return Err(rate_limited(reset, now_unix())); - } - if !(200..300).contains(&status) { - return Err(format!("couldn't reach GitHub (http status: {status})")); - } - let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?; - parse(&body) -} - -/// The certificate authorities the operating system trusts. -fn os_roots() -> Result { - let found = rustls_native_certs::load_native_certs(); - let certs: Vec> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect(); - if certs.is_empty() { - return Err("no trusted certificates found on this system".into()); - } - Ok(ureq::tls::RootCerts::new_with_certs(&certs)) -} - -/// The current Unix time in seconds (0 when the clock gives nothing usable). -fn now_unix() -> u64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) -} - -/// What to tell the user when GitHub answers 403: its anonymous quota (60 checks an hour, -/// shared by everyone behind the same address) ran out. `reset` is the `x-ratelimit-reset` -/// header, the Unix time the quota refills; `now` is the current Unix time. Anything -/// surprising (no header, not a number, already past) falls back to asking for patience. -fn rate_limited(reset: Option<&str>, now: u64) -> String { - const FALLBACK: &str = "GitHub is limiting update checks for now (shared hourly quota); try again later"; - let wait = reset.and_then(|r| r.trim().parse::().ok()).and_then(|reset| reset.checked_sub(now)).filter(|&wait| wait > 0); - match wait { - Some(wait) => { - let minutes = wait.div_ceil(60).max(1); - let plural = if minutes == 1 { "" } else { "s" }; - format!("GitHub is limiting update checks for now (shared hourly quota); try again in about {minutes} minute{plural}") - } - None => FALLBACK.into(), - } -} - -fn parse(body: &str) -> Result { - let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?; - let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string(); - let url = v["html_url"] - .as_str() - .filter(|u| u.strip_prefix(RELEASES_PAGE).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\']))) - .unwrap_or(RELEASES_PAGE) - .to_string(); - Ok(Release { version, url }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn answers_are_read_and_only_our_release_pages_are_offered() { - let r = parse(r#"{"tag_name":"v0.2.0","html_url":"https://github.com/storytold/pdfcraft/releases/tag/v0.2.0"}"#).unwrap(); - assert_eq!(r, Release { version: "v0.2.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v0.2.0".into() }); - for elsewhere in ["https://example.com/pdfcraft.exe", "https://github.com/storytold/pdfcraft/releases.evil/x", "javascript:alert(1)"] { - let r = parse(&format!(r#"{{"tag_name":"v9.9.9","html_url":"{elsewhere}"}}"#)).unwrap(); - assert_eq!(r.url, RELEASES_PAGE, "{elsewhere}"); - } - assert!(parse(r#"{"message":"Not Found"}"#).is_err()); - assert!(parse("").is_err()); - } - - #[test] - fn rate_limit_messages_name_the_wait() { - let limited = "GitHub is limiting update checks for now (shared hourly quota); try again in about 20 minutes"; - assert_eq!(rate_limited(Some("1800001200"), 1800000000), limited); - // Rounds up: 61 seconds out reads as 2 minutes. - assert_eq!( - rate_limited(Some("1800000061"), 1800000000), - "GitHub is limiting update checks for now (shared hourly quota); try again in about 2 minutes" - ); - assert_eq!( - rate_limited(Some("1800000060"), 1800000000), - "GitHub is limiting update checks for now (shared hourly quota); try again in about 1 minute" - ); - // No header, not a number, or already past: ask for patience instead of a time. - let later = "GitHub is limiting update checks for now (shared hourly quota); try again later"; - for bad in [None, Some(""), Some("soon"), Some("1799999999"), Some("1800000000")] { - assert_eq!(rate_limited(bad, 1800000000), later); - } - } - - /// Live: asks GitHub over TLS with the OS's roots (`cargo test -p pdfcraft -- --ignored`). - #[test] - #[ignore = "needs network access"] - fn github_answers_with_the_latest_release() { - let r = latest_release().unwrap(); - assert!(pdfcraft_ui_egui::updates::is_newer(&r.version, "0.0.0"), "{r:?}"); - assert!(r.url.starts_with(RELEASES_PAGE), "{r:?}"); - } -} diff --git a/apps/pdfcraft/tests/help.rs b/apps/pdfcraft/tests/help.rs new file mode 100644 index 0000000000..fce9a8dd39 --- /dev/null +++ b/apps/pdfcraft/tests/help.rs @@ -0,0 +1,55 @@ +//! `--help`, `-h` and `--version` print and exit without opening a window (#4076), so a script that +//! calls them returns. + +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft"); + +/// Run the app with `args`, failing if it is still running after 60 s (it opened its window). +fn run(args: &[&str], stdout: Stdio) -> Output { + let mut child = Command::new(BIN).args(args).stdin(Stdio::null()).stdout(stdout).stderr(Stdio::piped()).spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(60); + while child.try_wait().unwrap().is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + panic!("pdfcraft {args:?} did not exit: it started the app instead"); + } + std::thread::sleep(Duration::from_millis(50)); + } + child.wait_with_output().unwrap() +} + +#[test] +fn help_prints_usage_and_exits() { + for flag in ["--help", "-h"] { + let out = run(&[flag], Stdio::piped()); + assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.starts_with("Usage: pdfcraft [options] [files…]"), "{flag}: {stdout}"); + assert!(stdout.contains("pdfcraft-cli"), "{flag}: points scripts to the CLI: {stdout}"); + } + // Wherever it is on the command line, and with files named. + let out = run(&["missing.pdf", "--help"], Stdio::piped()); + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).starts_with("Usage:")); +} + +#[test] +fn version_prints_the_version_and_exits() { + let out = run(&["--version"], Stdio::piped()); + assert!(out.status.success(), "{:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + assert!(String::from_utf8_lossy(&out.stdout).starts_with("pdfcraft "), "{}", String::from_utf8_lossy(&out.stdout)); +} + +/// A standard output that can't be written (here a full device) ends the run normally: `println!` +/// would panic and exit 101. +#[cfg(target_os = "linux")] +#[test] +fn help_and_version_survive_an_unwritable_stdout() { + for flag in ["--help", "--version"] { + let full = std::fs::OpenOptions::new().write(true).open("/dev/full").unwrap(); + let out = run(&[flag], Stdio::from(full)); + assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + } +} diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs new file mode 100644 index 0000000000..b09ace5f40 --- /dev/null +++ b/apps/pdfcraft/tests/it/main.rs @@ -0,0 +1,22 @@ +//! The crate's integration tests, linked into one test binary. + +// Compile winit's scroll policy against its real event type, without a compositor. The vendored +// source (`winit_wayland_scroll`) names it as `crate::event`. +#[cfg(target_os = "linux")] +mod event { + pub use winit::event::TouchPhase; +} + +mod vendor_graph; +mod winit_macos_emoji_picker; +mod winit_macos_ime_commit; +mod winit_macos_ime_discard; +mod winit_macos_korean_first_key; +mod winit_macos_korean_space; +mod winit_macos_title_bar_drag; +mod winit_wayland_scroll; +mod winit_wayland_text_input; +mod winit_windows_hidden_maximized; +mod winit_windows_ime_cursor; +mod winit_x11_dpi; +mod winit_x11_ime_focus; diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs new file mode 100644 index 0000000000..a6a4d4b292 --- /dev/null +++ b/apps/pdfcraft/tests/it/vendor_graph.rs @@ -0,0 +1,81 @@ +//! Dependency-graph verification for the egui 0.37 window stack. +//! +//! The twelve `winit_*` modules next to this one read the retained `vendor/winit` / +//! `vendor/egui-wgpu` copies. They are provenance for W1–W13 and E2+E3, not proof of what the app +//! runs: the root manifest pins both crates to immutable storytold git revisions, and the retained +//! copies are not path patches. These tests check the resolved graph and the retained evidence +//! instead of treating the copies as the runtime source. The winit fork's own W17 tests own patch +//! behaviour. + +/// Return the workspace root above the PdfCraft app crate. +fn root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// Every `[[package]]` block in `Cargo.lock` whose `name` is exactly `name`. +fn locked_packages(name: &str) -> Vec { + let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("Cargo.lock"); + let needle = format!("name = \"{name}\""); + lock.split("[[package]]").filter(|package| package.lines().any(|line| line.trim() == needle.as_str())).map(str::to_owned).collect() +} + +/// The one resolved package called `name`. A duplicate name is a lockfile problem, not something +/// to resolve by taking the first match. +fn locked(name: &str) -> String { + let mut packages = locked_packages(name); + assert_eq!(packages.len(), 1, "Cargo.lock must contain exactly one `{name}` package, found {}", packages.len()); + packages.remove(0) +} + +/// The resolved package must be the exact canonical storytold git source at the reviewed +/// 40-character revision, including the `Cargo.lock` URL and fragment. A path source (no `source` +/// line) is refused: it would make the graph depend on a developer's checkout. A published path +/// patch is refused as well. +fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) { + let package = locked(name); + let expected_version = format!("version = \"{version}\""); + assert!(package.lines().any(|line| line.trim() == expected_version.as_str()), "{name} must resolve to {version}: {package}"); + let sources: Vec<&str> = package.lines().filter_map(|line| line.trim().strip_prefix("source = ")).collect(); + assert_eq!(sources.len(), 1, "{name} must have exactly one `source` in Cargo.lock; a local path source is not accepted: {package}"); + let expected_source = format!("\"git+{url}?rev={rev}#{rev}\""); + assert_eq!(sources[0], expected_source.as_str(), "{name} must resolve to the exact canonical git URL, revision and fragment, not {}", sources[0]); + + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} cannot stay a published path patch"); +} + +/// Regression test: the resolved window stack points at the reviewed forks and the retained vendor provenance +/// files remain. +#[test] +fn vendor_provenance_window_stack_graph_is_resolved() { + assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc"); + assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a"); + // Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows. + let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md"); + for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] { + assert!(readme.contains(marker), "vendor/README.md lost window-stack provenance: {marker}"); + } + let vcs = std::fs::read_to_string(root().join("vendor/winit/.cargo_vcs_info.json")).expect("vendor/winit provenance"); + assert!(vcs.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base"); + for path in ["vendor/winit/src/platform_impl", "vendor/egui-wgpu/src/winit.rs"] { + assert!(root().join(path).exists(), "retain {path} as provenance for the pinned fork"); + } +} + +/// Renderer, security and PostScript vendors are unrelated to the UI port and must stay path patches. +#[test] +fn renderer_and_security_vendors_remain() { + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml"); + for vendor in [ + "vendor/hayro", + "vendor/hayro-interpret", + "vendor/hayro-syntax", + "vendor/hayro-cmap", + "vendor/hayro-jbig2", + "vendor/hayro-postscript", + "vendor/lopdf", + "vendor/vello_common", + ] { + assert!(manifest.contains(&format!("path = \"{vendor}\"")), "{vendor} must stay a path patch"); + } +} diff --git a/apps/pdfcraft/tests/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs similarity index 75% rename from apps/pdfcraft/tests/winit_macos_emoji_picker.rs rename to apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs index 6cedf57e33..b95058ba94 100644 --- a/apps/pdfcraft/tests/winit_macos_emoji_picker.rs +++ b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (8) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (8) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits text inserted outside a key press and leaves `selectedRange` +/// unmarked. #[test] -fn winit_commits_macos_text_inserted_outside_a_key_press() { +fn vendor_provenance_winit_macos_text_inserted_outside_a_key_press() { // The macOS emoji picker inserts its text with `insertText` outside any key press and without // a composition. winit 0.30.13 reports `insertText` only as part of a key press or a // composition, so an emoji picked into a PdfCraft text field was dropped. vendor/winit commits diff --git a/apps/pdfcraft/tests/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs similarity index 73% rename from apps/pdfcraft/tests/winit_macos_ime_commit.rs rename to apps/pdfcraft/tests/it/winit_macos_ime_commit.rs index 8701fc20a1..877f9e2a22 100644 --- a/apps/pdfcraft/tests/winit_macos_ime_commit.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (6) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (6) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits a composition that ended without marked text and keeps the +/// marked-text underline. #[test] -fn winit_commits_macos_compositions_that_end_without_marked_text() { +fn vendor_provenance_winit_macos_compositions_that_end_without_marked_text() { // winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input // methods that end a composition with `unmarkText` first lost the committed text, and Apple // Korean, which clears its marked text before inserting the key typed after a syllable (`한` diff --git a/apps/pdfcraft/tests/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs similarity index 69% rename from apps/pdfcraft/tests/winit_macos_ime_discard.rs rename to apps/pdfcraft/tests/it/winit_macos_ime_discard.rs index 52e65622f6..89efbd3d81 100644 --- a/apps/pdfcraft/tests/winit_macos_ime_discard.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (7) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (7) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: disallowing IME in the vendored winit discards the macOS input method's marked text. #[test] -fn winit_discards_the_macos_composition_when_ime_is_disallowed() { +fn vendor_provenance_winit_discards_the_macos_composition_when_ime_is_disallowed() { // egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text // but doesn't tell the input method, which keeps the composition and continues it once IME is // allowed again: the abandoned syllable comes back in the next field. vendor/winit calls diff --git a/apps/pdfcraft/tests/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs similarity index 74% rename from apps/pdfcraft/tests/winit_macos_korean_first_key.rs rename to apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs index 76bb32ccbd..f18dde0f2a 100644 --- a/apps/pdfcraft/tests/winit_macos_korean_first_key.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (10) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (10) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit retries the first Apple Korean key once so it starts a composition. #[test] -fn winit_retries_the_first_apple_korean_key_once() { +fn vendor_provenance_winit_retries_the_first_apple_korean_key_once() { // On the first key in a freshly started app, Apple Korean can answer with `insertText` and the // raw compatibility jamo instead of starting a composition, so typing g k s in PdfCraft gave // ㅎㅏㄴ instead of 한. vendor/winit interprets that one key again before sending it to the diff --git a/apps/pdfcraft/tests/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs similarity index 72% rename from apps/pdfcraft/tests/winit_macos_korean_space.rs rename to apps/pdfcraft/tests/it/winit_macos_korean_space.rs index 30c9d4779c..f5f2aada2f 100644 --- a/apps/pdfcraft/tests/winit_macos_korean_space.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (9) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (9) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit sends a key committed through macOS IME only once. #[test] -fn winit_sends_a_key_committed_through_macos_ime_only_once() { +fn vendor_provenance_winit_sends_a_key_committed_through_macos_ime_only_once() { // Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, // `insertText(" ")` and `setMarkedText("")`. The last call resets winit 0.30.13's IME state, so // the key went to the app both as `Ime::Commit(" ")` and as `KeyboardInput` with text " ", and diff --git a/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs similarity index 74% rename from apps/pdfcraft/tests/winit_macos_title_bar_drag.rs rename to apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs index 5422af59b5..5c8e892f25 100644 --- a/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs +++ b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (12) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (12) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit lets title-bar drags reach the app while the empty tab strip still +/// moves the window. #[test] -fn winit_lets_macos_title_bar_drags_reach_the_app() { +fn vendor_provenance_winit_lets_macos_title_bar_drags_reach_the_app() { // On macOS the tab strip is the title bar: a transparent title bar over a full-size content // view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit // moved the whole window for a drag that began on a document tab, and tabs could not be diff --git a/apps/pdfcraft/tests/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs similarity index 67% rename from apps/pdfcraft/tests/winit_wayland_scroll.rs rename to apps/pdfcraft/tests/it/winit_wayland_scroll.rs index ec76d2b4b9..8087219b21 100644 --- a/apps/pdfcraft/tests/winit_wayland_scroll.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs @@ -1,13 +1,12 @@ +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. #![cfg(target_os = "linux")] use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2}; use wayland_client::protocol::wl_pointer::AxisSource; -// Compile the production policy against winit's real event type, without a compositor. -mod event { - pub use winit::event::TouchPhase; -} -#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"] +use crate::event; +#[path = "../../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"] mod scroll; fn egui_phase(phase: event::TouchPhase) -> TouchPhase { @@ -19,6 +18,7 @@ fn egui_phase(phase: event::TouchPhase) -> TouchPhase { } } +/// Run three wheel frames through egui and return each frame's zoom and smooth-scroll deltas. fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let ctx = egui::Context::default(); let mut previous = event::TouchPhase::Ended; @@ -30,7 +30,13 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) }; let input = RawInput { time: Some(frame as f64 / 60.0), - events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }], + events: vec![Event::MouseWheel { + unit, + delta: vec2(0.0, dy), + phase: egui_phase(phase), + modifiers, + source: egui::MouseWheelSource::Unknown, + }], ..Default::default() }; ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear(); @@ -38,8 +44,10 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { seen } +/// Regression test: releasing Ctrl after a pixel-wheel zoom makes the next wheel event scroll instead of +/// zooming. #[test] -fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { +fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] { let seen = wheel_trace(source, false); assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}"); @@ -48,16 +56,18 @@ fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { } } +/// Regression test: discrete wheels follow the modifiers of the current event, just like pixel wheels. #[test] -fn discrete_wheels_still_follow_current_modifiers() { +fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() { let seen = wheel_trace(AxisSource::Wheel, true); assert!(seen[1].0 > 1.0); assert_eq!(seen[2].0, 1.0); assert!(seen[2].1.y > 0.0); } +/// Regression test: a finger gesture keeps its Ctrl zoom through momentum until the gesture ends. #[test] -fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { +fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends() { // A finger scroll's momentum may arrive after Ctrl is released. let seen = wheel_trace(AxisSource::Finger, false); assert!(seen[1].0 > 1.0); @@ -67,8 +77,9 @@ fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started); } +/// Regression test: wheel sources without a phase keep the existing start, move and end policy. #[test] -fn unknown_sources_keep_the_existing_phase_policy() { +fn vendor_provenance_unknown_sources_keep_the_existing_phase_policy() { assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started); assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved); assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved); diff --git a/apps/pdfcraft/tests/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs similarity index 73% rename from apps/pdfcraft/tests/winit_wayland_text_input.rs rename to apps/pdfcraft/tests/it/winit_wayland_text_input.rs index 691659e5a6..6469354cdc 100644 --- a/apps/pdfcraft/tests/winit_wayland_text_input.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (2) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (2) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit keeps a Wayland seat's text input until the seat itself is removed. #[test] -fn winit_keeps_the_wayland_text_input_until_the_seat_goes() { +fn vendor_provenance_winit_keeps_the_wayland_text_input_until_the_seat_goes() { // winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a // touch screen or tablet unplugged, a remote-desktop session ending) and recreates it only when // a capability is added, so IME stops working in a running PdfCraft until it is restarted. diff --git a/apps/pdfcraft/tests/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs similarity index 73% rename from apps/pdfcraft/tests/winit_windows_hidden_maximized.rs rename to apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs index a1bdd11840..a8009591bf 100644 --- a/apps/pdfcraft/tests/winit_windows_hidden_maximized.rs +++ b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (4) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (4) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit maximizes a hidden Windows window only when it is shown. #[test] -fn winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { +fn vendor_provenance_winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { // eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the // window once its first frame is drawn. winit 0.30.13 applies the maximized state with // `ShowWindow(SW_MAXIMIZE)`, which shows a hidden window, so a window closed maximized came back diff --git a/apps/pdfcraft/tests/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs similarity index 69% rename from apps/pdfcraft/tests/winit_windows_ime_cursor.rs rename to apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs index aef55877d7..1df11d8a68 100644 --- a/apps/pdfcraft/tests/winit_windows_ime_cursor.rs +++ b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (3) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (3) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit reads the Windows IME cursor only when the composition reports it. #[test] -fn winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { +fn vendor_provenance_winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { // winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether // or not `WM_IME_COMPOSITION` says the IME set it. The Microsoft Korean IME never sets it, so // the composition's cursor read as 0, before the syllable being composed. vendor/winit puts it diff --git a/apps/pdfcraft/tests/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs similarity index 71% rename from apps/pdfcraft/tests/winit_x11_dpi.rs rename to apps/pdfcraft/tests/it/winit_x11_dpi.rs index fed0cf3e42..a9bde6a1ee 100644 --- a/apps/pdfcraft/tests/winit_x11_dpi.rs +++ b/apps/pdfcraft/tests/it/winit_x11_dpi.rs @@ -1,5 +1,9 @@ +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +/// Regression test: the vendored winit keeps the X11 server-DPI fallback and the desktop file does not +/// override scaling. #[test] -fn winit_carries_the_x11_server_dpi_fallback() { +fn vendor_provenance_winit_carries_the_x11_server_dpi_fallback() { // The behavior tests live beside the policy in winit (util::randr::tests). // This guards PdfCraft's vendored copy, which workspace tests do not test itself. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); diff --git a/apps/pdfcraft/tests/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs similarity index 75% rename from apps/pdfcraft/tests/winit_x11_ime_focus.rs rename to apps/pdfcraft/tests/it/winit_x11_ime_focus.rs index f244d1387a..61e5c8ebfa 100644 --- a/apps/pdfcraft/tests/winit_x11_ime_focus.rs +++ b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (5) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (5) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit focuses the X11 input context it replaces. #[test] -fn winit_focuses_the_x11_input_context_it_replaces() { +fn vendor_provenance_winit_focuses_the_x11_input_context_it_replaces() { // egui allows IME when a text field gains focus and disallows it when the field loses focus. // On X11, winit 0.30.13 replaces the window's input context each time and leaves focusing the // new one to the next focus event, which never comes while PdfCraft's window keeps the focus: diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 10fb2d4cf3..7c17ad0045 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -1,6 +1,6 @@ # PdfCraft app icon -PdfCraft app icon: an engraved lion's head on green +PdfCraft app icon: an engraved lion's head on red **Creature:** a lion, in a frontal head-and-shoulders portrait, mane running off the bottom of the tile. @@ -13,15 +13,14 @@ a full-bleed colour field, no frame or roundel, the animal looking at the viewer |---|---|---| | Ink | `#0b0b0c` | line work and the figure's contour | | Paper | `#efe9dc` | the figure (the lion's silhouette) | -| PdfCraft green (app colour) | `#12a58a` | the full-bleed field | +| PdfCraft red (app colour) | `#b70228` | the full-bleed field | **Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112` that clips everything. Windows and Linux icons use the full-bleed tile. macOS icons put it on Apple's grid (an 824 px body centred on a transparent 1024 px canvas). **Provenance:** the project owner's original drawing, made in ArtCraft (2880 px, keyed to the palette), then -vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` (potrace; no filtering or -warping). The source drawing is kept in craftrules at `assets/app-icons/pdfcraft/source.png`, not here. +vectorised (potrace; no filtering or warping). Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo). ## Files @@ -33,7 +32,7 @@ Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo). | `pdfcraft-1024.png` | 1024 px on Apple's grid; also the runtime Dock icon on macOS | | `pdfcraft.icns` | macOS icon (16–1024 px) | | `pdfcraft.ico` | Windows icon (16–256 px), embedded in `pdfcraft.exe` by `apps/pdfcraft/build.rs` | -| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, green badge and outlined PDF lettering; no font dependency | +| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, red badge and outlined PDF lettering; no font dependency | | `pdfcraft-document.ico` | Windows PDF file icon (16–256 px), embedded as resource 2 in `pdfcraft.exe` | | `hicolor/x/apps/ai.storyteller.pdfcraft.png` | Linux hicolor theme, 16–512 px; the 256 px one is the runtime icon on Windows and Linux | | `hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | Linux scalable icon (copy of the master) | diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png index 20f4ca4393..b4ac96bc47 100644 Binary files a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png index be76e69b86..c3f4315212 100644 Binary files a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png index a00f8f9501..5ae21c7ded 100644 Binary files a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png index be413a715a..766624ec8c 100644 Binary files a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png index f9e2ef2b6e..ab6e8e9312 100644 Binary files a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png index 73971ee5f9..55256b293d 100644 Binary files a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png index 1d9ff4d11c..eb3699f9b8 100644 Binary files a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png index 14444f01bc..9bba411e39 100644 Binary files a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg index 3a1de9f076..5a8017ec4f 100644 --- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/app-icon/pdfcraft-1024.png b/assets/app-icon/pdfcraft-1024.png index 3c79b60279..d6cf213765 100644 Binary files a/assets/app-icon/pdfcraft-1024.png and b/assets/app-icon/pdfcraft-1024.png differ diff --git a/assets/app-icon/pdfcraft-document.ico b/assets/app-icon/pdfcraft-document.ico index 79967a800a..ec1ea4568e 100644 Binary files a/assets/app-icon/pdfcraft-document.ico and b/assets/app-icon/pdfcraft-document.ico differ diff --git a/assets/app-icon/pdfcraft-document.svg b/assets/app-icon/pdfcraft-document.svg index 603a740157..b9d2fbf3c9 100644 --- a/assets/app-icon/pdfcraft-document.svg +++ b/assets/app-icon/pdfcraft-document.svg @@ -4,10 +4,10 @@ - - + + - + diff --git a/assets/app-icon/pdfcraft-small.svg b/assets/app-icon/pdfcraft-small.svg index fb35ea2912..a8d6517b54 100644 --- a/assets/app-icon/pdfcraft-small.svg +++ b/assets/app-icon/pdfcraft-small.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/app-icon/pdfcraft.icns b/assets/app-icon/pdfcraft.icns index 08369d5dd0..c41d1d10f0 100644 Binary files a/assets/app-icon/pdfcraft.icns and b/assets/app-icon/pdfcraft.icns differ diff --git a/assets/app-icon/pdfcraft.ico b/assets/app-icon/pdfcraft.ico index 6e1971d4a5..eedf34883a 100644 Binary files a/assets/app-icon/pdfcraft.ico and b/assets/app-icon/pdfcraft.ico differ diff --git a/assets/app-icon/pdfcraft.svg b/assets/app-icon/pdfcraft.svg index 3a1de9f076..5a8017ec4f 100644 --- a/assets/app-icon/pdfcraft.svg +++ b/assets/app-icon/pdfcraft.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/demo/showcase.html b/assets/demo/showcase.html index 267e56a56f..3b14ea3b47 100644 --- a/assets/demo/showcase.html +++ b/assets/demo/showcase.html @@ -14,27 +14,27 @@ (highlight, sticky note, stamp, ...), so annotation geometry follows the layout. -->

The families in this specimen

-
AgPlayfair DisplayDisplay
-
AgEB GaramondBody text
-
AgInterLabels
-
AgJetBrains MonoCode
-
AgGreat VibesScript
-
∑xSTIX Two MathMathematics
+
AgPlayfair DisplayDisplay
+
AgEB GaramondBody text
+
AgInterLabels
+
AgJetBrains MonoCode
+
AgGreat VibesScript
+
∑xSTIX Two MathMathematics
Forewordii
@@ -449,7 +449,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
- +
The find bar showing match 10 of 16 for the word 'type', highlighted in the Expressive Type chapter heading
onumOld-style figuresSince 1885: 3,467,920Since 1885: 3,467,920
fracFractions1/2 cup, 3/4 inch, 7/81/2 cup, 3/4 inch, 7/8
swshSwash capitalsThe Quiet RiverThe Quiet River
ss01Stylistic set 1a 1 4 6 9 G ?a 1 4 6 9 G ?
ss01Stylistic set 1a 1 4 6 9 G ?a 1 4 6 9 G ?
kernKerningAVATAR Type WaveAVATAR Type Wave
tnumTabular figures1111 / 88881111 / 8888
@@ -519,9 +519,9 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

A short greeting — roughly, “every letter deserves a good home” — in writing systems that need bidirectional layout, contextual shaping, reordering and vertical setting.

-
Arabic RTL · joining
لكل حرف بيت
Amiri
+
Arabic RTL · joining
لكل حرف بيت
Amiri
Hebrew RTL
לכל אות יש בית טוב
Noto Sans Hebrew
-
Devanagari conjuncts
हर अक्षर का घर
Noto Sans Devanagari
+
Devanagari conjuncts
हर अक्षर का घर
Noto Sans Devanagari
Thai no spaces
ทุกอักษรมีบ้าน
Noto Sans Thai
Greek polytonic
Κάθε γράμμα ἔχει σπίτι
EB Garamond
Cyrillic
Каждой букве — дом
EB Garamond
@@ -541,7 +541,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

Mixed direction in one line

-

The word كتاب means “book”, and ספר does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like ٣٤٥ and 345.

+

The word كتاب means “book”, and ספר does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like ٣٤٥ and 345.

Colour emoji

📄🖋️🎨📐🔍🌍🧮✅🦀📎
@@ -598,11 +598,11 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

Nine small plates, each exercising one piece of the PDF imaging model.

- +
Axial shading · three-stop linear gradient
- +
Radial shading · off-centre focal point
@@ -614,31 +614,31 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
Screen · additive light, /BM /Screen
- Clip + Clip
Clipping path · glyph outlines as a clip
- +
Soft mask · luminosity gradient fades artwork
- +
Dash patterns · caps, joins and phase
- +
Tiling patterns · checks and rotated hatch
- +
Fill rules · even-odd pentagram, 45% opacity
- + @@ -789,16 +789,16 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
Review canvas - . - . - . - . - . - . - . - . - . - . + freetext + note + attach + square + circle + polygon + polyline + line + ink + stamp
Review & Markup9
diff --git a/assets/icons/move-horizontal.svg b/assets/icons/move-horizontal.svg new file mode 100644 index 0000000000..7be9e3af6b --- /dev/null +++ b/assets/icons/move-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/pilcrow.svg b/assets/icons/pilcrow.svg new file mode 100644 index 0000000000..1a1b38b15b --- /dev/null +++ b/assets/icons/pilcrow.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/subscript.svg b/assets/icons/subscript.svg new file mode 100644 index 0000000000..a37e9c42d1 --- /dev/null +++ b/assets/icons/subscript.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/superscript.svg b/assets/icons/superscript.svg new file mode 100644 index 0000000000..c7978fb095 --- /dev/null +++ b/assets/icons/superscript.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/clippy.toml b/clippy.toml index 7f2fd53057..31e54da30f 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,3 +13,8 @@ disallowed-methods = [ { path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, { path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, ] +cognitive-complexity-threshold = 30 + +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/contributors/people.toml b/contributors/people.toml index cd8ef99f72..a5a47f5c52 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" @@ -20,6 +17,11 @@ [people.YouseifUS] sync_github_name = true +[people.brian-moon] +real_name = "Il Min Brian Moon" +display_name = "Il Min Brian Moon" +sync_github_name = false + [people.echelon] real_name = "Brandon Thomas" display_name = "Brandon Thomas" @@ -30,6 +32,9 @@ real_name = "Eduardo Soares" display_name = "Eduardo Soares" sync_github_name = false +[people.guillehr2] +real_name = "Guillem Hermida" + [people.jcl-bit] real_name = "James Locke" display_name = "James Locke" @@ -148,3 +153,30 @@ sync_github_name = false [people.MortenHusted] sync_github_name = true + +[people.EricKaden] +real_name = "Eric Kaden" +display_name = "Eric Kaden" +sync_github_name = false + +[people.MatrixMagician] +sync_github_name = true + +[people.Lcoding15] +real_name = "Livio Oswald" + +[people.Bode512] +sync_github_name = true + +[people.amoisttowe1] +real_name = "Nick Nyssen" + +[people.agent-reed] +sync_github_name = true + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true diff --git a/crates/a11y/README.md b/crates/a11y/README.md index 0a653f1316..96cdad13cc 100644 --- a/crates/a11y/README.md +++ b/crates/a11y/README.md @@ -16,6 +16,10 @@ let html = report_html(&report, "file.pdf", "2026-10-02", "en", &in_english); / - Each result is Passed, Failed (with findings and pages), Needs manual check, or Skipped. - **Content checks** walk the page content streams (and form XObjects) tracking marked content: painting outside MCID-marked or `/Artifact` content is untagged. +- **Character encoding** accepts a predefined non-Identity CJK CMap or an Adobe CJK + character collection (`GB1`, `CNS1`, `Japan1`, `Korea1`, `KR`), including Identity-H/V. + Unknown CMaps and foreign registries do not establish that route. This is a font-level + declaration check, not validation of every ToUnicode entry or used glyph. - **Structure checks** read the structure tree through the role map; table regularity counts `ColSpan`/`RowSpan` attributes. - The report is a self-contained HTML page. Fixes that edit the document (language, title, tab diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 92a5ae8c20..720b8a6a1e 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream}; /// A Figure element (after the role map). #[derive(Clone, Debug, PartialEq)] @@ -23,6 +23,8 @@ pub enum AltError { Untagged, #[error("object {0} is not a figure")] NotAFigure(u32), + #[error("the page has more content than can be edited")] + TooLarge, #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -48,6 +50,8 @@ pub enum ArtifactError { AmbiguousParent, #[error("couldn't update the parent tree: {0}")] ParentTree(String), + #[error("the page has more content than can be edited")] + TooLarge, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), } @@ -157,22 +161,19 @@ fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option Vec { + page_content_within(doc, page, &mut DecodeBudget::page()) +} + +/// The page's content streams, decoded against `budget` and joined with a space. Streams that +/// don't decode, or arrive after the budget is spent, are left out. +pub(crate) fn page_content_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Vec { let Some(c) = page.get(b"Contents") else { return Vec::new() }; - match &*doc.resolve(c) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|x| match &*doc.resolve(x) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b'\n'); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), + let mut out = Vec::new(); + for data in budget.decode_contents(doc, c).into_iter().flatten() { + out.extend_from_slice(&data); + out.push(b' '); } + out } fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { @@ -182,17 +183,70 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { } } -/// Where the marked content `ids` is drawn on a page (images, forms and paths). -fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { +/// An open marked-content sequence while bounds are gathered: its id, when it is one of those asked +/// for, and the bounds of what has been drawn in it so far. +struct Mark { + id: Option, + drawn: Option<[f64; 4]>, +} + +/// Adds `b` to what the innermost open sequence has drawn. +fn draw(marks: &mut [Mark], b: [f64; 4]) { + if let Some(m) = marks.last_mut() { + m.drawn = Some(union(m.drawn, b)); + } +} + +/// Closes the innermost open sequence: what it drew counts for its id and for the sequence around it. +fn close(marks: &mut Vec, matching: &mut usize, out: &mut HashMap) { + let Some(m) = marks.pop() else { return }; + if m.id.is_some() { + *matching = matching.saturating_sub(1); + } + let Some(b) = m.drawn else { return }; + if let Some(id) = m.id { + let e = out.entry(id).or_insert(b); + *e = union(Some(*e), b); + } + draw(marks, b); +} + +/// The page-space box a `Do` operator paints: a form's transformed `/BBox`, or the unit square for an image. +/// `None` when the operand is not a resolvable XObject or the form's box is malformed. +fn xobject_bbox(doc: &Document, xobjects: &Dict, op: &pdfcraft_content::Op, top: &Matrix) -> Option<[f64; 4]> { + let r = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref)?; + Some(match &*doc.get(r) { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => { + let bb: Vec = + s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[])) + .unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + match bb[..] { + [a, b, c, d] => m.then(top).bbox([a, b, c, d]), + _ => return None, + } + } + _ => top.bbox([0.0, 0.0, 1.0, 1.0]), + }) +} + +/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass +/// over the page. What a sequence draws counts for every sequence of `ids` around it too. +pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet) -> HashMap { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let props = page_properties(doc, page); + #[cfg(test)] + crate::BOUNDS_PASSES.with(|c| c.set(c.get() + 1)); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; - let mut marks: Vec = Vec::new(); - let mut bbox = None; + // The open marked-content sequences, and how many of them are one of `ids`. + let mut marks: Vec = Vec::new(); + let mut matching = 0usize; + let mut out = HashMap::new(); let mut path: Vec<(f64, f64)> = Vec::new(); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let inside = marks.iter().any(|m| *m); + crate::count_mark_reads(1); + let inside = matching > 0; let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); match op.op.as_slice() { b"q" => ctm.push(top), @@ -206,11 +260,13 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; *t = m.then(&top); } } - b"BMC" => marks.push(false), - b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))), - b"EMC" => { - marks.pop(); + b"BMC" => marks.push(Mark { id: None, drawn: None }), + b"BDC" => { + let id = bdc_mcid(doc, &props, &op).filter(|n| ids.contains(n)); + matching += usize::from(id.is_some()); + marks.push(Mark { id, drawn: None }); } + b"EMC" => close(&mut marks, &mut matching, &mut out), b"m" | b"l" => { if let Some([x, y]) = op.nums::<2>() { path.push(top.apply(x, y)); @@ -231,33 +287,23 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => { if inside && !matches!(op.op.as_slice(), b"n") { for (x, y) in &path { - bbox = Some(union(bbox, [*x, *y, *x, *y])); + draw(&mut marks, [*x, *y, *x, *y]); } } path.clear(); } b"Do" if inside => { - let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) else { continue }; - let unit = match &*doc.get(r) { - Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => { - let bb: Vec = - s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[])) - .unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); - match bb[..] { - [a, b, c, d] => m.then(&top).bbox([a, b, c, d]), - _ => continue, - } - } - _ => top.bbox([0.0, 0.0, 1.0, 1.0]), - }; - bbox = Some(union(bbox, unit)); + let Some(unit) = xobject_bbox(doc, &xobjects, &op, &top) else { continue }; + draw(&mut marks, unit); } - b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))), + b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])), _ => {} } } - bbox + while !marks.is_empty() { + close(&mut marks, &mut matching, &mut out); + } + out } /// The figures, in document order. @@ -266,14 +312,28 @@ pub fn figures(doc: &Document) -> Vec
{ let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let pages = pdfcraft_model::pages(doc); let index: HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - elements(doc) + let figures: Vec<(Element, Option)> = elements(doc) .into_iter() .filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure")) .map(|e| { let page = e.page.and_then(|p| index.get(&p).copied()); + (e, page) + }) + .collect(); + let mut wanted: HashMap> = HashMap::new(); + for (e, page) in &figures { + if let Some(p) = page { + wanted.entry(*p).or_default().extend(mcids(&e.dict)); + } + } + let bounds: HashMap> = + wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect(); + figures + .into_iter() + .map(|(e, page)| { let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())); - let ids: HashSet = mcids(&e.dict).into_iter().collect(); - let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids)); + let on_page = page.and_then(|p| bounds.get(&p)); + let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b)))); Figure { obj: e.object, page, alt, bbox } }) .collect() @@ -321,25 +381,43 @@ fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet) -> Result { let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) }; let props = page_properties(doc, &page.dict); + let mut streams: Vec)>> = Vec::with_capacity(entries.len()); + let mut budget = DecodeBudget::page(); + for entry in &entries { + match &*doc.resolve(entry) { + Object::Stream(s) => { + let data = match budget.decode(s) { + Ok(data) => data, + // Content past the budget may hold more of the figure: editing the rest + // would leave that part marked as a figure the tree no longer has. + Err(_) if budget.exhausted() => return Err(AltError::TooLarge), + Err(_) => Vec::new(), + }; + streams.push(Some((s.clone(), data))); + } + _ => streams.push(None), + } + } + let decoded: Vec<&[u8]> = streams.iter().flatten().map(|(_, bytes)| bytes.as_slice()).collect(); + let pieces = pdfcraft_content::Pieces::join(&decoded); + let edits: Vec<_> = + pieces.parse().ops.into_iter().filter(|op| marked(doc, &props, op, ids)).map(|op| (op.span, b"/Artifact BMC".to_vec())).collect(); + if edits.is_empty() { + return Ok(false); + } + let mut spliced = pieces.splice(edits).into_iter(); let mut next = Vec::with_capacity(entries.len()); let mut changed = false; - for entry in entries { - let Some(stream) = (match &*doc.resolve(&entry) { - Object::Stream(s) => Some(s.clone()), - _ => None, - }) else { + for (entry, stream) in entries.into_iter().zip(streams) { + let Some((stream, old)) = stream else { next.push(entry); continue; }; - let mut ops = pdfcraft_content::parse(&stream.decoded().unwrap_or_default()).ops; - let mut ours = false; - for op in &mut ops { - if marked(doc, &props, op, ids) { - *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); - ours = true; - } - } - if !ours { + let Some(new) = spliced.next() else { + next.push(entry); + continue; + }; + if new == old { next.push(entry); continue; } @@ -348,15 +426,10 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: dict.remove(b"Length"); dict.remove(b"Filter"); dict.remove(b"DecodeParms"); - let stream = Object::Stream(Stream::flate(dict, &pdfcraft_content::serialize_ops(&ops))); - let stored = match entry { - Object::Ref(r) => { - doc.set(r, stream); - Object::Ref(r) - } - _ => Object::Ref(doc.add(stream)), - }; - next.push(stored); + let stream = Object::Stream(Stream::flate(dict, &new)); + // Splicing depends on this occurrence's neighbours. The original stream may also + // appear elsewhere in this array or on another page, where it must stay unchanged. + next.push(Object::Ref(doc.add(stream))); } if changed { let contents = match next.as_slice() { @@ -418,7 +491,23 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact let pages = pdfcraft_model::pages(doc); let page_ref = element.page.ok_or(ArtifactError::NoPage)?; let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?; - let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops; + let mut content = Vec::new(); + if let Some(c) = page.dict.get(b"Contents") { + let mut budget = DecodeBudget::page(); + for data in budget.decode_contents(doc, c) { + match data { + Ok(data) => { + content.extend_from_slice(&data); + content.push(b' '); + } + // The page's /Contents is replaced with what was decoded: content past the + // budget would be dropped from the page. + Err(_) if budget.exhausted() => return Err(ArtifactError::TooLarge), + Err(_) => {} + } + } + } + let mut ops = pdfcraft_content::parse(&content).ops; let mut found = HashSet::new(); for op in &mut ops { let id = (op.op == b"BDC") diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 28a613f366..9b65f6972b 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -70,7 +70,7 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R .map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))? }; all.extend_from_slice(&decoded); - all.push(b'\n'); + all.push(b' '); } Ok(all) } @@ -132,6 +132,38 @@ fn harmless_inside(op: &[u8]) -> bool { ) } +/// The `(BT, ET)` operator index pairs of each text object that shows text, or why the page cannot be tagged. +fn text_spans(ops: &[Op], index: usize) -> Result, AutoTagError> { + let mut start = None; + let mut has_text = false; + let mut spans = Vec::new(); + for (at, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"BT" if start.is_none() => { + start = Some(at); + has_text = false; + } + b"ET" => { + let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) }; + if has_text { + spans.push((first, at)); + if spans.len() > MAX_TEXT_OBJECTS { + return Err(unsupported(index, "too many text objects (maximum 4096)")); + } + } + } + b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true, + name if start.is_some() && harmless_inside(name) => {} + name if start.is_none() && harmless_outside(name) => {} + _ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))), + } + } + if start.is_some() { + return Err(unsupported(index, "unterminated BT text object")); + } + Ok(spans) +} + fn plan(doc: &Document) -> Result, AutoTagError> { let root = doc.root().ok_or(AutoTagError::NoCatalog)?; let catalog = doc.get(root); @@ -163,33 +195,7 @@ fn plan(doc: &Document) -> Result, AutoTagError> { if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS { return Err(unsupported(index, "content has malformed or too many operators")); } - let mut start = None; - let mut has_text = false; - let mut spans = Vec::new(); - for (at, op) in parsed.ops.iter().enumerate() { - match op.op.as_slice() { - b"BT" if start.is_none() => { - start = Some(at); - has_text = false; - } - b"ET" => { - let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) }; - if has_text { - spans.push((first, at)); - if spans.len() > MAX_TEXT_OBJECTS { - return Err(unsupported(index, "too many text objects (maximum 4096)")); - } - } - } - b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true, - name if start.is_some() && harmless_inside(name) => {} - name if start.is_none() && harmless_outside(name) => {} - _ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))), - } - } - if start.is_some() { - return Err(unsupported(index, "unterminated BT text object")); - } + let spans = text_spans(&parsed.ops, index)?; total = total.saturating_add(spans.len()); out.push(Plan { page: page.obj, ops: parsed.ops, spans }); } diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index e8a7db377f..d548c57f9d 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; use pdfcraft_model::Page; use crate::{Finding, Rule}; @@ -20,6 +20,8 @@ pub(crate) struct Scan { pub untagged: BTreeMap, /// Page → fonts used. pub fonts: BTreeMap>, + /// Pages read only in part: content or forms past the page's decode budget are missing. + pub incomplete: BTreeSet, } const MAX_FORM_DEPTH: usize = 12; @@ -30,38 +32,34 @@ impl Scan { for &p in list { let Some(page) = pages.get(p) else { continue }; let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let data = page_content(doc, &page.dict); - let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new() }; + let mut budget = DecodeBudget::page(); + let data = crate::alt::page_content_within(doc, &page.dict, &mut budget); + if budget.remaining() == 0 { + s.incomplete.insert(p); + } + let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget }; w.content(&data, &res, false, 0); } s } } -fn page_content(doc: &Document, page: &Dict) -> Vec { - let Some(c) = page.get(b"Contents") else { return Vec::new() }; - match &*doc.resolve(c) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|x| match &*doc.resolve(x) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b'\n'); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), - } -} - struct Walk<'a> { doc: &'a Document, scan: &'a mut Scan, page: usize, forms: HashSet, + /// Decoded bytes and form entries left for this page. + budget: DecodeBudget, +} + +fn bdc_marks(doc: &Document, op: &pdfcraft_content::Op, props: &Dict) -> bool { + op.name(0) == Some(b"Artifact") + || match op.operands.get(1) { + Some(Object::Dict(d)) => d.contains(b"MCID"), + Some(Object::Name(n)) => props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().map(|d| d.contains(b"MCID"))).unwrap_or(false), + _ => false, + } } impl Walk<'_> { @@ -79,17 +77,7 @@ impl Walk<'_> { let inside = covered || stack.last().copied().unwrap_or(false); match op.op.as_slice() { b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")), - b"BDC" => { - let marks = op.name(0) == Some(b"Artifact") - || match op.operands.get(1) { - Some(Object::Dict(d)) => d.contains(b"MCID"), - Some(Object::Name(n)) => { - props.get(n).map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().map(|d| d.contains(b"MCID"))).unwrap_or(false) - } - _ => false, - }; - stack.push(inside || marks); - } + b"BDC" => stack.push(inside || bdc_marks(self.doc, &op, &props)), b"EMC" => { stack.pop(); } @@ -116,14 +104,27 @@ impl Walk<'_> { self.scan.has_images = true; untagged += usize::from(!inside); } - Some(b"Form") if depth < MAX_FORM_DEPTH && self.forms.insert(r) => { + Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) => { + if !self.budget.enter_form() { + self.scan.incomplete.insert(self.page); + continue; + } + let data = match self.budget.decode(x) { + Ok(data) => data, + Err(_) => { + if self.budget.exhausted() { + self.scan.incomplete.insert(self.page); + } + continue; + } + }; + self.forms.insert(r); let inner = x .dict .get(b"Resources") .map(|o| self.doc.resolve(o)) .and_then(|o| o.as_dict().cloned()) .unwrap_or_else(|| res.clone()); - let data = x.decoded().unwrap_or_default(); self.content(&data, &inner, inside, depth + 1); self.forms.remove(&r); } @@ -143,6 +144,28 @@ fn catalog(doc: &Document) -> Dict { doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default() } +/// The most references one value may be written through. A chain is unusual but legal, and the +/// render inspector follows them (lopdf's `dereference` has a limit of its own), so the two +/// readers of a flag must agree about one. +const MAX_REFERENCE_CHAIN: usize = 32; + +/// The value a chain of indirect references ends at. [`Document::resolve`] follows one reference, +/// and an indirect object's value may itself be a reference (ISO 32000-2 §7.3.10): `/Marked +/// 8 0 R` with object 8 holding `9 0 R` holding `true` is a tagged document, which this rule read +/// as untagged while `doc_info` read it as tagged. Bounded and cycle-safe, since a damaged file +/// can point a reference at itself; a chain longer than that, or one that loops, is no value. +fn resolved(doc: &Document, o: &Object) -> std::sync::Arc { + let mut seen: HashSet = HashSet::new(); + let mut value = doc.resolve(o); + while let Object::Ref(r) = &*value { + if seen.len() >= MAX_REFERENCE_CHAIN || !seen.insert(*r) { + return std::sync::Arc::new(Object::Null); + } + value = doc.get(*r); + } + value +} + fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option { d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()) } @@ -167,12 +190,16 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S } } Rule::TaggedPdf => { - let marked = cat.get(b"MarkInfo").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").cloned())); + // `/Marked` may be an indirect reference like any other value -- or a chain of + // them -- so it is followed to the value before the test below, which looks for the + // boolean itself (#3671). + let marked = + cat.get(b"MarkInfo").map(|m| resolved(doc, m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").map(|v| resolved(doc, v)))); let mut out = Vec::new(); if !cat.contains(b"StructTreeRoot") { out.push(Finding::plain(None, "The document has no tags (structure tree)")); } - if !matches!(marked, Some(Object::Bool(true))) { + if !matches!(marked.as_deref(), Some(Object::Bool(true))) { out.push(Finding::plain(None, "The document isn't marked as tagged (MarkInfo Marked)")); } out @@ -360,8 +387,47 @@ fn full_name(doc: &Document, field: ObjRef) -> String { parts.join(".") } -/// Whether text in this font can be mapped to Unicode (ToUnicode, a standard encoding, or a -/// predefined CJK character collection). +/// Predefined CJK CMaps (ISO 32000-2 §9.7.5.2, Table 116), excluding Identity-H/V. +/// Test complete names: neither a custom CMap nor a nonexistent vertical variant is predefined. +fn predefined_cjk_cmap(name: &[u8]) -> bool { + if matches!(name, b"H" | b"V" | b"83pv-RKSJ-H" | b"90pv-RKSJ-H" | b"KSCpc-EUC-H" | b"UniAKR-UTF8-H" | b"UniAKR-UTF16-H" | b"UniAKR-UTF32-H") { + return true; + } + let Some(base) = name.strip_suffix(b"-H").or_else(|| name.strip_suffix(b"-V")) else { return false }; + matches!( + base, + b"GB-EUC" + | b"GBpc-EUC" + | b"GBK-EUC" + | b"GBKp-EUC" + | b"GBK2K" + | b"UniGB-UCS2" + | b"UniGB-UTF16" + | b"B5pc" + | b"HKscs-B5" + | b"ETen-B5" + | b"ETenms-B5" + | b"CNS-EUC" + | b"UniCNS-UCS2" + | b"UniCNS-UTF16" + | b"90ms-RKSJ" + | b"90msp-RKSJ" + | b"Add-RKSJ" + | b"EUC" + | b"Ext-RKSJ" + | b"UniJIS-UCS2" + | b"UniJIS-UCS2-HW" + | b"UniJIS-UTF16" + | b"KSC-EUC" + | b"KSCms-UHC" + | b"KSCms-UHC-HW" + | b"UniKS-UCS2" + | b"UniKS-UTF16" + ) +} + +/// Whether text in this font declares a Unicode mapping route (ToUnicode, a standard encoding, +/// a predefined CJK CMap, or an Adobe CJK character collection). This does not validate every glyph. fn maps_to_unicode(doc: &Document, f: &Dict) -> bool { if f.contains(b"ToUnicode") { return true; @@ -369,16 +435,22 @@ fn maps_to_unicode(doc: &Document, f: &Dict) -> bool { let enc = f.get(b"Encoding").map(|e| doc.resolve(e)); match f.name(b"Subtype") { Some(b"Type0") => { - let identity = matches!(enc.as_deref(), Some(Object::Name(n)) if n.starts_with(b"Identity")); - let ordering = f + // ISO 32000-2 §9.10.2: either a predefined non-Identity CMap or the descendant's + // Adobe CJK collection supplies the route. Identity-H/V do not rule out the latter. + if matches!(enc.as_deref(), Some(Object::Name(n)) if predefined_cjk_cmap(n)) { + return true; + } + let system = f .get(b"DescendantFonts") .map(|d| doc.resolve(d)) .and_then(|d| d.as_array().and_then(|a| a.first().cloned())) .and_then(|d| doc.resolve(&d).as_dict().cloned()) - .and_then(|d| d.get(b"CIDSystemInfo").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned())) - .and_then(|c| c.get(b"Ordering").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.bytes.clone()))); - let known = matches!(ordering.as_deref(), Some(b"Japan1" | b"GB1" | b"CNS1" | b"Korea1" | b"KR")); - !identity && known + .and_then(|d| d.get(b"CIDSystemInfo").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned())); + let Some(system) = system else { return false }; + let registry = system.get(b"Registry").map(|r| doc.resolve(r)); + let ordering = system.get(b"Ordering").map(|o| doc.resolve(o)); + matches!(registry.as_deref(), Some(Object::String(s)) if s.bytes == b"Adobe") + && matches!(ordering.as_deref(), Some(Object::String(s)) if matches!(s.bytes.as_slice(), b"Japan1" | b"GB1" | b"CNS1" | b"Korea1" | b"KR")) } Some(b"Type3") => enc.is_some(), _ => match enc.as_deref() { diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index c7e911fde2..0fa31ccbb1 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -182,34 +182,42 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { let res = sub(doc, page, b"Resources"); let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties")); let mut metrics: HashMap, Metrics> = HashMap::new(); + // The id each open marked-content sequence is in: its own, else the enclosing one's. let mut stack: Vec> = Vec::new(); let mut out: HashMap = HashMap::new(); // Where each id's last glyph ended, in user space. let mut ends: HashMap = HashMap::new(); let mut gs = TextState::default(); - let mut saved: Vec = Vec::new(); + // With each saved state, the text and text line matrices: inside a text object `q`/`Q` + // save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's + // errata, issue 368), as the renderer does. + let mut saved: Vec<(TextState, Matrix, Matrix)> = Vec::new(); let mut unsaved = 0usize; let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { - let id = stack.iter().rev().find_map(|m| *m); + crate::count_mark_reads(1); + let id = stack.last().copied().flatten(); match op.op.as_slice() { - b"BMC" => stack.push(None), - b"BDC" => stack.push(match op.operands.get(1) { - Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), - Some(Object::Name(n)) => { - props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + b"BMC" => stack.push(id), + b"BDC" => stack.push( + match op.operands.get(1) { + Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int), + Some(Object::Name(n)) => { + props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))) + } + _ => None, } - _ => None, - }), + .or(id), + ), b"EMC" => { stack.pop(); } - b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()), + b"q" if saved.len() < MAX_SAVED => saved.push((gs.clone(), tm, tlm)), b"q" => unsaved += 1, b"Q" if unsaved > 0 => unsaved -= 1, b"Q" => { if let Some(saved) = saved.pop() { - gs = saved; + (gs, tm, tlm) = saved; } } b"cm" => { @@ -263,7 +271,9 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { tm = tlm; } let pieces: Vec<&Object> = match op.op.as_slice() { - b"TJ" => op.operands.first().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(), + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still takes the + // one immediately before it, as the renderer and every other reader here do (#3569). + b"TJ" => op.operands.last().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(), _ => op.operands.last().into_iter().collect(), }; for piece in pieces { @@ -278,17 +288,17 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { }; for (code, len) in m.codes(&s.bytes) { let trm = tm.then(&gs.ctm); + // A glyph's text is only built while its id still takes more. if let Some(id) = id + && out.get(&id).is_none_or(|t| t.len() < MAX_TITLE * 4) && let Some(glyph) = m.text_of(code) { let text = out.entry(id).or_default(); - if text.len() < MAX_TITLE * 4 { - let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); - if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { - text.push(' '); - } - text.push_str(&glyph); + let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale)); + if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') { + text.push(' '); } + text.push_str(&glyph); } let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 }; tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm); diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index ff81cc4c97..280f4e845f 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -21,6 +21,22 @@ mod report; mod structure; mod tags; +#[cfg(test)] +thread_local! { + /// Marked-content stack entries read to decide which marks enclose an operator, for the tests + /// that keep that constant per operator. + static MARK_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Page content passes made to find figure bounds. + static BOUNDS_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` marked-content stack reads (tests only). +#[inline] +fn count_mark_reads(_n: usize) { + #[cfg(test)] + MARK_READS.with(|c| c.set(c.get().saturating_add(_n))); +} + pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt}; pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use autotag_fields::tag_form_fields; @@ -665,6 +681,18 @@ pub fn check(doc: &Document, options: &Options) -> Report { } } } + // A page read only in part may hold what the scan didn't find: text that makes the + // document more than images, untagged content, or a font without Unicode mappings. + let unsure = !scan.incomplete.is_empty() + && match rule { + Rule::ImageOnly => !scan.has_text, + Rule::TaggedContent | Rule::CharacterEncoding => findings.is_empty(), + _ => false, + }; + if unsure { + results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() }); + continue; + } if total > MAX_FINDINGS { let more = total - MAX_FINDINGS; findings.truncate(MAX_FINDINGS); diff --git a/crates/a11y/src/tags.rs b/crates/a11y/src/tags.rs index 565f6620d1..ba9d639018 100644 --- a/crates/a11y/src/tags.rs +++ b/crates/a11y/src/tags.rs @@ -1,6 +1,6 @@ //! Properties of reachable indirect structure elements. -use pdfcraft_cos::{CosError, Document, ObjRef, Object, PdfString}; +use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, PdfString}; use crate::alt; @@ -17,6 +17,10 @@ pub struct TagInfo { pub actual_text: Option, pub alt: Option, pub language: Option, + /// A table's `Summary` attribute. + pub summary: Option, + /// A header cell's `Scope` attribute (`Row`, `Column` or `Both`). + pub scope: Option, /// Zero-based page when `/Pg` is known. pub page: Option, } @@ -36,6 +40,10 @@ pub enum TagProperty { ActualText, AlternateText, Language, + /// A table's `Summary`: a Table attribute (an attribute object with `/O /Table` in `/A`). + Summary, + /// A header cell's `Scope` (a Table attribute): `Row`, `Column` or `Both`. + Scope, } /// Where to place a structure tag relative to one of its direct siblings. @@ -53,6 +61,8 @@ impl TagProperty { "actual_text" => Some(Self::ActualText), "alternate_text" => Some(Self::AlternateText), "language" => Some(Self::Language), + "summary" => Some(Self::Summary), + "scope" => Some(Self::Scope), _ => None, } } @@ -64,6 +74,17 @@ impl TagProperty { Self::ActualText => b"ActualText", Self::AlternateText => b"Alt", Self::Language => b"Lang", + Self::Summary => b"Summary", + Self::Scope => b"Scope", + } + } + + /// The standard structure type a Table attribute belongs to; `None` for the tag's own entries. + fn table_attribute_of(self) -> Option<&'static str> { + match self { + Self::Summary => Some("Table"), + Self::Scope => Some("TH"), + _ => None, } } } @@ -82,6 +103,10 @@ pub enum TagError { AmbiguousChildren, #[error("the tag is already at the requested position")] AlreadyThere, + #[error("{0} applies only to {1} tags")] + WrongTagType(&'static str, &'static str), + #[error("a scope must be Row, Column or Both")] + InvalidScope, #[error(transparent)] Cos(#[from] CosError), } @@ -142,6 +167,8 @@ pub fn tags(doc: &Document) -> Vec { actual_text: text(doc, &e.dict, b"ActualText"), alt: text(doc, &e.dict, b"Alt"), language: text(doc, &e.dict, b"Lang"), + summary: table_attribute(doc, &e.dict, b"Summary").and_then(|o| o.as_string().map(PdfString::to_text)), + scope: table_attribute(doc, &e.dict, b"Scope").and_then(|o| o.as_name().map(|n| String::from_utf8_lossy(n).into_owned())), page: e.page.and_then(|p| page_index.get(&p).copied()), }) }) @@ -200,11 +227,97 @@ fn direct_marks(doc: &Document, item: &Object, page: Option, out: &mut V } } -/// Change one property of a reachable tag; `None` removes a text property. -pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> { - if !alt::elements(doc).iter().any(|e| e.object == object) { - return Err(TagError::NotATag(object)); +/// The attribute objects of a tag's `/A` entry (one object, or an array of them each optionally +/// followed by its revision number), as written: each entry and its revision number. +fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option)>, bool) { + let Some(a) = d.get(b"A") else { return (Vec::new(), false) }; + // An indirect array is read as its items. + let resolved = doc.resolve(a); + let Object::Array(items) = &*resolved else { return (vec![(a.clone(), None)], false) }; + let mut out: Vec<(Object, Option)> = Vec::new(); + for item in items { + match (item, out.last_mut()) { + (Object::Int(_), Some((_, revision @ None))) => *revision = Some(item.clone()), + _ => out.push((item.clone(), None)), + } } + (out, true) +} + +/// Whether attribute object `o` (resolved) is a Table attribute object. +fn is_table_attributes(o: &Object) -> bool { + o.as_dict().is_some_and(|d| d.name(b"O") == Some(b"Table")) +} + +/// The value of `key` in the first Table attribute object of tag `d` that has it. +fn table_attribute(doc: &Document, d: &Dict, key: &[u8]) -> Option { + attribute_entries(doc, d).0.iter().map(|(o, _)| doc.resolve(o)).filter(|o| is_table_attributes(o)).find_map(|o| o.as_dict()?.get(key).cloned()) +} + +/// Set `key` in the Table attribute object of `object`'s `/A` entry that holds it (else the first +/// one, else a new one), or with `None` remove it from every Table attribute object that holds it. +/// An edited object is written into the tag's own `/A`, so an attribute object other tags share +/// keeps its value for them; one left with nothing but its owner goes, and so does an `/A` left +/// empty. +fn set_table_attribute(doc: &mut Document, object: ObjRef, key: &[u8], value: Option) -> Result<(), TagError> { + let dict = doc.get(object).as_dict().cloned().ok_or(TagError::NotATag(object))?; + let (entries, array) = attribute_entries(doc, &dict); + // Each entry with its Table attributes (resolved), when it is a Table attribute object. + let mut entries: Vec<(Object, Option, Option)> = entries + .into_iter() + .map(|(o, r)| { + let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(a)).and_then(|a| a.as_dict().cloned()); + (o, r, table) + }) + .collect(); + let holding = |e: &(Object, Option, Option)| e.2.as_ref().is_some_and(|d| d.contains(key)); + match value { + Some(v) => match entries.iter().position(holding).or_else(|| entries.iter().position(|e| e.2.is_some())) { + Some(i) => { + if let Some((o, _, Some(attributes))) = entries.get_mut(i) { + attributes.set(key.to_vec(), v); + *o = Object::Dict(attributes.clone()); + } + } + None => { + let mut attributes = Dict::new(); + attributes.set(b"O".to_vec(), Object::name("Table")); + attributes.set(key.to_vec(), v); + entries.push((Object::Dict(attributes), None, None)); + } + }, + None => { + if !entries.iter().any(holding) { + // Nothing to clear. + return Ok(()); + } + for (o, _, table) in &mut entries { + if let Some(attributes) = table.as_mut().filter(|a| a.contains(key)) { + attributes.remove(key); + *o = Object::Dict(attributes.clone()); + } + } + entries.retain(|(_, _, table)| !table.as_ref().is_some_and(|a| a.iter().all(|(k, _)| k.as_slice() == b"O"))); + } + } + let entries: Vec<(Object, Option)> = entries.into_iter().map(|(o, r, _)| (o, r)).collect(); + doc.update_dict(object, |d| match entries.as_slice() { + [] => { + d.remove(b"A"); + } + [(only, None)] if !array => d.set(b"A".to_vec(), only.clone()), + _ => d.set(b"A".to_vec(), Object::Array(entries.into_iter().flat_map(|(o, r)| std::iter::once(o).chain(r)).collect())), + })?; + Ok(()) +} + +/// Change one property of a reachable tag; `None` removes a text property. A table's summary and +/// a header cell's scope are Table attributes ([`TagProperty::Summary`], [`TagProperty::Scope`]), +/// set only on tags of that type after the role map. +pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> { + let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { + return Err(TagError::NotATag(object)); + }; // ActualText may intentionally contain leading or trailing whitespace; preserve it. let value = value.filter(|s| !s.is_empty()); if value.is_some_and(|s| s.len() > 65_536) { @@ -215,6 +328,26 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert { return Err(TagError::InvalidType); } + if let Some(owner) = property.table_attribute_of() { + let role_map = + alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let ty = element.dict.name(b"S").map(|s| alt::standard(doc, &role_map, s)).unwrap_or_default(); + if ty != owner.as_bytes() { + let name = if property == TagProperty::Summary { "summary" } else { "scope" }; + return Err(TagError::WrongTagType(name, owner)); + } + let value = match value { + Some(s) if property == TagProperty::Scope => { + if !matches!(s, "Row" | "Column" | "Both") { + return Err(TagError::InvalidScope); + } + Some(Object::name(s)) + } + Some(s) => Some(Object::String(PdfString::text(s))), + None => None, + }; + return set_table_attribute(doc, object, property.key(), value); + } doc.update_dict(object, |d| match value { Some(s) if property == TagProperty::Type => d.set(property.key().to_vec(), Object::name(s)), Some(s) => d.set(property.key().to_vec(), Object::String(PdfString::text(s))), diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index fcd681efc9..a70ffbf385 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -192,6 +192,44 @@ fn an_untagged_document_fails_the_document_rules() { assert_eq!((only.count(Status::Failed), only.count(Status::Skipped)), (1, 31)); } +/// `/MarkInfo /Marked` may be written as an indirect reference, like any value. The rule read the +/// reference itself and so reported a tagged document as untagged (#3671). +#[test] +fn marked_as_an_indirect_reference_still_counts_as_tagged() { + let mut bodies = good(); + // The catalog is object 1; point /Marked at a new object holding `true`. + let last = bodies.len() + 1; + bodies[0] = bodies[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {last} 0 R >>")); + bodies.push("true".into()); + let doc = pdf(&bodies, ""); + let report = check(&doc, &Options { rules: [Rule::TaggedPdf].into(), pages: None }); + assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed, "{:?}", failed(&report)); + + // A reference to a reference is followed too: the render inspector follows chains, so the + // two readers of the flag would otherwise disagree about one file. + let mut chain = good(); + let first = chain.len() + 1; + chain[0] = chain[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {first} 0 R >>")); + chain.push(format!("{} 0 R", first + 1)); + chain.push("true".into()); + let report = check(&pdf(&chain, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None }); + assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed, "{:?}", failed(&report)); + + // A reference that points at itself is no value, not a hang. + let mut loops = good(); + let at = loops.len() + 1; + loops[0] = loops[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {at} 0 R >>")); + loops.push(format!("{at} 0 R")); + let report = check(&pdf(&loops, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None }); + assert_eq!(status(&report, Rule::TaggedPdf), Status::Failed); + + // The direct form is unchanged, and a document with no flag at all still fails. + assert_eq!(status(&check(&pdf(&good(), ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None }), Rule::TaggedPdf), Status::Passed); + let mut none = good(); + none[0] = none[0].replace("/MarkInfo << /Marked true >>", ""); + assert_eq!(status(&check(&pdf(&none, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None }), Rule::TaggedPdf), Status::Failed); +} + /// A tagged document that passes every automatic rule. fn good() -> Vec { vec![ @@ -329,6 +367,103 @@ fn reachable_tag_properties_round_trip_and_reject_unrelated_objects() { assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(4, 1), TagProperty::Title, Some("No")), Err(TagError::NotATag(_)))); } +/// A table (object 4) with a header cell (6) and a data cell (7); `th` is the header cell's +/// dictionary and `extra` more objects from 8 on, of which the Document element (3) lists `kids` +/// after the table. The role map maps /MyTable to /Table. +fn table_doc(th: &str, extra: &[&str], kids: &str) -> Document { + let mut objs = vec![ + "<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(), + "<< /Type /StructTreeRoot /K 3 0 R /RoleMap << /MyTable /Table >> >>".to_string(), + format!("<< /S /Document /K [4 0 R {kids}] >>"), + "<< /S /Table /K [5 0 R] >>".to_string(), + "<< /S /TR /K [6 0 R 7 0 R] >>".to_string(), + th.to_string(), + "<< /S /TD >>".to_string(), + ]; + objs.extend(extra.iter().map(ToString::to_string)); + pdf(&objs, "") +} + +/// The attribute objects of tag `r`'s /A entry as written (one object or an array). +fn attributes(doc: &Document, r: pdfcraft_cos::ObjRef) -> Option { + doc.get(r).as_dict()?.get(b"A").cloned() +} + +#[test] +fn a_table_summary_is_a_table_attribute() { + let mut doc = table_doc("<< /S /TH >>", &["<< /S /MyTable >>"], "8 0 R"); + let (table, th) = (pdfcraft_cos::ObjRef::new(4, 0), pdfcraft_cos::ObjRef::new(6, 0)); + set_tag_property(&mut doc, table, TagProperty::Summary, Some("Opening hours by day")).unwrap(); + let a = attributes(&doc, table).unwrap(); + let a = a.as_dict().unwrap(); + assert_eq!(a.name(b"O"), Some(&b"Table"[..])); + assert_eq!( + a.get(b"Summary").and_then(pdfcraft_cos::Object::as_string).map(pdfcraft_cos::PdfString::to_text).as_deref(), + Some("Opening hours by day") + ); + assert_eq!(tags(&doc).iter().find(|t| t.object == table).unwrap().summary.as_deref(), Some("Opening hours by day")); + // Only a table takes one (after the role map: /MyTable is a table). + assert!(matches!(set_tag_property(&mut doc, th, TagProperty::Summary, Some("No")), Err(TagError::WrongTagType(..)))); + set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(8, 0), TagProperty::Summary, Some("Mapped")).unwrap(); + // Cleared, the attribute object has nothing left and goes. + set_tag_property(&mut doc, table, TagProperty::Summary, None).unwrap(); + assert_eq!(attributes(&doc, table), None); +} + +#[test] +fn a_header_cell_scope_keeps_its_other_attributes() { + // The cell's /A holds a layout attribute object, then a table one with a revision number. + let mut doc = table_doc("<< /S /TH /A [ << /O /Layout /Placement /Block >> << /O /Table /ColSpan 2 >> 1 ] >>", &[], ""); + let th = pdfcraft_cos::ObjRef::new(6, 0); + set_tag_property(&mut doc, th, TagProperty::Scope, Some("Column")).unwrap(); + let Some(pdfcraft_cos::Object::Array(items)) = attributes(&doc, th) else { panic!("{:?}", attributes(&doc, th)) }; + assert_eq!(items.len(), 3, "{items:?}"); + assert_eq!(items[0].as_dict().and_then(|d| d.name(b"O")), Some(&b"Layout"[..])); + let table = items[1].as_dict().unwrap(); + assert_eq!((table.int(b"ColSpan"), table.name(b"Scope")), (Some(2), Some(&b"Column"[..]))); + assert_eq!(items[2].as_int(), Some(1), "the revision number stays with its object"); + assert_eq!(tags(&doc).iter().find(|t| t.object == th).unwrap().scope.as_deref(), Some("Column")); + // Cleared, the span stays. + set_tag_property(&mut doc, th, TagProperty::Scope, None).unwrap(); + let Some(pdfcraft_cos::Object::Array(items)) = attributes(&doc, th) else { panic!() }; + assert_eq!(items[1].as_dict().map(|d| (d.int(b"ColSpan"), d.contains(b"Scope"))), Some((Some(2), false))); + // A scope is Row, Column or Both, and only header cells have one. + for bad in ["row", "Sideways", " Both"] { + assert!(matches!(set_tag_property(&mut doc, th, TagProperty::Scope, Some(bad)), Err(TagError::InvalidScope)), "{bad}"); + } + assert!(matches!(set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(7, 0), TagProperty::Scope, Some("Row")), Err(TagError::WrongTagType(..)))); +} + +/// With two Table attribute objects, the one holding the value is the one set and cleared +/// (#4130 review): a value in the second isn't left behind or shadowed. +#[test] +fn the_table_attribute_object_holding_a_value_is_the_one_edited() { + let mut doc = table_doc("<< /S /TH /A [ << /O /Table /ColSpan 2 >> << /O /Table /Scope /Row >> ] >>", &[], ""); + let th = pdfcraft_cos::ObjRef::new(6, 0); + let scopes = |doc: &Document| -> Vec>> { + let Some(pdfcraft_cos::Object::Array(items)) = attributes(doc, th) else { return vec![] }; + items.iter().map(|i| i.as_dict().and_then(|d| d.name(b"Scope")).map(<[u8]>::to_vec)).collect() + }; + set_tag_property(&mut doc, th, TagProperty::Scope, Some("Column")).unwrap(); + assert_eq!(scopes(&doc), [None, Some(b"Column".to_vec())]); + assert_eq!(tags(&doc).iter().find(|t| t.object == th).unwrap().scope.as_deref(), Some("Column")); + set_tag_property(&mut doc, th, TagProperty::Scope, None).unwrap(); + assert_eq!(scopes(&doc), [None], "the second object had only the scope: {:?}", attributes(&doc, th)); + assert_eq!(tags(&doc).iter().find(|t| t.object == th).unwrap().scope, None); +} + +#[test] +fn a_shared_attribute_object_is_not_changed_for_other_tags() { + // Both cells point at attribute object 8. + let mut doc = table_doc("<< /S /TH /A 8 0 R >>", &["<< /O /Table /Scope /Row >>"], ""); + doc.update_dict(pdfcraft_cos::ObjRef::new(7, 0), |d| d.set(b"A".to_vec(), pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)))).unwrap(); + set_tag_property(&mut doc, pdfcraft_cos::ObjRef::new(6, 0), TagProperty::Scope, Some("Both")).unwrap(); + assert_eq!(doc.get(pdfcraft_cos::ObjRef::new(8, 0)).as_dict().and_then(|d| d.name(b"Scope")), Some(&b"Row"[..])); + let a = attributes(&doc, pdfcraft_cos::ObjRef::new(6, 0)).unwrap(); + assert_eq!(a.as_dict().and_then(|d| d.name(b"Scope")), Some(&b"Both"[..]), "the cell's own copy: {a:?}"); + assert_eq!(attributes(&doc, pdfcraft_cos::ObjRef::new(7, 0)), Some(pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)))); +} + #[test] fn tag_hierarchy_keeps_indirect_parent_through_inline_tags() { let doc = structure_doc(&["<< /S /Document /K 4 0 R >>".into(), "<< /S /Sect /K << /S /Div /K 5 0 R >> >>".into(), "<< /S /Figure >>".into()]); @@ -536,6 +671,126 @@ fn scans_identity_fonts_and_long_documents() { assert_eq!(some.result(Rule::CharacterEncoding).unwrap().findings[0].page, Some(3)); } +fn cid_encoding_pdf(encoding: &str, system: &str, to_unicode: bool) -> Document { + let unicode = if to_unicode { "/ToUnicode 7 0 R" } else { "" }; + pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + stream("/P << /MCID 0 >> BDC BT /F1 12 Tf <002200230024> Tj ET EMC"), + format!("<< /Type /Font /Subtype /Type0 /BaseFont /SyntheticCID /Encoding {encoding} /DescendantFonts [6 0 R] {unicode} >>"), + format!("<< /Type /Font /Subtype /CIDFontType0 /BaseFont /SyntheticCID /CIDSystemInfo {system} >>"), + stream( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /SyntheticUnicode def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 3 beginbfchar <0022> <0041> <0023> <0042> <0024> <0043> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + ), + ], + "", + ) +} + +#[test] +fn character_encoding_accepts_identity_cjk_collections() { + for encoding in ["/Identity-H", "/Identity-V"] { + for ordering in ["Japan1", "GB1", "CNS1", "Korea1", "KR"] { + let system = format!("<< /Registry (Adobe) /Ordering ({ordering}) /Supplement 0 >>"); + let doc = cid_encoding_pdf(encoding, &system, false); + let report = checked(&doc, &[Rule::CharacterEncoding]); + let result = report.result(Rule::CharacterEncoding).unwrap(); + assert_eq!(result.status, Status::Passed, "{encoding}, Adobe-{ordering}: {:?}", result.findings); + assert!(result.findings.is_empty()); + } + } +} + +#[test] +fn character_encoding_accepts_predefined_cmaps_without_a_known_collection() { + // The predefined CMap and the descendant's character collection are alternative routes. + let system = "<< /Registry (Private) /Ordering (Custom) /Supplement 0 >>"; + for name in [ + "GB-EUC-H", + "GB-EUC-V", + "GBK2K-H", + "GBK2K-V", + "UniGB-UTF16-H", + "UniGB-UTF16-V", + "B5pc-H", + "B5pc-V", + "UniCNS-UCS2-H", + "UniCNS-UCS2-V", + "83pv-RKSJ-H", + "90pv-RKSJ-H", + "90ms-RKSJ-H", + "90ms-RKSJ-V", + "H", + "V", + "UniJIS-UCS2-HW-H", + "UniJIS-UCS2-HW-V", + "KSCpc-EUC-H", + "KSC-EUC-V", + "KSCms-UHC-HW-H", + "KSCms-UHC-HW-V", + "UniKS-UTF16-H", + "UniKS-UTF16-V", + "UniAKR-UTF8-H", + "UniAKR-UTF16-H", + "UniAKR-UTF32-H", + ] { + let doc = cid_encoding_pdf(&format!("/{name}"), system, false); + let report = checked(&doc, &[Rule::CharacterEncoding]); + assert_eq!(status(&report, Rule::CharacterEncoding), Status::Passed, "{name}"); + } +} + +#[test] +fn character_encoding_rejects_unknown_cmaps_and_foreign_collections() { + for encoding in + ["/Identity-H", "/Identity-V", "/Custom-H", "/UniCustom-UTF16-H", "/UniJIS-UCS2-HW-extra-H", "/83pv-RKSJ-V", "/KSCpc-EUC-V", "null"] + { + for system in [ + "<< /Registry (Adobe) /Ordering (Identity) /Supplement 0 >>", + "<< /Registry (Adobe) /Ordering (Custom) /Supplement 0 >>", + "<< /Registry (Private) /Ordering (Japan1) /Supplement 0 >>", + "<< /Registry (adobe) /Ordering (Japan1) /Supplement 0 >>", + "<< /Ordering (Japan1) /Supplement 0 >>", + "<< /Registry /Adobe /Ordering /Japan1 /Supplement 0 >>", + "null", + ] { + let doc = cid_encoding_pdf(encoding, system, false); + let report = checked(&doc, &[Rule::CharacterEncoding]); + let result = report.result(Rule::CharacterEncoding).unwrap(); + assert_eq!(result.status, Status::Failed, "{encoding}, {system}"); + assert_eq!(result.findings.len(), 1); + assert_eq!(result.findings[0].page, Some(0)); + assert!(result.findings[0].message.contains("SyntheticCID")); + } + } + let doc = cid_encoding_pdf("/Identity-H", "<< /Registry (Private) /Ordering (Custom) /Supplement 0 >>", true); + assert_eq!(status(&checked(&doc, &[Rule::CharacterEncoding]), Rule::CharacterEncoding), Status::Passed, "ToUnicode is still sufficient"); +} + +#[test] +fn character_encoding_resolves_indirect_cid_metadata() { + use pdfcraft_cos::{Dict, ObjRef, PdfString}; + let mut doc = cid_encoding_pdf("/Identity-H", "null", false); + let registry = doc.add(Object::String(PdfString::literal(b"Adobe".to_vec()))); + let ordering = doc.add(Object::String(PdfString::literal(b"Japan1".to_vec()))); + let mut system = Dict::new(); + system.set(b"Registry".to_vec(), Object::Ref(registry)); + system.set(b"Ordering".to_vec(), Object::Ref(ordering)); + let system = doc.add(Object::Dict(system)); + doc.update_dict(ObjRef::new(6, 0), |d| d.set(b"CIDSystemInfo".to_vec(), Object::Ref(system))).unwrap(); + let descendants = doc.add(Object::Array(vec![Object::Ref(ObjRef::new(6, 0))])); + let encoding = doc.add(Object::Name(b"Identity-V".to_vec())); + doc.update_dict(ObjRef::new(5, 0), |d| { + d.set(b"DescendantFonts".to_vec(), Object::Ref(descendants)); + d.set(b"Encoding".to_vec(), Object::Ref(encoding)); + }) + .unwrap(); + let report = checked(&doc, &[Rule::CharacterEncoding]); + assert_eq!(status(&report, Rule::CharacterEncoding), Status::Passed); +} + #[test] fn rules_have_ids_and_the_report_lists_them() { assert_eq!(Rule::ALL.len(), 32); @@ -865,6 +1120,67 @@ fn glyph_by_tm(text: &str, size: f64, y: f64) -> String { ops + " ET" } +/// Marked-content stack reads while `f` runs. +fn mark_reads(f: impl FnOnce()) -> usize { + MARK_READS.with(|c| c.set(0)); + f(); + MARK_READS.with(|c| c.get()) +} + +/// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside. +fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict { + use pdfcraft_cos::{Dict, Stream}; + let mut data = "/Figure << /MCID 0 >> BDC ".to_string(); + data.push_str(&"/X BMC ".repeat(depth)); + data.push_str(&"0 0 1 1 re f ".repeat(ops)); + data.push_str(&"EMC ".repeat(depth + 1)); + let content = doc.add(Stream::flate(Dict::new(), data.as_bytes())); + [(b"Contents".to_vec(), Object::Ref(content))].into_iter().collect() +} + +#[test] +fn marked_text_reads_its_enclosing_id_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + let reads = mark_reads(|| { + crate::headings::mcid_texts(&doc, &page); + }); + // About 15 000 operators; a scan of the stack per operator reads millions of entries. + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + +#[test] +fn figure_bounds_read_the_marked_content_state_in_constant_steps() { + let mut doc = Document::new_empty(); + let page = deeply_nested_page(&mut doc, 5_000, 5_000); + // No mark encloses figure 7, so a scan of the stack per operator reads all of it. + let reads = mark_reads(|| { + crate::alt::content_bboxes(&doc, &page, &std::collections::HashSet::from([7])); + }); + assert!(reads <= 4 * 15_000, "{reads} stack reads"); +} + +#[test] +fn figure_bounds_take_one_pass_over_each_page() { + let n = 50; + let content: Vec = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect(); + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(), + stream(&content.join(" ")), + format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")), + ]; + objs.extend((0..n).map(|i| format!("<< /S /Figure /Pg 3 0 R /K {i} >>"))); + let doc = pdf(&objs, ""); + BOUNDS_PASSES.with(|c| c.set(0)); + let f = figures(&doc); + assert_eq!(BOUNDS_PASSES.with(|c| c.get()), 1, "page passes"); + assert_eq!(f.len(), n); + assert!(f.iter().enumerate().all(|(i, f)| f.bbox == Some([i as f64, 0.0, i as f64 + 1.0, 1.0]))); +} + /// One tagged page whose H1 headings show `contents` (one marked-content sequence each). fn heading_page(contents: &[String]) -> Document { let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect(); @@ -919,6 +1235,19 @@ fn headings_drawn_glyph_by_glyph_keep_their_words() { ); } +/// Inside a text object `q`/`Q` save and restore the text matrices (ISO 32000-2 §9.4.1, as +/// corrected by the PDF Association's errata, issue 368), as the renderer does: a word whose +/// glyphs continue after a `Q` stays one word. +#[test] +fn q_inside_a_text_object_restores_the_text_matrices() { + let doc = heading_page(&[ + "BT /F1 20 Tf 72 700 Td (Chap) Tj q 0 -100 Td Q (ter One) Tj ET".into(), + "BT /F1 20 Tf 72 650 Td (Chap) Tj q 1 0 0 1 300 300 Tm Q (ter Two) Tj ET".into(), + ]); + let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); + assert_eq!(titles, ["Chapter One", "Chapter Two"]); +} + #[test] fn an_untagged_document_has_no_headings() { let mut objs = headings_doc(); @@ -926,6 +1255,16 @@ fn an_untagged_document_has_no_headings() { assert!(headings(&pdf(&objs, "")).is_empty()); } +/// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still +/// draws only the one immediately before it, as the renderer and every other reader agree (#3569) +/// -- not the first, which is never drawn at all. +#[test] +fn a_heading_with_a_tj_surplus_array_reads_the_one_the_page_draws() { + let doc = heading_page(&["BT /F1 20 Tf 72 700 Td [(FIRSTARR)] [(LASTARR)] TJ ET".into()]); + let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); + assert_eq!(titles, ["LASTARR"]); +} + /// The text of every `"…"` literal with a space in it, outside comments and byte strings. fn sentences(source: &str) -> Vec { let b = source.as_bytes(); @@ -1023,3 +1362,75 @@ fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() { let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); assert_eq!(titles, ["I Overview"]); } + +#[test] +fn repeated_content_references_share_one_decode_budget() { + use pdfcraft_cos::{Dict, MAX_PAGE_DECODED, Object, Stream}; + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect(); + let data = crate::alt::page_content(&doc, &page); + assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len()); +} + +/// A figure whose marked content goes on in a stream past the page's decode budget is refused, +/// not removed with that content still pointing at it. +#[test] +fn marking_decorative_refuses_content_past_the_decode_budget() { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let mut doc = figures_doc(); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(last)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err()); + assert_eq!(figures(&doc).len(), 2, "the figure stays"); +} + +/// Text past the page's decode budget is unread, so a page that shows images and no text so far +/// can't be called image-only, nor its content all tagged. +#[test] +fn content_past_the_decode_budget_needs_a_manual_check() { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>" + .into(), + stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" + .into(), + FONT.into(), + ], + "", + ); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let text = doc.add(Stream::from_raw(Dict::new(), b"BT /F1 12 Tf 10 10 Td (Hello) Tj ET".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(text)); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + let report = check(&doc, &Options::default()); + assert_eq!(status(&report, Rule::ImageOnly), Status::Manual); + assert_eq!(status(&report, Rule::TaggedContent), Status::Manual); +} + +/// Marking a tag as an artifact replaces the page's content with what it decoded, so content past +/// the page's decode budget is refused rather than dropped from the page. +#[test] +fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() { + use pdfcraft_cos::{Dict, ObjRef, Object, Stream}; + let mut doc = figures_doc(); + let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let last = doc.add(Stream::from_raw(Dict::new(), b"0 0 5 5 re f".to_vec())); + let mut contents = vec![Object::Ref(ObjRef::new(4, 0))]; + contents.extend(std::iter::repeat_n(Object::Ref(filler), 256)); + contents.push(Object::Ref(last)); + let contents = Object::Array(contents); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), contents.clone())).unwrap(); + assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge)); + assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content"); +} diff --git a/crates/a11y/tests/cross_stream.rs b/crates/a11y/tests/cross_stream.rs new file mode 100644 index 0000000000..ad613a2939 --- /dev/null +++ b/crates/a11y/tests/cross_stream.rs @@ -0,0 +1,254 @@ +use pdfcraft_a11y::{Options, Rule, Status, autotag_text, check, figures, mark_decorative, mark_tag_artifact, tag_content, tags}; +use pdfcraft_content::{Op, Pieces}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental}; + +const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; +const HIDDEN_AND_AFTER: &str = "BT /F1 12 Tf 72 520 Td (Hidden) Tj ET\nBT /F1 12 Tf 72 500 Td (After) Tj ET"; + +fn stream(data: &str) -> String { + format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +fn build_pdf(objects: &[(u32, String)]) -> Document { + let mut raw = Vec::from(&b"%PDF-1.7\n"[..]); + let max_num = objects.iter().map(|(n, _)| *n).max().unwrap_or(0); + let mut offsets = std::collections::HashMap::new(); + for (num, body) in objects { + offsets.insert(*num, raw.len()); + raw.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + } + let xref = raw.len(); + let size = max_num + 1; + raw.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for num in 1..size { + if let Some(off) = offsets.get(&num) { + raw.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } else { + raw.extend_from_slice(b"0000000000 65535 f \n"); + } + } + raw.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Document::open(std::sync::Arc::new(raw)).expect("valid synthetic PDF") +} + +fn untagged_two_streams(first: &str, second: &str) -> Document { + build_pdf(&[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into()), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents [5 0 R 6 0 R] /Resources << /Font << /F1 4 0 R >> >> >>".into()), + (4, FONT.into()), + (5, stream(first)), + (6, stream(second)), + ]) +} + +fn tagged_figure_two_streams(after_figure: &str, second: &str) -> Document { + let figure = "/Figure << /MCID 0 >> BDC q 50 0 0 50 72 600 cm /Im1 Do Q EMC\n"; + let first = format!("{figure}{after_figure}"); + build_pdf(&[ + ( + 1, + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R /MarkInfo << /Marked true >> >>" + .into(), + ), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()), + ( + 3, + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /StructParents 0 /Contents [10 0 R 11 0 R] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 5 0 R >> >> >>".into(), + ), + (4, FONT.into()), + ( + 5, + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\x00\x7f\x40\x20\nendstream".into(), + ), + (6, "<< /Type /StructTreeRoot /K 7 0 R /ParentTree 9 0 R >>".into()), + (7, "<< /Type /StructElem /S /Document /P 6 0 R /K [8 0 R] >>".into()), + ( + 8, + "<< /Type /StructElem /S /Figure /P 7 0 R /Pg 3 0 R /K 0 /Alt (A grey square) >>" + .into(), + ), + (9, "<< /Nums [0 [8 0 R]] >>".into()), + (10, stream(&first)), + (11, stream(second)), + ]) +} + +fn joined_ops(doc: &Document) -> (Vec, usize) { + let page = &pdfcraft_model::pages(doc)[0]; + let contents = page.dict.get(b"Contents").expect("page contents"); + let pieces: Vec> = match &*doc.resolve(contents) { + Object::Stream(s) => vec![s.decoded().expect("decoded stream")], + Object::Array(items) => items + .iter() + .filter_map(|item| match &*doc.resolve(item) { + Object::Stream(s) => Some(s.decoded().expect("decoded stream")), + _ => None, + }) + .collect(), + _ => Vec::new(), + }; + let joined = Pieces::join(&pieces); + let parsed = joined.parse(); + (parsed.ops, parsed.skipped) +} + +fn shown_strings(ops: &[Op]) -> Vec { + ops.iter().filter(|op| op.is("Tj")).filter_map(|op| op.operands.first()?.as_string().map(|s| s.to_text())).collect() +} + +#[test] +fn mark_decorative_leaves_the_other_stream_untouched() { + let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET"); + let before = doc.get(ObjRef::new(11, 0)).clone(); + mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds"); + assert!(figures(&doc).is_empty()); + assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object"); + let page = doc.get(ObjRef::new(3, 0)); + let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap(); + assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy"); +} + +/// A and B supply different operands to R's BDC. Reusing R must not let the first +/// occurrence's splice remove the unselected second occurrence's BDC. +fn split_marked_content(shared: bool) -> Document { + let mut doc = tagged_figure_two_streams("", ""); + doc.set(ObjRef::new(10, 0), Object::Stream(Stream::flate(Dict::new(), b"/Figure << /MCID 0 >>"))); + let body = Object::Stream(Stream::flate(Dict::new(), b"BDC 0 0 10 10 re f EMC")); + doc.set(ObjRef::new(11, 0), body.clone()); + let b = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Span << /MCID 1 >>"))); + let second = if shared { ObjRef::new(11, 0) } else { doc.add(body) }; + doc.update_dict(ObjRef::new(3, 0), |p| { + p.set( + b"Contents".to_vec(), + Object::Array(vec![Object::Ref(ObjRef::new(10, 0)), Object::Ref(ObjRef::new(11, 0)), Object::Ref(b), Object::Ref(second)]), + ); + }) + .unwrap(); + Document::open(std::sync::Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() +} + +fn assert_selected_occurrence_only(shared: bool) { + let mut doc = split_marked_content(shared); + let (before, skipped) = joined_ops(&doc); + assert_eq!(skipped, 0); + assert_eq!(before.iter().filter(|op| op.is("BDC")).count(), 2); + let figure = figures(&doc)[0].obj; + mark_decorative(&mut doc, figure).unwrap(); + for bytes in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] { + let reopened = Document::open(std::sync::Arc::new(bytes)).unwrap(); + let (ops, skipped) = joined_ops(&reopened); + assert_eq!(skipped, 0); + assert_eq!(ops.iter().filter(|op| op.is("BMC") && op.name(0) == Some(b"Artifact")).count(), 1); + let unselected: Vec<_> = ops.iter().filter(|op| op.is("BDC")).collect(); + assert_eq!(unselected.len(), 1, "unselected Span must retain its BDC after save/reopen"); + assert_eq!(unselected[0].name(0), Some(b"Span".as_slice())); + assert_eq!(unselected[0].operands[1].as_dict().unwrap().get(b"MCID").and_then(Object::as_int), Some(1)); + assert_eq!(ops.iter().filter(|op| op.is("re")).count(), 2); + assert_eq!(ops.iter().filter(|op| op.is("EMC")).count(), 2); + assert!(figures(&reopened).is_empty()); + } +} + +#[test] +fn mark_decorative_isolates_shared_stream_occurrences_after_save() { + assert_selected_occurrence_only(true); +} + +#[test] +fn mark_decorative_preserves_unique_stream_occurrences_after_save() { + assert_selected_occurrence_only(false); +} + +#[test] +fn mark_decorative_leaves_another_page_using_the_stream_untouched() { + let mut doc = tagged_figure_two_streams("", "BT /F1 12 Tf 72 500 Td (After) Tj ET"); + let page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let other_page = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |p| { + p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(other_page)])); + p.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + let before = doc.get(ObjRef::new(10, 0)).clone(); + mark_decorative(&mut doc, ObjRef::new(8, 0)).unwrap(); + for bytes in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] { + let reopened = Document::open(std::sync::Arc::new(bytes)).unwrap(); + let pages = pdfcraft_model::pages(&reopened); + assert_eq!(pages.len(), 2); + let contents = pages[1].dict.get(b"Contents").unwrap().as_array().unwrap(); + assert_eq!(*reopened.resolve(&contents[0]), *before, "the other page's shared figure stream must stay intact"); + let (ops, skipped) = joined_ops(&reopened); + assert_eq!(skipped, 0); + assert!(ops.iter().any(|op| op.is("BMC") && op.name(0) == Some(b"Artifact"))); + } +} + +#[test] +fn autotag_keeps_text_commented_out_across_stream_boundary_hidden() { + let mut doc = untagged_two_streams("BT /F1 12 Tf 72 540 Td (Visible) Tj ET\n% a note at the end of the first stream", HIDDEN_AND_AFTER); + let result = autotag_text(&mut doc).expect("autotag succeeds"); + assert_eq!(result.pages, 1); + assert_eq!(result.paragraphs, 2, "commented-out text at start of second stream must not be tagged"); + + let (ops, skipped) = joined_ops(&doc); + assert_eq!(skipped, 0); + assert_eq!(shown_strings(&ops), ["Visible", "After"]); + + let p_tags: Vec<_> = tags(&doc).into_iter().filter(|t| t.kind == "P").collect(); + assert_eq!(p_tags.len(), 2); + let texts: Vec<_> = p_tags.iter().flat_map(|t| tag_content(&doc, t.object).expect("tag content")).map(|item| item.text).collect(); + assert_eq!(texts, ["Visible", "After"]); + + // Control: when the comment ends with a newline inside the first stream, Hidden is visible. + let mut control = untagged_two_streams("BT /F1 12 Tf 72 540 Td (Visible) Tj ET\n% a note at the end of the first stream\n", HIDDEN_AND_AFTER); + let control_res = autotag_text(&mut control).expect("control autotag succeeds"); + assert_eq!(control_res.paragraphs, 3); + let (control_ops, _) = joined_ops(&control); + assert_eq!(shown_strings(&control_ops), ["Visible", "Hidden", "After"]); +} + +#[test] +fn mark_decorative_preserves_shape_split_across_content_streams() { + let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50", "re f\nBT /F1 12 Tf 72 500 Td (After) Tj ET"); + mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds"); + assert!(figures(&doc).is_empty()); + + let (ops, skipped) = joined_ops(&doc); + assert_eq!(skipped, 0, "split rectangle operands must not be dropped as skipped tokens"); + let re_op = ops.iter().find(|op| op.is("re")).expect("rectangle operator must survive"); + assert_eq!(re_op.nums::<4>(), Some([300.0, 600.0, 100.0, 50.0])); + assert_eq!(shown_strings(&ops), ["After"]); + + // Control: rectangle whole in the first stream. + let mut control = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET"); + mark_decorative(&mut control, ObjRef::new(8, 0)).expect("control mark decorative succeeds"); + let (control_ops, control_skipped) = joined_ops(&control); + assert_eq!(control_skipped, 0); + let control_re = control_ops.iter().find(|op| op.is("re")).expect("control rectangle"); + assert_eq!(control_re.nums::<4>(), Some([300.0, 600.0, 100.0, 50.0])); + assert_eq!(shown_strings(&control_ops), ["After"]); +} + +#[test] +fn mark_decorative_and_mark_artifact_keep_cross_stream_comment_hidden() { + let mut doc = tagged_figure_two_streams("% a note at the end of the first stream", HIDDEN_AND_AFTER); + mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds"); + let (ops, skipped) = joined_ops(&doc); + assert_eq!(skipped, 0); + assert_eq!(shown_strings(&ops), ["After"], "commented-out Hidden text must not become visible after mark_decorative"); + + let mut doc_artifact = tagged_figure_two_streams("% a note at the end of the first stream", HIDDEN_AND_AFTER); + mark_tag_artifact(&mut doc_artifact, ObjRef::new(8, 0)).expect("mark_tag_artifact succeeds"); + let (artifact_ops, artifact_skipped) = joined_ops(&doc_artifact); + assert_eq!(artifact_skipped, 0); + assert_eq!(shown_strings(&artifact_ops), ["After"], "commented-out Hidden text must not become visible after mark_tag_artifact"); + + // Checker also honours the cross-stream comment when scanning for untagged content. + let mut comment_only = tagged_figure_two_streams("% a note at the end of the first stream", "BT /F1 12 Tf 72 520 Td (Hidden) Tj ET\n"); + let report = check(&comment_only, &Options { rules: [Rule::TaggedContent].into(), pages: None }); + assert_eq!(report.result(Rule::TaggedContent).expect("rule result").status, Status::Passed); + mark_decorative(&mut comment_only, ObjRef::new(8, 0)).expect("mark decorative"); + let report_after = check(&comment_only, &Options { rules: [Rule::TaggedContent].into(), pages: None }); + assert_eq!(report_after.result(Rule::TaggedContent).expect("rule result").status, Status::Passed); +} diff --git a/crates/annot/README.md b/crates/annot/README.md index 8ba3ca8c4e..999baec20a 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -9,10 +9,12 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, line and arrow (`Line` with `/LE`), freehand drawing (`Ink`) and text box (`FreeText`). - **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`). + A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as + ISO 32000-2 Table 178 places them, the caption in black Helvetica 10. Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't - draw (in a text box, callout, Fill & Sign text, measurement label or dynamic stamp's by-line; - `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with + draw (in a text box, callout, Fill & Sign text, line caption, measurement label or dynamic + stamp's by-line; `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and flattening refuses to make it permanent. Two refusals (`AnnotError::Undrawable`): a redaction mark's overlay text, which becomes page content when applied; and a restyle or resize of a @@ -28,7 +30,7 @@ let index = add_annotation(&mut doc, &NewAnnotation { page, shape, style, conten add_reply(&mut doc, page, index, "Agreed", "Ada", &meta)?; set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?; move_annotation(&mut doc, page, index, dx, dy, &meta)?; -set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?; +set_style(&mut doc, page, index, StyleChange::new(Some(rgb), Some(0.5), Some(2.0), None), &meta)?; delete_annotation(&mut doc, page, index)?; signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials ``` @@ -51,6 +53,11 @@ deterministic; the engine supplies them. signature) and PdfCraft's rubber stamps counterrotate their appearance on rotated pages, so the picture or label reads upright as the page is displayed. Restyling a picture stamp keeps that appearance matrix. The rotation is the page's `/Rotate` at placement. +- Sticky notes and file attachments are not counterrotated: they carry the NoRotate flag and + their icon is drawn in an unturned 20 × 20 box. A viewer keeps them upright on a turned page, + about the upper-left corner of `/Rect` (ISO 32000-2 §12.5.3). PdfCraft's renderer does so + (vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is + drawn. ## Not yet diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 0f0706428f..abc746c0dd 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -16,6 +16,11 @@ pub use pdfcraft_fonts::{helvetica_width as text_width, wrap}; use crate::{NOTE_SIZE, Rgb, n}; +mod symbols; + +pub(crate) use symbols::display_stamp; +use symbols::{CAPTION_SIZE, attach_icon, ellipse, line_caption, line_layout, note_icon, stamp}; + /// Length of an arrowhead for a line of width `w`. pub fn arrow_size(w: f64) -> f64 { 6.0 + 3.0 * w.max(0.0) @@ -120,12 +125,18 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String { smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect() } +/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow. +pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000; + /// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of /// half-circle bumps of about `r`, bulging outwards. pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String { let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum(); // Outward normal of an edge: to the right of travel for counter-clockwise outlines. let sign = if area >= 0.0 { 1.0 } else { -1.0 }; + let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum(); + // Each edge rounds to at least one bump, so the total stays within the budget plus one per edge. + let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64)); let mut c = String::new(); for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() { let (dx, dy) = (b.0 - a.0, b.1 - a.1); @@ -174,23 +185,42 @@ fn interior_color(d: &Dict, fallback: Option) -> Option { color(d, b"IC").flatten().or(fallback) } -fn border_width(d: &Dict) -> f64 { - if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) { - return w.max(0.0); - } - match nums(d, b"Border") { - Some(b) if b.len() >= 3 => b[2].max(0.0), - _ => 1.0, - } +/// The border width (ISO 32000-2 §12.5.4): `/BS /W` when there is a border style dictionary, which +/// makes `/Border` ignored (Table 166), else the third element of `/Border [hr vr w [dash]]`, read +/// whether or not the optional fourth element, a dash array, follows. 1 when neither says, and +/// when the number given is negative or not finite. +pub(crate) fn border_width(d: &Dict) -> f64 { + let width = match d.get(b"BS").and_then(Object::as_dict) { + Some(bs) => bs.get(b"W").and_then(Object::as_f64), + None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_f64), + }; + width.filter(|w| w.is_finite() && *w >= 0.0).unwrap_or(1.0) } -/// Dash pattern operator for a dashed border, if any. +/// Elements a dash array may have before it is drawn solid instead: the lengths are written into +/// the appearance as they are, so a file can't make it arbitrarily large. +const MAX_DASH: usize = 64; + +/// A dash array (§8.4.3.6) as drawn: every element finite and non-negative, not all zero (that +/// pattern is invalid) and at most [`MAX_DASH`] long. `None` for anything else, which is drawn solid. +fn dash_array(o: &Object) -> Option> { + let a = o.as_array().filter(|a| !a.is_empty() && a.len() <= MAX_DASH)?; + let v: Vec = a.iter().map(Object::as_f64).collect::>()?; + (v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0)).then_some(v) +} + +/// Dash pattern operator for a dashed border, if any: `/BS /S /D` with its `/D` (default `[3]`, +/// Table 168), or, without `/BS`, the dash array that may follow the width in `/Border` (Table 166). fn dash(d: &Dict) -> String { - let Some(bs) = d.get(b"BS").and_then(|b| b.as_dict()) else { return String::new() }; - if bs.name(b"S") != Some(b"D") { - return String::new(); - } - let arr = nums(bs, b"D").filter(|a| !a.is_empty() && a.iter().all(|x| *x >= 0.0)).unwrap_or_else(|| vec![3.0]); + let arr = match d.get(b"BS").and_then(Object::as_dict) { + Some(bs) if bs.name(b"S") != Some(b"D") => return String::new(), + Some(bs) => match bs.get(b"D") { + None => Some(vec![3.0]), + Some(o) => dash_array(o), + }, + None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(3)).and_then(dash_array), + }; + let Some(arr) = arr else { return String::new() }; format!("[{}] 0 d\n", arr.iter().map(|x| n(*x)).collect::>().join(" ")) } @@ -277,13 +307,14 @@ fn text_turn(d: &Dict) -> i64 { /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The -/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's -/// "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of +/// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a +/// dynamic stamp's "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of /// it can be drawn. pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); let (what, text) = match d.name(b"Subtype")? { b"FreeText" => ("the comment's text", text(b"Contents")?), + b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?), b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?), b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?), // Drawn in Helvetica when the redaction is applied: refused when it is marked. @@ -293,16 +324,109 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { Some((what, first_undrawable(&text)?)) } +/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. +fn helvetica_resource() -> Object { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let mut fonts = Dict::new(); + fonts.set(b"Helv".to_vec(), Object::Dict(font)); + Object::Dict(fonts) +} + +/// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for +/// `/Sy /P` the symbol's baseline origin and font size (ISO 32000-2 Table 183). +/// +/// The caret lies within `/Rect` less `/RD` (left, top, right, bottom), when `/RD` is four finite, +/// non-negative numbers that leave a box; otherwise within `/Rect`. The ¶ sits to the right of the +/// caret, its height a little over half the caret's, in the room `/RD` leaves on the right; when +/// that is too narrow, the caret gives up to half its width and the symbol shrinks to what is left, +/// so both stay inside `/Rect` (the form's `/BBox`). +fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) { + let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0)); + let mut caret = match rd { + Some(rd) => [rect[0] + rd[0], rect[1] + rd[3], rect[2] - rd[2], rect[3] - rd[1]], + None => rect, + }; + if !(caret[2] > caret[0] && caret[3] > caret[1]) { + caret = rect; + } + if d.name(b"Sy") != Some(b"P") { + return (caret, None); + } + let size = (caret[3] - caret[1]) * 0.6; + let (gap, glyph) = (size * 0.1, text_width("\u{b6}", size)); + let need = gap + glyph; + let room = rect[2] - caret[2]; + if room < need { + caret[2] -= (need - room).min((caret[2] - caret[0]) * 0.5); + } + let room = rect[2] - caret[2]; + let scale = if need > room { room / need } else { 1.0 }; + let size = size * scale; + if !(size.is_finite() && size > 0.0) { + return (caret, None); + } + // The pilcrow descends about a sixth of the size below its baseline. + (caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size))) +} + +#[derive(Clone, Copy)] +struct AppearanceContext<'a> { + dict: &'a Dict, + subtype: &'a [u8], + rect: [f64; 4], + cloudy: bool, + opacity: f64, + stroke: Option, + width: f64, +} + +#[derive(Default)] +struct AppearanceDrawing { + resources: Dict, + content: String, + tail: Vec, +} + +impl AppearanceDrawing { + fn new(context: &AppearanceContext<'_>) -> Self { + let mut drawing = Self::default(); + if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") { + drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); + drawing.content.push_str("/GS0 gs\n"); + } + drawing + } +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(d: &Dict) -> Option { + let context = appearance_context(d)?; + let drawing = AppearanceDrawing::new(&context); + match context.subtype { + b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), + b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing), + b"Stamp" => return draw_stamp(&context, drawing), + b"FreeText" => return draw_free_text(&context, drawing), + _ => {} + } + let mut drawing = drawing; + draw_body(&context, &mut drawing)?; + finish_appearance(&context, drawing) +} + +fn appearance_context(d: &Dict) -> Option> { // Imported measurement appearances may include leaders, captions and formatting that // this builder cannot reproduce. Preserve them rather than silently losing detail. if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") { return None; } - let subtype = d.name(b"Subtype")?.to_vec(); + let subtype = d.name(b"Subtype")?; let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?; let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; // Cloudy borders (§12.5.4) are drawn for polygons only. @@ -310,668 +434,576 @@ pub fn build(d: &Dict) -> Option { if cloudy && subtype != b"Polygon" { return None; } - let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); - let stroke = color(d, b"C")?; - let w = border_width(d); - let mut res = Dict::new(); - let mut c = String::new(); - let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly"); - if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") { - res.set(b"ExtGState".to_vec(), Object::Dict(gs)); - c.push_str("/GS0 gs\n"); - } - match subtype.as_slice() { - b"Text" => { - let col = stroke.unwrap_or([1.0, 0.82, 0.0]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "Note".into()); - c.push_str(¬e_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - b"FileAttachment" => { - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into()); - c.push_str(&attach_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - _ if markup => { - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke?; - for quad in q.as_chunks::<8>().0.iter() { - let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); - let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); - let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); - // A point a fraction `t` of the way from the bottom edge to the top edge. - let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); - match subtype.as_slice() { - b"Highlight" => { - c.push_str(&rg(col)); - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h f\n", - n(p1.0), - n(p1.1), - n(p2.0), - n(p2.1), - n(p4.0), - n(p4.1), - n(p3.0), - n(p3.1) - )); - } - b"Underline" | b"StrikeOut" => { - let lw = (h * 0.07).clamp(0.5, 3.0); - let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; - let (a, b) = (at(p3, p1, t), at(p4, p2, t)); - c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); - } - _ => { - // Squiggly: a zigzag along the bottom of the quad. - let lw = (h * 0.05).clamp(0.5, 2.0); - let amp = (h * 0.06).max(0.75); - let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); - let step = (h / 4.0).max(1.5); - let steps = ((len / step).ceil() as usize).clamp(1, 10_000); - c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); - for i in 0..=steps { - let t = i as f64 / steps as f64; - let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); - let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; - let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; - let (x, y) = (base.0 + ux * up, base.1 + uy * up); - c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - } - } - } - } - b"Redact" => { - // While marked: the outline of each area (the fill comes when applied). - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke.unwrap_or([0.89, 0.13, 0.13]); - c.push_str(&format!("{}1 w\n", rg_stroke(col))); - for quad in q.as_chunks::<8>().0.iter() { - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h S\n", - n(quad[0]), - n(quad[1]), - n(quad[2]), - n(quad[3]), - n(quad[6]), - n(quad[7]), - n(quad[4]), - n(quad[5]) - )); - } - } - b"Square" | b"Circle" => { - let fill = interior_color(d, None); - if stroke.is_none() && fill.is_none() { - return Some(form(rect, c.as_bytes(), res)); - } - let inset = w / 2.0; - let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = stroke { - c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); - } - if subtype == b"Square" { - c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); - } else { - c.push_str(&ellipse(x0, y0, x1, y1)); - } - c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - } - b"Line" => { - let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; - let col = stroke?; - let ends: Vec> = match d.get(b"LE") { - None => vec![b"None".to_vec(), b"None".to_vec()], - Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, - }; - if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { - return None; - } - let fill = interior_color(d, Some(col)).unwrap_or(col); - c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); - c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); - for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) { - if !line_end(&mut c, end, tip, from, w) { - return None; - } - } - } - b"Polygon" | b"PolyLine" => { - let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; - let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let col = stroke; - let closed = subtype == b"Polygon"; - let fill = if closed { interior_color(d, None) } else { None }; - let ends = if subtype == b"PolyLine" { - match d.get(b"LE") { - None => None, - Some(o) => { - let names: Vec> = - o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; - if names.iter().any(|e| !known_ending(e)) { - return None; - } - Some(names) - } - } - } else { - None - }; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = col { - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); - } - if cloudy { - let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); - c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); - } else { - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - if closed { - c.push_str("h\n"); - } - } - c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - if let Some(ends) = ends { - let col = col?; - let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; - let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; - let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; - let fill = interior_color(d, Some(col)).unwrap_or(col); - c.push_str(&format!("[] 0 d\n{}", rg(fill))); - if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) { - return None; - } - } - } - b"Caret" => { - // A filled caret: two curved flanks meeting at the top centre. - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let [x0, y0, x1, y1] = rect; - let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); - c.push_str(&format!( - "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", - rg(col), - n(x0), - n(y0), - n(cx - (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(cx), - n(y1 - h * 0.25), - n(cx), - n(y1), - n(cx), - n(y1 - h * 0.25), - n(cx + (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(x1), - n(y0) - )); - } - b"Ink" => { - let col = stroke?; - let list = d.get(b"InkList")?.as_array()?; - c.push_str(&format!("{}{} w 1 J 1 j\n", rg_stroke(col), n(w))); - for s in list { - let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; - let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let Some(first) = pts.first() else { continue }; - c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); - match pts.as_slice() { - [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), - [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), - _ => c.push_str(&smooth_curve(&pts)), - } - c.push_str("S\n"); - } - } - b"Stamp" => { - // A typed signature: filled outlines normalised to the rectangle. - if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); - for contour in outline { - let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); - for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); - } - if v.len() >= 6 { - c.push_str("h\n"); - } - } - c.push_str("f*\n"); - return Some(form(rect, c.as_bytes(), res)); - } - // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets - // the standard counter-rotation as its form `/Matrix`: viewers fit the turned - // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. - if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) - }; - c.push_str(&format!("q {place} /Pic Do Q\n")); - let mut xo = Dict::new(); - xo.set(b"Pic".to_vec(), Object::Ref(pic)); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, c.as_bytes(), res); - let turn = picture_turn(d); - if image && turn != 0 { - // The page's view matrix without its offset: display axes in user space. - let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); - let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; - stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); - } - return Some(stream); - } - // Only PdfCraft's own Fill & Sign marks are drawn here. - let name = d.name(b"Name")?; - let col = stroke.unwrap_or([0.0; 3]); - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - let lw = w.min(h) * 0.12; - match name { - b"PCCheck" => c.push_str(&format!( - "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.15), - n(y0 + h * 0.5), - n(x0 + w * 0.4), - n(y0 + h * 0.2), - n(x0 + w * 0.88), - n(y0 + h * 0.85) - )), - b"PCCross" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.18), - n(y0 + h * 0.18), - n(x1 - w * 0.18), - n(y1 - h * 0.18), - n(x0 + w * 0.18), - n(y1 - h * 0.18), - n(x1 - w * 0.18), - n(y0 + h * 0.18) - )), - b"PCDot" => { - let r = w.min(h) * 0.3; - c.push_str(&rg(col)); - c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); - c.push_str("f\n"); - } - b"PCLine" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l S\n", - rg_stroke(col), - n(h.clamp(0.5, 2.0)), - n(x0), - n(y0 + h / 2.0), - n(x1), - n(y0 + h / 2.0) - )), - other => { - // Only stamps PdfCraft made: others keep their own artwork. - if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { - return None; - } - let kind = crate::StampKind::from_name(other)?; - let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - let turn = stamp_turn(d); - let draw = if turn == 0 { - rect - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - [0.0, 0.0, dw, dh] - }; - let mut stream = stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res); - if turn != 0 { - // Upright in the stamp's displayed box, then mapped into `/Rect` the way - // the page maps display space into user space. Viewers fit that box to - // `/Rect`; the page rotation then shows the label the right way up. - let m = pdfcraft_model::view_matrix_for(turn, rect); - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - return Some(stream); - } - } - } - b"FreeText" => { - let (text_color, size) = parse_da(d); - let bg = stroke; - let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; - // A callout: the leader line (arrowhead at its first point), and the text box inside - // `/Rect` by `/RD`. - // Text added to a turned page is drawn upright in its displayed box, which the form - // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. - let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; - let (rect, matrix) = if turn == 0 { - (rect, None) - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) - }; - let full = rect; - let mut rect = rect; - if d.contains(b"CL") { - let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; - let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; - rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; - if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return None; - } - let end = match d.get(b"LE") { - None => b"None".to_vec(), - Some(o) => o.as_name()?.to_vec(), - }; - if !known_ending(&end) { - return None; - } - let lw = bw.max(0.5); - let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { - return None; - } - c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - if !line_end(&mut c, &end, pts[0], pts[1], lw) { - return None; - } - } - if let Some(bg) = bg { - c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); - } - if bw > 0.0 { - let h = bw / 2.0; - c.push_str(&format!( - "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(text_color), - n(bw), - dash(d), - n(rect[0] + h), - n(rect[1] + h), - n(rect[2] - rect[0] - bw), - n(rect[3] - rect[1] - bw) - )); - } - let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); - let pad = 2.0 + bw; - let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); - let q = d.int(b"Q").unwrap_or(0); - c.push_str(&format!( - "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", - n(rect[0]), - n(rect[1]), - n(rect[2] - rect[0]), - n(rect[3] - rect[1]), - n(size), - rg(text_color) - )); - let mut out = c.into_bytes(); - let mut y = rect[3] - pad - size * 0.9; - for line in wrap(&text, size, width) { - if y < rect[1] - size { - break; - } - let lw = text_width(&line, size); - let x = match q { - 1 => rect[0] + pad + (width - lw) / 2.0, - 2 => rect[2] - pad - lw, - _ => rect[0] + pad, - }; - out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - out.extend(literal(&win_ansi(&line))); - out.extend_from_slice(b" Tj\n"); - y -= size * 1.2; - } - out.extend_from_slice(b"ET\n"); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); - res.set(b"Font".to_vec(), Object::Dict(fonts)); - let mut stream = form(full, &out, res); - if let Some(m) = matrix { - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - return Some(stream); - } - _ => return None, + Some(AppearanceContext { + dict: d, + subtype, + rect, + cloudy, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0), + stroke: color(d, b"C")?, + width: border_width(d), + }) +} + +fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + match context.subtype { + b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing), + b"Redact" => draw_redaction(context, drawing), + b"Square" | b"Circle" => draw_box(context, drawing), + b"Line" => draw_line(context, drawing), + b"Polygon" | b"PolyLine" => draw_polygon(context, drawing), + b"Caret" => draw_caret(context, drawing), + b"Ink" => draw_ink(context, drawing), + _ => None, } +} + +fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { // PdfCraft measurement captions are kept separate from the comment's free-form text. // A restyle regenerates the path and its value together. - let mut out = c.into_bytes(); - if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) { + let mut out = drawing.content.into_bytes(); + out.extend(drawing.tail); + if let Some(value) = context.dict.get(b"PCMeasureValue").and_then(Object::as_string) { let value = value.to_text(); - if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") { + if value.chars().count() <= 256 && matches!(context.subtype, b"Line" | b"PolyLine" | b"Polygon") { let size = 10.0; - let x = (rect[0] + rect[2] - text_width(&value, size)) * 0.5; - let y = rect[3] - 12.0; - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let x = (context.rect[0] + context.rect[2] - text_width(&value, size)) * 0.5; + let y = context.rect[3] - 12.0; + let col = context.stroke.unwrap_or([0.0, 0.47, 0.84]); out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes()); // WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8. out.extend(literal(&win_ansi(&value))); out.extend_from_slice(b" Tj ET\n"); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); - res.set(b"Font".to_vec(), Object::Dict(fonts)); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); } } - Some(form(rect, &out, res)) + Some(form(context.rect, &out, drawing.resources)) } -/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold -/// capitals, and the dynamic stamps' "By … at …" line. -fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { - let [x0, y0, x1, y1] = rect; - let h = y1 - y0; - let mut c = String::new(); - if opacity < 1.0 { - c.push_str("/GS0 gs\n"); - } - let lw = (h * 0.07).clamp(1.0, 3.0); - let inset = lw / 2.0 + 0.5; - if kind.group() == crate::StampGroup::SignHere { - // A tag pointing left, filled, with white text. - let tip = h * 0.45; - c.push_str(&format!( - "{}{}{} w 1 j\n{} {} m {} {} l {} {} l {} {} l {} {} l h B\n", - rg(col), - rg_stroke(col), - n(lw), - n(x0 + inset), - n(y0 + h / 2.0), - n(x0 + tip), - n(y1 - inset), - n(x1 - inset), - n(y1 - inset), - n(x1 - inset), - n(y0 + inset), - n(x0 + tip), - n(y0 + inset) - )); - } else { - // A rounded frame with a light tint. - let r = (h * 0.2).min(8.0); - let k = 0.552_284_75 * r; - let (a0, b0, a1, b1) = (x0 + inset, y0 + inset, x1 - inset, y1 - inset); - let tint = col.map(|v| 1.0 - (1.0 - v) * 0.12); - c.push_str(&format!( - "{}{}{} w\n{} {} m {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c h B\n", - rg(tint), - rg_stroke(col), - n(lw), - n(a0 + r), - n(b0), - n(a1 - r), - n(b0), - n(a1 - r + k), - n(b0), - n(a1), - n(b0 + r - k), - n(a1), - n(b0 + r), - n(a1), - n(b1 - r), - n(a1), - n(b1 - r + k), - n(a1 - r + k), - n(b1), - n(a1 - r), - n(b1), - n(a0 + r), - n(b1), - n(a0 + r - k), - n(b1), - n(a0), - n(b1 - r + k), - n(a0), - n(b1 - r), - n(a0), - n(b0 + r), - n(a0), - n(b0 + r - k), - n(a0 + r - k), - n(b0), - n(a0 + r), - n(b0) - )); - } - let label = kind.label(); - let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) }; - let text_area = if kind.group() == crate::StampGroup::SignHere { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; - let mut size = title_h; - let tw = text_width(label, size) * 1.12; - if tw > text_area.1 - text_area.0 - 4.0 { - size *= (text_area.1 - text_area.0 - 4.0) / tw; - } - let tw = text_width(label, size) * 1.12; - let text_col = if kind.group() == crate::StampGroup::SignHere { [1.0, 1.0, 1.0] } else { col }; - let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 }; - let mut out = c.into_bytes(); - out.extend( - format!("BT\n{}/HelvB {} Tf\n1 0 0 1 {} {} Tm ", rg(text_col), n(size), n(text_area.0 + (text_area.1 - text_area.0 - tw) / 2.0), n(ty)) - .bytes(), - ); - out.extend(literal(&win_ansi(label))); - out.extend_from_slice(b" Tj\n"); - if let Some(b) = by { - let mut s = by_h; - let bw = text_width(b, s); - if bw > text_area.1 - text_area.0 - 4.0 { - s *= (text_area.1 - text_area.0 - 4.0) / bw; +fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let c = &mut drawing.content; + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke?; + for quad in q.as_chunks::<8>().0.iter() { + let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); + let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); + let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); + // A point a fraction `t` of the way from the bottom edge to the top edge. + let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); + match subtype { + b"Highlight" => { + c.push_str(&rg(col)); + c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1))); + } + b"Underline" | b"StrikeOut" => { + let lw = (h * 0.07).clamp(0.5, 3.0); + let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; + let (a, b) = (at(p3, p1, t), at(p4, p2, t)); + c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); + } + _ => { + // Squiggly: a zigzag along the bottom of the quad. + let lw = (h * 0.05).clamp(0.5, 2.0); + let amp = (h * 0.06).max(0.75); + let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); + let step = (h / 4.0).max(1.5); + let steps = ((len / step).ceil() as usize).clamp(1, 10_000); + c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); + for i in 0..=steps { + let t = i as f64 / steps as f64; + let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); + let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; + let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; + let (x, y) = (base.0 + ux * up, base.1 + uy * up); + c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + } } - let bw = text_width(b, s); - out.extend( - format!("/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(s), n(text_area.0 + (text_area.1 - text_area.0 - bw) / 2.0), n(y0 + h * 0.18)).bytes(), + } + Some(()) +} + +fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let c = &mut drawing.content; + // While marked: the outline of each area (the fill comes when applied). + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + c.push_str(&format!("{}1 w\n", rg_stroke(col))); + for quad in q.as_chunks::<8>().0.iter() { + c.push_str(&format!( + "{} {} m {} {} l {} {} l {} {} l h S\n", + n(quad[0]), + n(quad[1]), + n(quad[2]), + n(quad[3]), + n(quad[6]), + n(quad[7]), + n(quad[4]), + n(quad[5]) + )); + } + Some(()) +} + +fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let rect = context.rect; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let fill = interior_color(d, None); + if stroke.is_none() && fill.is_none() { + return Some(()); + } + let inset = w / 2.0; + let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; + if let Some(f) = fill { + c.push_str(&rg(f)); + } + if let Some(s) = stroke { + c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); + } + if subtype == b"Square" { + c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); + } else { + c.push_str(&ellipse(x0, y0, x1, y1)); + } + c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + Some(()) +} + +fn draw_line(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let res = &mut drawing.resources; + let tail = &mut drawing.tail; + let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; + let col = stroke?; + let ends: Vec> = match d.get(b"LE") { + None => vec![b"None".to_vec(), b"None".to_vec()], + Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, + }; + if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { + return None; + } + let fill = interior_color(d, Some(col)).unwrap_or(col); + let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); + let [p, q] = layout.line; + c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); + for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { + c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); + } + c.push_str("[] 0 d\n"); + for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { + if !line_end(c, end, tip, from, w) { + return None; + } + } + if let Some((text, m)) = layout.caption { + // The caption is black, in the standard font the measurement label uses. + tail.extend( + format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])).bytes(), ); - out.extend(literal(&win_ansi(b))); + // WinAnsi bytes go into the stream as-is, not through UTF-8. + tail.extend(literal(&win_ansi(&text))); + tail.extend_from_slice(b" Tj ET Q\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let w = context.width; + let cloudy = context.cloudy; + let c = &mut drawing.content; + let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; + let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let col = stroke; + let closed = subtype == b"Polygon"; + let fill = if closed { interior_color(d, None) } else { None }; + let ends = if subtype == b"PolyLine" { + match d.get(b"LE") { + None => None, + Some(o) => { + let names: Vec> = + o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; + if names.iter().any(|e| !known_ending(e)) { + return None; + } + Some(names) + } + } + } else { + None + }; + if let Some(f) = fill { + c.push_str(&rg(f)); + } + if let Some(s) = col { + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); + } + if cloudy { + let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); + c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); + } else { + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + if closed { + c.push_str("h\n"); + } + } + c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + if let Some(ends) = ends { + let col = col?; + let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; + let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; + let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; + let fill = interior_color(d, Some(col)).unwrap_or(col); + c.push_str(&format!("[] 0 d\n{}", rg(fill))); + if !line_end(c, start, tip0, from0, w) || !line_end(c, end, tip1, from1, w) { + return None; + } + } + Some(()) +} + +fn draw_caret(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let c = &mut drawing.content; + // A filled caret: two curved flanks meeting at the top centre. + let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let (caret, pilcrow) = caret_layout(d, rect); + let [x0, y0, x1, y1] = caret; + let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); + c.push_str(&format!( + "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", + rg(col), + n(x0), + n(y0), + n(cx - (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(cx), + n(y1 - h * 0.25), + n(cx), + n(y1), + n(cx), + n(y1 - h * 0.25), + n(cx + (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(x1), + n(y0) + )); + if let Some((x, y, size)) = pilcrow { + // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. + // 0xB6 is the pilcrow in WinAnsiEncoding. + drawing.tail.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); + drawing.tail.push(0xB6); + drawing.tail.extend_from_slice(b") Tj ET\n"); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_ink(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let col = stroke?; + let list = d.get(b"InkList")?.as_array()?; + // `/BS` gives an ink stroke its width and dash pattern (Table 182). + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); + for s in list { + let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; + let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let Some(first) = pts.first() else { continue }; + c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); + match pts.as_slice() { + [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), + [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), + _ => c.push_str(&smooth_curve(&pts)), + } + c.push_str("S\n"); + } + Some(()) +} + +fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let opacity = context.opacity; + let c = &mut drawing.content; + let mut res = std::mem::take(&mut drawing.resources); + // A typed signature: filled outlines normalised to the rectangle. + if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); + for contour in outline { + let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); + } + if v.len() >= 6 { + c.push_str("h\n"); + } + } + c.push_str("f\n"); + return Some(form(rect, c.as_bytes(), res)); + } + // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills + // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the standard counter-rotation as its form `/Matrix`: viewers fit the turned + // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. + if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); + let place = if image { + format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) + } else { + let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; + format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) + }; + c.push_str(&format!("q {place} /Pic Do Q\n")); + let mut xo = Dict::new(); + xo.set(b"Pic".to_vec(), Object::Ref(pic)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut stream = form(rect, c.as_bytes(), res); + let turn = picture_turn(d); + if image && turn != 0 { + // The page's view matrix without its offset: display axes in user space. + let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); + let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; + stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); + } + return Some(stream); + } + // Only PdfCraft's own Fill & Sign marks are drawn here. + let name = d.name(b"Name")?; + let col = stroke.unwrap_or([0.0; 3]); + // A mark or stamp placed on a turned page is drawn upright in its displayed box, then + // mapped into `/Rect` the way the page maps display space into user space. Viewers + // fit that box to `/Rect`; the page rotation then shows it the right way up. + let turn = stamp_turn(d); + let draw = if turn == 0 { + rect + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + [0.0, 0.0, dw, dh] + }; + let turned = |mut stream: Stream| { + if turn != 0 { + let m = pdfcraft_model::view_matrix_for(turn, rect); + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + stream + }; + let [x0, y0, x1, y1] = draw; + let (w, h) = (x1 - x0, y1 - y0); + let lw = w.min(h) * 0.12; + match name { + b"PCCheck" => c.push_str(&format!( + "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.15), + n(y0 + h * 0.5), + n(x0 + w * 0.4), + n(y0 + h * 0.2), + n(x0 + w * 0.88), + n(y0 + h * 0.85) + )), + b"PCCross" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.18), + n(y0 + h * 0.18), + n(x1 - w * 0.18), + n(y1 - h * 0.18), + n(x0 + w * 0.18), + n(y1 - h * 0.18), + n(x1 - w * 0.18), + n(y0 + h * 0.18) + )), + b"PCDot" => { + let r = w.min(h) * 0.3; + c.push_str(&rg(col)); + c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); + c.push_str("f\n"); + } + b"PCLine" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l S\n", + rg_stroke(col), + n(h.clamp(0.5, 2.0)), + n(x0), + n(y0 + h / 2.0), + n(x1), + n(y0 + h / 2.0) + )), + other => { + // Only stamps PdfCraft made: others keep their own artwork. + if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { + return None; + } + let kind = crate::StampKind::from_name(other)?; + let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); + let sign_here = kind.group() == crate::StampGroup::SignHere; + return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); + } + } + if turn != 0 { + return Some(turned(form(draw, c.as_bytes(), res))); + } + Some(form(rect, c.as_bytes(), res)) +} + +fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let mut c = std::mem::take(&mut drawing.content); + let mut res = std::mem::take(&mut drawing.resources); + let _tail = std::mem::take(&mut drawing.tail); + let (text_color, size) = parse_da(d); + let bg = stroke; + let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; + // A callout: the leader line (arrowhead at its first point), and the text box inside + // `/Rect` by `/RD`. + // Text added to a turned page is drawn upright in its displayed box, which the form + // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. + let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; + let (rect, matrix) = if turn == 0 { + (rect, None) + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) + }; + let full = rect; + let mut rect = rect; + if d.contains(b"CL") { + let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; + let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; + rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; + if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let end = match d.get(b"LE") { + None => b"None".to_vec(), + Some(o) => o.as_name()?.to_vec(), + }; + if !known_ending(&end) { + return None; + } + let lw = bw.max(0.5); + let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return None; + } + c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + if !line_end(&mut c, &end, pts[0], pts[1], lw) { + return None; + } + } + if let Some(bg) = bg { + c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); + } + if bw > 0.0 { + let h = bw / 2.0; + c.push_str(&format!( + "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", + rg_stroke(text_color), + n(bw), + dash(d), + n(rect[0] + h), + n(rect[1] + h), + n(rect[2] - rect[0] - bw), + n(rect[3] - rect[1] - bw) + )); + } + let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); + let pad = 2.0 + bw; + let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); + let q = d.int(b"Q").unwrap_or(0); + c.push_str(&format!( + "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", + n(rect[0]), + n(rect[1]), + n(rect[2] - rect[0]), + n(rect[3] - rect[1]), + n(size), + rg(text_color) + )); + let mut out = c.into_bytes(); + let mut y = rect[3] - pad - size * 0.9; + for line in wrap(&text, size, width) { + if y < rect[1] - size { + break; + } + let lw = text_width(&line, size); + let x = match q { + 1 => rect[0] + pad + (width - lw) / 2.0, + 2 => rect[2] - pad - lw, + _ => rect[0] + pad, + }; + out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + out.extend(literal(&win_ansi(&line))); out.extend_from_slice(b" Tj\n"); + y -= size * 1.2; } out.extend_from_slice(b"ET\n"); - let font = |base: &str| { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - Object::Dict(f) - }; - let mut fonts = Dict::new(); - fonts.set(b"HelvB".to_vec(), font("Helvetica-Bold")); - fonts.set(b"Helv".to_vec(), font("Helvetica")); - res.set(b"Font".to_vec(), Object::Dict(fonts)); - form(rect, &out, res) + res.set(b"Font".to_vec(), helvetica_resource()); + let mut stream = form(full, &out, res); + if let Some(m) = matrix { + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + Some(stream) } -/// An ellipse inscribed in a rectangle, as four Bézier arcs. -fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String { - let k = 0.552_284_75; - let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0); - let (ox, oy) = (rx * k, ry * k); - let mut s = format!("{} {} m\n", n(cx + rx), n(cy)); - for [a, b, c, d, e, f] in [ - [cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry], - [cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy], - [cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry], - [cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy], - ] { - s.push_str(&format!("{} {} {} {} {} {} c\n", n(a), n(b), n(c), n(d), n(e), n(f))); - } - s.push_str("h\n"); - s -} - -/// PdfCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page -/// with a folded corner for everything else, both filled with the note colour. -/// File attachment icons in a 20 × 20 box: PdfCraft's own drawings. -fn attach_icon(name: &str, col: Rgb) -> String { - let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col)); - match name { - "Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"), - "Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"), - "Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"), - _ => { - // Push pin: a round head on a short needle. - s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n"); - } - } - s -} - -fn note_icon(name: &str, col: Rgb) -> String { - let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col)); - if name == "Comment" { - s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n"); - s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n"); - } else { - s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n"); - s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n"); - } - s +fn draw_icon(context: &AppearanceContext<'_>, name: &str, fallback: Rgb, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let col = context.stroke.unwrap_or(fallback); + let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| name.into()); + let content = if name == "Note" { note_icon(&icon, col) } else { attach_icon(&icon, col) }; + drawing.content.push_str(&content); + Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], drawing.content.as_bytes(), drawing.resources)) } diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs new file mode 100644 index 0000000000..3b33662524 --- /dev/null +++ b/crates/annot/src/appearance/symbols.rs @@ -0,0 +1,534 @@ +use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi}; + +/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp +/// another program wrote without an appearance. Names are matched byte for byte, as PDF names +/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of +/// their own and the palette's colour for their meaning. +fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> { + use crate::StampKind::*; + let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease]; + if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) { + return Some((kind.label(), kind.color())); + } + let (label, like) = match name { + b"Experimental" => ("EXPERIMENTAL", Draft), + b"AsIs" => ("AS IS", Draft), + b"Expired" => ("EXPIRED", NotApproved), + b"Sold" => ("SOLD", Approved), + b"Departmental" => ("DEPARTMENTAL", Draft), + b"TopSecret" => ("TOP SECRET", Confidential), + _ => return None, + }; + Some((label, like.color())) +} + +/// The neutral grey of a stamp whose name has no drawing. +const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45]; + +/// The display appearance of a rubber stamp another program wrote without one: the stamp its +/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the +/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays +/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp +/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text. +/// +/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so +/// edits (restyling, flattening) never write this drawing into the file. `None` for any other +/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is +/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal +/// appearance), or a `/Rect` that isn't finite or is under a point wide or high. +pub(crate) fn display_stamp(d: &Dict) -> Option { + let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline"); + if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") { + return None; + } + let rect = nums(d, b"Rect")?; + let [a, b, c, e] = rect.as_slice() else { return None }; + let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)]; + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); + // A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp. + let stroke = color(d, b"C").flatten(); + let mut res = Dict::new(); + if let Some(gs) = ext_gstate(opacity, false) { + res.set(b"ExtGState".to_vec(), Object::Dict(gs)); + } + // A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default. + let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp"); + let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice())); + if let Some((label, col)) = name.and_then(standard_stamp) { + return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res)); + } + let [x0, y0, x1, y1] = rect; + let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5); + let mut c = String::new(); + if opacity < 1.0 { + c.push_str("/GS0 gs\n"); + } + c.push_str(&format!( + "{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n", + // Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour. + rg_stroke(PLACEHOLDER_GREY), + n(a0), + n(b0), + n(a1 - a0), + n(b1 - b0), + n(a0), + n(b0), + n(a1), + n(b1), + n(a0), + n(b1), + n(a1), + n(b0) + )); + Some(form(rect, c.as_bytes(), res)) +} + +/// Size of a line's caption, as of a measurement's label. +pub(super) const CAPTION_SIZE: f64 = 10.0; + +/// Characters of a line's caption drawn at most, as for a measurement's label. +const MAX_CAPTION_CHARS: usize = 256; + +/// Leader lengths and caption offsets (Table 178) are held within this many points (200 inches, +/// the largest page side ISO 32000-2 Annex C allows), so an absurd value can't overflow. +const MAX_LINE_EXTENT: f64 = 14_400.0; + +/// A Table 178 length or offset: finite and within [`MAX_LINE_EXTENT`], 0 when absent or not one. +fn line_length(v: Option) -> f64 { + v.filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(-MAX_LINE_EXTENT, MAX_LINE_EXTENT)) +} + +/// The caption a Line with `/Cap true` shows (ISO 32000-2 Table 178): its `/Contents`, else the +/// plain text of its `/RC`, on one line and cut to [`MAX_CAPTION_CHARS`]. `None` without a +/// caption, and for PdfCraft's own measurements, whose label (`/PCMeasureValue`) is the caption. +pub(super) fn line_caption(d: &Dict) -> Option { + if !matches!(d.get(b"Cap"), Some(Object::Bool(true))) || d.contains(b"PCMeasureValue") { + return None; + } + let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); + let raw = text(b"Contents").filter(|t| !t.trim().is_empty()).or_else(|| text(b"RC").map(|rc| rich_plain(&rc)))?; + let words: Vec<&str> = raw.split_whitespace().collect(); + let caption: String = words.join(" ").chars().filter(|c| !c.is_control()).take(MAX_CAPTION_CHARS).collect(); + (!caption.is_empty()).then_some(caption) +} + +/// The text of rich text (§12.7.4.3, XHTML): its markup dropped, the five XML entities and +/// numeric character references read, block ends read as spaces. +fn rich_plain(rc: &str) -> String { + let mut out = String::new(); + let mut tag: Option = None; + // Rich text past this is not caption material; it bounds the work. + for ch in rc.chars().take(MAX_CAPTION_CHARS * 64) { + match (&mut tag, ch) { + (None, '<') => tag = Some(String::new()), + (Some(name), '>') => { + let name = + name.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default().to_ascii_lowercase(); + if matches!(name.as_str(), "p" | "br" | "div" | "li") { + out.push(' '); + } + tag = None; + } + (Some(name), c) => { + if name.len() < 16 { + name.push(c); + } + } + (None, c) => out.push(c), + } + } + let mut text = String::new(); + let mut rest = out.as_str(); + while let Some(at) = rest.find('&') { + text.push_str(rest.get(..at).unwrap_or_default()); + let after = rest.get(at + 1..).unwrap_or_default(); + let entity = after.find(';').filter(|&end| end <= 10).and_then(|end| Some((after.get(..end)?, end))); + let decoded = entity.and_then(|(name, end)| { + let c = match name { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + _ => { + let num = name.strip_prefix('#')?; + let code = match num.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => num.parse().ok()?, + }; + char::from_u32(code)? + } + }; + Some((c, end)) + }); + match decoded { + Some((c, end)) => { + text.push(c); + rest = after.get(end + 1..).unwrap_or_default(); + } + None => { + text.push('&'); + rest = after; + } + } + } + text.push_str(rest); + text +} + +/// Where a Line annotation's parts go (ISO 32000-2 §12.5.6.7, Table 178). +#[derive(Debug, Default)] +pub(super) struct LineLayout { + /// The line itself, start to end: `/L`, or `/L` moved out by its leader lines. + pub(super) line: [(f64, f64); 2], + /// Strokes along the line: the whole line, or the two pieces either side of an inline caption. + pub(super) strokes: Vec<[(f64, f64); 2]>, + /// The leader lines, from `/L` (past the `/LLO` gap) to the line and on by `/LLE`. + pub(super) leaders: Vec<[(f64, f64); 2]>, + /// A short stroke from the line to a caption `/CO` moved off it. + pub(super) tick: Option<[(f64, f64); 2]>, + /// The caption and its text matrix. + pub(super) caption: Option<(String, [f64; 6])>, +} + +/// Lay out a Line annotation from `/L` = `[a b]`, line width `w`, and its leader and caption +/// entries. +/// +/// Leader lines: a positive `/LL` puts the line on the left of travel from `a` to `b` (above `/L` +/// for a line drawn left to right), so the leader lines, followed from the line to `/L`, turn +/// clockwise from the direction of travel; a negative one puts it on the right. The line sits +/// `|LL| + LLO` from `/L`. `/LLE` and `/LLO` are non-negative: a negative one counts as 0. +/// +/// Caption (`/Cap true`): centred on the line's midpoint and turned with it so it reads left to +/// right (bottom to top for a vertical line). `/CP /Inline` (the default) centres it on the line, +/// which is broken around it; `/Top` sets it on top of the unbroken line. `/CO [h v]` moves it `h` +/// along the line and `v` across it (positive: up in reading direction); a caption moved off an +/// inline line gets a tick from the line to it. +/// +/// A zero-length or overflowing `/L` has no direction: it gets neither leader lines nor caption. +/// Every point is finite; if anything derived from the entries is not, they are left out. +pub(super) fn line_layout(d: &Dict, [a, b]: [(f64, f64); 2], w: f64) -> LineLayout { + let plain = LineLayout { line: [a, b], strokes: vec![[a, b]], ..LineLayout::default() }; + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = dx.hypot(dy); + if !len.is_finite() || len <= 0.0 { + return plain; + } + let (ux, uy) = (dx / len, dy / len); + let mut out = LineLayout { line: [a, b], ..LineLayout::default() }; + let ll = line_length(d.get(b"LL").and_then(Object::as_f64)); + if ll != 0.0 { + // The unit normal from `/L` towards the line: left of travel for a positive /LL. + let s = ll.signum(); + let (nx, ny) = (-uy * s, ux * s); + let llo = line_length(d.get(b"LLO").and_then(Object::as_f64)).max(0.0); + let lle = line_length(d.get(b"LLE").and_then(Object::as_f64)).max(0.0); + let at = |p: (f64, f64), k: f64| (p.0 + nx * k, p.1 + ny * k); + let off = ll.abs() + llo; + out.line = [at(a, off), at(b, off)]; + out.leaders = vec![[at(a, llo), at(a, off + lle)], [at(b, llo), at(b, off + lle)]]; + } + let [p, _] = out.line; + let along = |t: f64| (p.0 + ux * t, p.1 + uy * t); + let mut gap = None; + if let Some(text) = line_caption(d) { + let size = CAPTION_SIZE; + let width = text_width(&text, size); + // Reading direction, and "up" across it. + let (tx, ty) = if ux < 0.0 || (ux == 0.0 && uy < 0.0) { (-ux, -uy) } else { (ux, uy) }; + let (vx, vy) = (-ty, tx); + let co = nums(d, b"CO").filter(|c| c.len() == 2).unwrap_or_default(); + let h = line_length(co.first().copied()); + let v = line_length(co.get(1).copied()); + let top = d.name(b"CP") == Some(b"Top"); + // The caption's centre, as a distance along the line from `p`. + let centre = len / 2.0 + h * (tx * ux + ty * uy); + let mid = along(centre); + // Baseline across the line: on top of it, or with the text centred on it. + let base = if top { w.max(0.0) / 2.0 + 2.0 + v } else { v - size * 0.35 }; + let origin = (mid.0 - tx * width / 2.0 + vx * base, mid.1 - ty * width / 2.0 + vy * base); + out.caption = Some((text, [tx, ty, -ty, tx, origin.0, origin.1])); + if !top && v.abs() < size / 2.0 { + let half = width / 2.0 + 2.0; + gap = Some(((centre - half).clamp(0.0, len), (centre + half).clamp(0.0, len))); + } else if !top { + let reach = v - v.signum() * size * 0.4; + out.tick = Some([mid, (mid.0 + vx * reach, mid.1 + vy * reach)]); + } + } + out.strokes = match gap { + Some((g0, g1)) => [(0.0, g0), (g1, len)].into_iter().filter(|(s, e)| e > s).map(|(s, e)| [along(s), along(e)]).collect(), + None => vec![out.line], + }; + let finite = |q: &(f64, f64)| q.0.is_finite() && q.1.is_finite(); + let all_finite = + out.line.iter().chain(out.strokes.iter().flatten()).chain(out.leaders.iter().flatten()).chain(out.tick.iter().flatten()).all(finite) + && out.caption.as_ref().is_none_or(|(_, m)| m.iter().all(|x| x.is_finite())); + if all_finite { out } else { plain } +} + +/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold +/// capitals, and the dynamic stamps' "By … at …" line. +pub(super) fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { + let [x0, y0, x1, y1] = rect; + let h = y1 - y0; + let mut c = String::new(); + if opacity < 1.0 { + c.push_str("/GS0 gs\n"); + } + let lw = (h * 0.07).clamp(1.0, 3.0); + let inset = lw / 2.0 + 0.5; + if sign_here { + // A tag pointing left, filled, with white text. + let tip = h * 0.45; + c.push_str(&format!( + "{}{}{} w 1 j\n{} {} m {} {} l {} {} l {} {} l {} {} l h B\n", + rg(col), + rg_stroke(col), + n(lw), + n(x0 + inset), + n(y0 + h / 2.0), + n(x0 + tip), + n(y1 - inset), + n(x1 - inset), + n(y1 - inset), + n(x1 - inset), + n(y0 + inset), + n(x0 + tip), + n(y0 + inset) + )); + } else { + // A rounded frame with a light tint. + let r = (h * 0.2).min(8.0); + let k = 0.552_284_75 * r; + let (a0, b0, a1, b1) = (x0 + inset, y0 + inset, x1 - inset, y1 - inset); + let tint = col.map(|v| 1.0 - (1.0 - v) * 0.12); + c.push_str(&format!( + "{}{}{} w\n{} {} m {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c h B\n", + rg(tint), + rg_stroke(col), + n(lw), + n(a0 + r), + n(b0), + n(a1 - r), + n(b0), + n(a1 - r + k), + n(b0), + n(a1), + n(b0 + r - k), + n(a1), + n(b0 + r), + n(a1), + n(b1 - r), + n(a1), + n(b1 - r + k), + n(a1 - r + k), + n(b1), + n(a1 - r), + n(b1), + n(a0 + r), + n(b1), + n(a0 + r - k), + n(b1), + n(a0), + n(b1 - r + k), + n(a0), + n(b1 - r), + n(a0), + n(b0 + r), + n(a0), + n(b0 + r - k), + n(a0 + r - k), + n(b0), + n(a0 + r), + n(b0) + )); + } + let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) }; + let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; + let mut size = title_h; + let tw = text_width(label, size) * 1.12; + if tw > text_area.1 - text_area.0 - 4.0 { + size *= (text_area.1 - text_area.0 - 4.0) / tw; + } + let tw = text_width(label, size) * 1.12; + let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col }; + let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 }; + let mut out = c.into_bytes(); + out.extend( + format!("BT\n{}/HelvB {} Tf\n1 0 0 1 {} {} Tm ", rg(text_col), n(size), n(text_area.0 + (text_area.1 - text_area.0 - tw) / 2.0), n(ty)) + .bytes(), + ); + out.extend(literal(&win_ansi(label))); + out.extend_from_slice(b" Tj\n"); + if let Some(b) = by { + let mut s = by_h; + let bw = text_width(b, s); + if bw > text_area.1 - text_area.0 - 4.0 { + s *= (text_area.1 - text_area.0 - 4.0) / bw; + } + let bw = text_width(b, s); + out.extend( + format!("/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(s), n(text_area.0 + (text_area.1 - text_area.0 - bw) / 2.0), n(y0 + h * 0.18)).bytes(), + ); + out.extend(literal(&win_ansi(b))); + out.extend_from_slice(b" Tj\n"); + } + out.extend_from_slice(b"ET\n"); + let font = |base: &str| { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + Object::Dict(f) + }; + let mut fonts = Dict::new(); + fonts.set(b"HelvB".to_vec(), font("Helvetica-Bold")); + fonts.set(b"Helv".to_vec(), font("Helvetica")); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + form(rect, &out, res) +} + +/// An ellipse inscribed in a rectangle, as four Bézier arcs. +pub(super) fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String { + let k = 0.552_284_75; + let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0); + let (ox, oy) = (rx * k, ry * k); + let mut s = format!("{} {} m\n", n(cx + rx), n(cy)); + for [a, b, c, d, e, f] in [ + [cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry], + [cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy], + [cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry], + [cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy], + ] { + s.push_str(&format!("{} {} {} {} {} {} c\n", n(a), n(b), n(c), n(d), n(e), n(f))); + } + s.push_str("h\n"); + s +} + +/// PdfCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page +/// with a folded corner for everything else, both filled with the note colour. +/// File attachment icons in a 20 × 20 box: PdfCraft's own drawings. +pub(super) fn attach_icon(name: &str, col: Rgb) -> String { + let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col)); + match name { + "Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"), + "Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"), + "Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"), + _ => { + // Push pin: a round head on a short needle. + s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n"); + } + } + s +} + +/// A closed circle path (four Bézier quarters) centred on (`x`, `y`). +fn circle_path(x: f64, y: f64, r: f64) -> String { + let k = r * 0.5523; + let p = |v: f64| n(v); + format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h\n", + p(x + r), + p(y), + p(x + r), + p(y + k), + p(x + k), + p(y + r), + p(x), + p(y + r), + p(x - k), + p(y + r), + p(x - r), + p(y + k), + p(x - r), + p(y), + p(x - r), + p(y - k), + p(x - k), + p(y - r), + p(x), + p(y - r), + p(x + k), + p(y - r), + p(x + r), + p(y - k), + p(x + r), + p(y) + ) +} + +/// A note's icon in a 20 × 20 box: filled in `col`, outlined in dark grey. Each name of +/// [`crate::NoteIcon`] has its own shape, so the icon a note names is the one it shows (ISO +/// 32000-2 §12.5.6.4, Table 175); a name PdfCraft doesn't know draws as `Note`. +pub(super) fn note_icon(name: &str, col: Rgb) -> String { + let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col)); + match name { + "Comment" => { + s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n"); + s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n"); + } + // A key lying flat: a round bow with a hole, a shaft and two bits. + "Key" => { + s.push_str(&circle_path(5.5, 10.0, 4.5)); + s.push_str(&circle_path(5.0, 10.0, 1.5)); + s.push_str( + "9.5 11.2 m 18.5 11.2 l 18.5 8.8 l 17 8.8 l 17 6 l 15 6 l 15 8.8 l 13.5 8.8 l 13.5 6.8 l 11.5 6.8 l 11.5 8.8 l 9.5 8.8 l h B*\n", + ); + } + // A disc with a question mark. + "Help" => { + s.push_str(&circle_path(10.0, 10.0, 8.5)); + s.push_str("B\n1.6 w 7 12.8 m 7 15.6 13 15.6 13 12.8 c 13 10.6 10 10.8 10 8.2 c S\n10 5 m 10 5.1 l S\n"); + } + // An insertion caret. + "Insert" => s.push_str("1.5 3 m 10 18 l 18.5 3 l 14.5 3 l 10 11 l 5.5 3 l h B\n"), + // A caret over a bar: a new paragraph goes here. + "NewParagraph" => s.push_str("3.5 9 m 10 18.5 l 16.5 9 l h B\n3 2 14 4 re B\n"), + // A pilcrow: a filled bowl and two stems. + "Paragraph" => { + s.push_str("11 18.5 m 7 18.5 4 16.3 4 13.2 c 4 10.1 7 8 11 8 c h B\n"); + s.push_str("11 18.5 m 16 18.5 l 16 1.5 l 14 1.5 l 14 16.5 l 13 16.5 l 13 1.5 l 11 1.5 l h B\n"); + } + // A tick. + "Check" => s.push_str("1.5 10.5 m 4.5 13.5 l 8 9.5 l 16 18.5 l 18.5 15.5 l 8 3.5 l h B\n"), + // A ring. + "Circle" => { + s.push_str(&circle_path(10.0, 10.0, 8.5)); + s.push_str(&circle_path(10.0, 10.0, 5.0)); + s.push_str("B*\n"); + } + // A thick X. + "Cross" => s.push_str("4 1.5 m 10 7.5 l 16 1.5 l 18.5 4 l 12.5 10 l 18.5 16 l 16 18.5 l 10 12.5 l 4 18.5 l 1.5 16 l 7.5 10 l 1.5 4 l h B\n"), + // A ring with sighting lines through it. + "CrossHairs" => { + s.push_str(&circle_path(10.0, 10.0, 7.0)); + s.push_str(&circle_path(10.0, 10.0, 4.5)); + s.push_str("B*\n1.2 w 10 0.5 m 10 19.5 l 0.5 10 m 19.5 10 l S\n"); + } + // A five-pointed star. + "Star" => { + let points: Vec = (0..10) + .map(|i| { + let r = if i % 2 == 0 { 9.5 } else { 3.9 }; + let a = std::f64::consts::FRAC_PI_2 + f64::from(i) * std::f64::consts::PI / 5.0; + format!("{} {}", n(10.0 + r * a.cos()), n(9.5 + r * a.sin())) + }) + .collect(); + s.push_str(&format!("{} m {} l h B\n", points[0], points[1..].join(" l "))); + } + // Block arrows. + "RightArrow" => s.push_str("1.5 7 m 11 7 l 11 2.5 l 18.5 10 l 11 17.5 l 11 13 l 1.5 13 l h B\n"), + "UpArrow" => s.push_str("7 1.5 m 7 11 l 2.5 11 l 10 18.5 l 17.5 11 l 13 11 l 13 1.5 l h B\n"), + _ => { + s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n"); + s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n"); + } + } + s +} diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs new file mode 100644 index 0000000000..a6db5b940b --- /dev/null +++ b/crates/annot/src/create.rs @@ -0,0 +1,485 @@ +use super::draw::draw_appearance; +use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; +use super::modify::{n, rich_text}; +use super::types::{ + FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, +}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; + +pub(crate) fn num_array(v: &[f64]) -> Object { + Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) +} + +pub(crate) fn rgb(c: Rgb) -> Object { + num_array(&c.map(|x| x.clamp(0.0, 1.0))) +} + +pub(crate) fn finite(v: &[f64]) -> bool { + v.iter().all(|x| x.is_finite() && x.abs() < 1e7) +} + +pub(crate) fn normalize(r: [f64; 4]) -> [f64; 4] { + [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] +} + +pub(crate) fn bounds(points: impl Iterator) -> Option<[f64; 4]> { + points.fold(None, |acc, [x, y]| match acc { + None => Some([x, y, x, y]), + Some([a, b, c, d]) => Some([a.min(x), b.min(y), c.max(x), d.max(y)]), + }) +} + +pub(crate) fn grow(r: [f64; 4], by: f64) -> [f64; 4] { + [r[0] - by, r[1] - by, r[2] + by, r[3] + by] +} + +pub(crate) fn ending_draws(ending: LineEnding) -> bool { + ending != LineEnding::None +} + +pub(crate) fn ending_pair(start: LineEnding, end: LineEnding) -> Object { + Object::Array(vec![Object::name(start.name()), Object::name(end.name())]) +} + +/// Validate and compute `/Rect` for a new comment. +pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { + let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}")); + let half = style.width.max(0.0) / 2.0; + let r = match shape { + Shape::Note { at, .. } | Shape::Attachment { at, .. } => { + if !finite(at) { + return Err(bad("position")); + } + [at[0], at[1] - NOTE_SIZE, at[0] + NOTE_SIZE, at[1]] + } + Shape::TextMarkup { quads, .. } | Shape::Redact { quads, .. } => { + if quads.is_empty() || !quads.iter().all(|q| finite(q)) { + return Err(bad("text area (no quadrilaterals)")); + } + bounds(quads.iter().flat_map(|q| q.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect::>())) + .ok_or_else(|| bad("text area"))? + } + Shape::Rectangle { rect } + | Shape::Oval { rect } + | Shape::TextBox { rect, .. } + | Shape::Typewriter { rect, .. } + | Shape::Stamp { rect, .. } + | Shape::TypedSignature { rect, .. } + | Shape::Mark { rect, .. } => { + let r = normalize(*rect); + if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { + return Err(bad("rectangle (too small)")); + } + r + } + Shape::CustomStamp { rect, .. } => { + let r = normalize(*rect); + // Image signatures may be very thin; a positive PDF appearance box still works. + if !finite(rect) || r[2] <= r[0] || r[3] <= r[1] { + return Err(bad("rectangle (empty)")); + } + r + } + Shape::Line { from, to, start, end } => { + if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 { + return Err(bad("line (too short)")); + } + let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; + grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) + } + Shape::Ink { strokes } | Shape::Signature { strokes } => { + if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) { + return Err(bad("drawing (no points)")); + } + // Strokes of three or more points are drawn as curves, which stay within their + // points and control points. + let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { + let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); + appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) + }); + grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0) + } + Shape::Polygon { vertices, cloud } => { + let b = bounds(vertices.iter().copied()) + .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); + let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?; + grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 }) + } + Shape::PolyLine { vertices, start, end } => { + let b = bounds(vertices.iter().copied()) + .filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0); + let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; + grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra) + } + Shape::Callout { rect, knee, point, .. } => { + let r = normalize(*rect); + if !finite(rect) || !finite(knee) || !finite(point) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { + return Err(bad("callout")); + } + let pad = half + appearance::arrow_size(style.width) + 1.0; + let lead = grow(bounds([*knee, *point].into_iter()).unwrap_or_default(), pad); + [r[0].min(lead[0]), r[1].min(lead[1]), r[2].max(lead[2]), r[3].max(lead[3])] + } + Shape::Caret { rect } => { + let r = normalize(*rect); + if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { + return Err(bad("caret")); + } + r + } + }; + Ok(r) +} + +pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name(subtype)); + d.set(b"Rect".to_vec(), num_array(&rect)); + d.set(b"Contents".to_vec(), PdfString::text(contents)); + if !author.is_empty() { + d.set(b"T".to_vec(), PdfString::text(author)); + } + if let Some(date) = &meta.date { + d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + d.set(b"CreationDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } + if !meta.id.is_empty() { + d.set(b"NM".to_vec(), PdfString::text(&meta.id)); + } + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT)); + d +} + +/// Acrobat's `/Subj` for each tool (shown as the comment type in other viewers). +fn subject(shape: &Shape) -> &'static str { + match shape { + Shape::Note { .. } => "Sticky Note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly", + Shape::Rectangle { .. } => "Rectangle", + Shape::Oval { .. } => "Oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", + Shape::Line { .. } => "Line", + Shape::Ink { .. } => "Pencil", + Shape::TextBox { .. } => "Text Box", + Shape::Typewriter { .. } => "Typewriter", + Shape::Mark { mark, .. } => mark.label(), + Shape::Signature { .. } => "Signature", + Shape::Redact { .. } => "Redact", + Shape::Stamp { stamp, .. } => match stamp.group() { + StampGroup::Dynamic => "Dynamic stamp", + StampGroup::SignHere => "Sign Here", + StampGroup::StandardBusiness => "Stamp", + }, + Shape::CustomStamp { .. } => "Stamp", + Shape::TypedSignature { .. } => "Signature", + Shape::Polygon { cloud: true, .. } => "Cloud", + Shape::Polygon { .. } => "Polygon", + Shape::PolyLine { .. } => "Polygonal Line", + Shape::Callout { .. } => "Callout", + Shape::Caret { .. } => "Inserted Text", + Shape::Attachment { .. } => "File Attachment", + } +} + +/// Where a callout's leader line meets its text box: the middle of the side facing `knee`. +pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { + let [x0, y0, x1, y1] = normalize(rect); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + if knee[0] < x0 { + [x0, cy] + } else if knee[0] > x1 { + [x1, cy] + } else if knee[1] > y1 { + [cx, y1] + } else { + [cx, y0] + } +} + +/// Add a comment; returns its index in the page's `/Annots`. +pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result { + let page = page_ref(doc, new.page)?; + let style = &new.style; + validate_style(style)?; + let rect = rect_for(&new.shape, style)?; + let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); + d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); + let opacity = style.opacity.clamp(0.0, 1.0); + set_non_opaque_style(&mut d, opacity); + let border = |d: &mut Dict| { + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); + bs.set(b"S".to_vec(), Object::name("S")); + d.set(b"BS".to_vec(), Object::Dict(bs)); + }; + let mut popup = None; + match &new.shape { + Shape::Note { icon, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(icon.name())); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"Open".to_vec(), Object::Bool(false)); + popup = Some([rect[2] + 10.0, rect[3] - 120.0, rect[2] + 210.0, rect[3]]); + } + Shape::TextMarkup { quads, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + } + Shape::Redact { quads, overlay, look } => set_redaction_properties(&mut d, style, quads, overlay, look), + Shape::Rectangle { .. } | Shape::Oval { .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + if let Some(f) = style.fill { + d.set(b"IC".to_vec(), rgb(f)); + } + border(&mut d); + } + Shape::Line { from, to, start, end } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]])); + if ending_draws(*start) || ending_draws(*end) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } + border(&mut d); + } + Shape::Mark { mark, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(mark.name())); + border(&mut d); + // Drawn upright for the page as displayed, as a stamp is (#2392). + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); + } + } + Shape::Stamp { stamp, by, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(stamp.name())); + // Marks the stamp as drawn by PdfCraft: other stamps with standard names keep + // their own artwork. + d.set(b"PCStamp".to_vec(), Object::Bool(true)); + if let Some(b) = by { + d.set(b"PCByLine".to_vec(), PdfString::text(b)); + } + // The label is drawn for the page as displayed. A later page rotation is a new edit. + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); + } + } + Shape::TypedSignature { contours, .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name("PCTypedSignature")); + let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::>())).collect(); + d.set(b"PCOutline".to_vec(), Object::Array(list)); + } + Shape::CustomStamp { name, picture, image, size, .. } => { + let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect(); + d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}"))); + d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); + d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); + d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); + // An image signature's rectangle is in user space; on a turned page its appearance is + // turned back (see `appearance::build`), so it reads upright as displayed. + if *image && matches!(clean.as_str(), "Signature" | "Initials") { + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if turn != 0 { + d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); + } + } + } + Shape::Ink { strokes } | Shape::Signature { strokes } => { + d.set(b"C".to_vec(), rgb(style.color)); + let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); + d.set(b"InkList".to_vec(), Object::Array(list)); + border(&mut d); + } + Shape::Polygon { vertices, cloud } => { + d.set(b"C".to_vec(), rgb(style.color)); + if let Some(f) = style.fill { + d.set(b"IC".to_vec(), rgb(f)); + } + d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); + if *cloud { + d.set(b"IT".to_vec(), Object::name("PolygonCloud")); + let mut be = Dict::new(); + be.set(b"S".to_vec(), Object::name("C")); + be.set(b"I".to_vec(), Object::Int(1)); + d.set(b"BE".to_vec(), Object::Dict(be)); + } + border(&mut d); + } + Shape::PolyLine { vertices, start, end } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); + if ending_draws(*start) || ending_draws(*end) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } + border(&mut d); + } + Shape::Caret { .. } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Sy".to_vec(), Object::name("None")); + } + Shape::Attachment { icon, file, data, .. } => { + if file.trim().is_empty() { + return Err(AnnotError::Invalid("the attached file needs a name".into())); + } + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"Name".to_vec(), Object::name(icon.name())); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + // The file: an embedded file stream inside a file specification (§7.11.4). + let mut params = Dict::new(); + params.set(b"Size".to_vec(), Object::Int(data.len() as i64)); + if let Some(date) = &meta.date { + params.set(b"ModDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } + let mut ef = Dict::new(); + ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); + ef.set(b"Params".to_vec(), Object::Dict(params)); + let ef = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(ef, data))); + let mut efd = Dict::new(); + efd.set(b"F".to_vec(), Object::Ref(ef)); + efd.set(b"UF".to_vec(), Object::Ref(ef)); + let mut fs = Dict::new(); + fs.set(b"Type".to_vec(), Object::name("Filespec")); + fs.set(b"F".to_vec(), PdfString::text(file.trim())); + fs.set(b"UF".to_vec(), PdfString::text(file.trim())); + fs.set(b"EF".to_vec(), Object::Dict(efd)); + d.set(b"FS".to_vec(), Object::Ref(doc.add(Object::Dict(fs)))); + if new.contents.is_empty() { + d.set(b"Contents".to_vec(), PdfString::text(file.trim())); + } + } + Shape::Callout { rect: tb, knee, point, ending, .. } => { + let tb = normalize(*tb); + let attach = callout_attach(tb, *knee); + d.set(b"IT".to_vec(), Object::name("FreeTextCallout")); + d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]])); + d.set(b"LE".to_vec(), Object::name(ending.name())); + // The text box inside /Rect (§12.5.6.6 /RD). + d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]])); + } + _ => {} + } + match &new.shape { + Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } | Shape::Callout { font_size, .. } => { + if matches!(new.shape, Shape::Typewriter { .. }) { + d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter")); + } + let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 }; + let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + d.set(b"Q".to_vec(), Object::Int(0)); + if let Some(f) = style.fill { + d.set(b"C".to_vec(), rgb(f)); + } + border(&mut d); + rich_text(&mut d); + // The text is laid out for the page as displayed, like a stamp's label. + if !matches!(new.shape, Shape::Callout { .. }) { + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if matches!(turn, 90 | 180 | 270) { + d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); + } + } + } + _ => {} + } + if fill_sign_shape(&new.shape) { + // Survives a later subject edit, so flatten-on-save still finds the mark. + d.set(b"PCFillSign".to_vec(), Object::Bool(true)); + } + // A redaction's overlay text becomes page content when it is applied, where `?` would be there + // for good: refused before anything is added. A comment's own text is stored as typed and + // drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`). + if d.name(b"Subtype") == Some(b"Redact") + && let Some((what, c)) = appearance::undrawable_text(&d) + { + return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c))); + } + let r = doc.add(Object::Dict(d.clone())); + draw_appearance(doc, r)?; + let mut list = annots(doc, page); + let index = list.len(); + list.push(Object::Ref(r)); + add_popup(doc, r, popup, &mut list)?; + set_annots(doc, page, list)?; + Ok(index) +} + +fn validate_style(style: &Style) -> Result<(), AnnotError> { + if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { + return Err(AnnotError::Invalid("invalid style".into())); + } + Ok(()) +} + +fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) { + dict.set(b"C".to_vec(), rgb(style.color)); + dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); + dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + if !overlay.is_empty() { + dict.set(b"OverlayText".to_vec(), PdfString::text(overlay)); + let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); + let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; + dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes())); + dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); + if look.repeat { + dict.set(b"Repeat".to_vec(), Object::Bool(true)); + } + } +} + +fn set_non_opaque_style(dict: &mut Dict, opacity: f64) { + if opacity < 1.0 { + dict.set(b"CA".to_vec(), Object::Real(opacity)); + } +} + +fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec) -> Result<(), AnnotError> { + let Some(rect) = rect else { return Ok(()) }; + let mut popup = Dict::new(); + popup.set(b"Type".to_vec(), Object::name("Annot")); + popup.set(b"Subtype".to_vec(), Object::name("Popup")); + popup.set(b"Rect".to_vec(), num_array(&rect)); + popup.set(b"Parent".to_vec(), Object::Ref(parent)); + popup.set(b"Open".to_vec(), Object::Bool(false)); + popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + let popup_ref = doc.add(Object::Dict(popup)); + doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?; + annots.push(Object::Ref(popup_ref)); + Ok(()) +} + +/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. +/// The picture may be an image or a PDF page. The normal appearance's transformed bounding +/// box is fitted to `/Rect` by PDF viewers. +pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> { + let matrix = match rotation { + 90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0], + 180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0], + 270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0], + _ => return Ok(()), + }; + let (_, r) = annot_ref(doc, page, index)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() { + return Err(AnnotError::Invalid("only a picture stamp can be oriented".into())); + } + let normal = d + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N"))) + .ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?; + let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else { + return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into())); + }; + stream.dict.set(b"Matrix".to_vec(), num_array(&matrix)); + doc.set(normal, Object::Stream(stream)); + Ok(()) +} diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs new file mode 100644 index 0000000000..5e698332da --- /dev/null +++ b/crates/annot/src/draw.rs @@ -0,0 +1,198 @@ +use super::create::{finite, num_array}; +use super::lookup::{annot_dict, annots, page_refs}; +use super::modify::refuse_foreign_text; +use super::types::NOTE_SIZE; +use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance}; + +/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its +/// text (a restyle, a new size or colour). +/// +/// A comment whose text the standard fonts can't draw, with an appearance another program drew +/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in +/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?` +/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn. +pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { + refuse_foreign_text(doc, &annot_dict(doc, r))?; + draw_appearance(doc, r) +} + +/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style. +pub(crate) fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> { + if let Err(e) = set_appearance(doc, r) { + doc.set(r, Object::Dict(before)); + return Err(e); + } + Ok(()) +} + +/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard +/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`, +/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that +/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font +/// can't show, and for display-only copies. +pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) }; + apply_appearance(doc, r, &d, stream) +} + +/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`]. +/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. +fn build_appearance(doc: &Document, d: &Dict) -> Option { + let mut stream = appearance::build(d)?; + // Picture-stamp restyling must retain the placement's page-axis correction. + if d.get(b"PCPicture").is_some() { + let matrix = d.get(b"AP").and_then(|ap| { + let ap = doc.resolve(ap); + let normal = doc.resolve(ap.as_dict()?.get(b"N")?); + let Object::Stream(normal) = normal.as_ref() else { return None }; + normal.dict.get(b"Matrix").cloned() + }); + if let Some(matrix) = matrix { + stream.dict.set(b"Matrix".to_vec(), matrix); + } + } + Some(stream) +} + +/// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. +fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { + // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. + let missing = appearance::undrawable_text(d).map(|(_, c)| c); + let ap = doc.add(Object::Stream(stream)); + // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover + // appearances would show the previous look on press or hover, so they go with the old /N. + let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + apd.remove(b"D"); + apd.remove(b"R"); + apd.set(b"N".to_vec(), Object::Ref(ap)); + // A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`. + // The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with + // no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing. + let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment")) + .then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::>>()) + .flatten() + .filter(|q| q.len() == 4 && finite(q)) + .map(|q| { + let (x, top) = (q[0].min(q[2]), q[1].max(q[3])); + [x, top - NOTE_SIZE, x + NOTE_SIZE, top] + }); + doc.update_dict(r, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(rect) = icon_rect { + d.set(b"Rect".to_vec(), num_array(&rect)); + } + d.remove(b"AS"); + pdfcraft_fonts::set_undrawn_mark(d, missing); + })?; + Ok(()) +} + +/// Display appearances built for annotations without any, reused while the annotation +/// dictionaries they were drawn from are unchanged. +/// +/// A content edit (moving a paragraph, retyping text, …) leaves every annotation dictionary +/// alone, but the display copy is rebuilt from the edited document on every refresh: without +/// this cache each refresh redrew every appearance (27k on one CAD file, ~0.7 s per edit +/// commit). Entries are keyed by annotation reference; object numbers are never reused within +/// a document lineage, so an entry stays valid until an annotation edit runs. Callers clear +/// the cache on any refresh that may change annotations (comments, structural and full +/// refreshes) and keep it for content-only ones. +#[derive(Clone, Debug, Default)] +pub struct AppearanceCache { + entries: std::collections::HashMap, + bytes: usize, + hits: u64, + builds: u64, +} + +impl AppearanceCache { + /// Cached appearance data restarts past this instead of growing without bound. + const MAX_BYTES: usize = 64 << 20; + + /// Forget every entry (an annotation edit may have changed the dictionaries). + pub fn clear(&mut self) { + *self = Self::default(); + } + + fn get(&mut self, r: ObjRef) -> Option { + let stream = self.entries.get(&r)?.clone(); + self.hits += 1; + Some(stream) + } + + fn insert(&mut self, r: ObjRef, stream: &Stream) { + self.builds += 1; + if self.entries.len() >= 1_000_000 { + self.clear(); + } + if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES { + self.bytes += stream.raw.len(); + self.entries.insert(r, stream.clone()); + } + // Else over budget: keep serving the entries already cached instead of growing. + } + + /// Cache hits so far (reused appearances); for tests. + pub fn hits(&self) -> u64 { + self.hits + } + + /// Appearances built so far; for tests. + pub fn builds(&self) -> u64 { + self.builds + } +} + +/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one +/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no +/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes, +/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file +/// as it was. Links, form fields and pop-ups keep their own handling. +pub fn with_missing_appearances(doc: &Document) -> Option { + with_missing_appearances_cached(doc, &mut AppearanceCache::default()) +} + +/// A copy of `doc` with [`AppearanceCache`] reuse across refreshes: annotations whose +/// dictionary is unchanged since the cached build keep their stored appearance instead of +/// redrawing it. See [`AppearanceCache`] for when entries stay valid. +pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option { + let mut copy: Option = None; + // An annotation listed more than once, on one page or several, is drawn once. `has_normal` + // reads `doc`, which never gets the appearance, so every repeat used to add another stream to + // the copy, and the display bytes grew with the number of listings. + let mut seen = std::collections::HashSet::new(); + for page in page_refs(doc).ok()? { + for r in annots(doc, page).iter().filter_map(Object::as_ref) { + if !seen.insert(r) { + continue; + } + let d = annot_dict(doc, r); + if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) { + continue; + } + let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))); + if has_normal { + continue; + } + if let Some(stream) = cache.get(r) { + // Fails only when `r` isn't a dictionary, which the cached build ruled out. + apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); + continue; + } + // Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its + // `/AP`, if any, has no `/N` to keep here. + let other_stamp = || { + let mut bare = d.clone(); + bare.remove(b"AP"); + appearance::display_stamp(&bare) + }; + let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; + cache.insert(r, &stream); + // Fails only when `r` isn't a dictionary, which the build just ruled out. + apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); + } + } + copy +} diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs new file mode 100644 index 0000000000..b56e0de20d --- /dev/null +++ b/crates/annot/src/info.rs @@ -0,0 +1,276 @@ +use super::create::finite; +use super::draw::set_appearance; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; +use super::modify::{fillable, touch, unlocked}; +use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; + +// ── reading ───────────────────────────────────────────────────────────────────────────────── + +/// One comment as the viewer lists it (the same fields and rules as +/// `pdfcraft_render::Annotation`), read straight from the object graph. The engine uses it to +/// refresh the comment list after a comment edit without re-inspecting the whole document. +#[derive(Clone, Debug, PartialEq)] +pub struct Summary { + pub page: usize, + pub index: usize, + pub subtype: String, + pub author: Option, + pub contents: Option, + /// `/M` as written (a PDF date string). + pub modified: Option, + pub name: Option, + pub in_reply_to: Option, + /// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique). + pub parent: Option<(usize, usize)>, + /// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it. + pub grouped: bool, + pub rect: [f32; 4], + pub color: Option<[f32; 3]>, + pub state: Option, + pub quads: Vec<[f32; 8]>, + pub locked: bool, + pub intent: Option, + /// A Fill & Sign mark ([`is_fill_sign`]). + pub fill_sign: bool, + /// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`. + pub no_rotate: bool, + /// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud + /// (`/Vertices`), in user space; empty for other comments. + pub points: Vec<[f32; 2]>, +} + +pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { + let o = doc.resolve(d.get(key)?); + let s = match &*o { + Object::String(s) => s.to_text(), + Object::Name(n) => String::from_utf8_lossy(n).into_owned(), + _ => return None, + }; + let s = s.trim_matches('\0').trim().to_string(); + (!s.is_empty()).then_some(s) +} + +/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup +/// multimedia and print-production annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia, +/// 3D, PrinterMark, TrapNet, Watermark) are not. +pub fn is_comment_subtype(subtype: &str) -> bool { + !matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") +} + +/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect` +/// follows it, within a few steps (a cycle stops there). +fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { + for _ in 0..8 { + match &*doc.get(r) { + Object::Ref(next) => r = *next, + _ => break, + } + } + r +} + +/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. +pub fn summaries(doc: &Document) -> Vec { + let mut out = Vec::new(); + let Ok(pages) = page_refs(doc) else { return out }; + let lists: Vec> = pages.iter().map(|p| annots(doc, *p)).collect(); + // Where each annotation is first listed, so a reply's `/IRT` finds its parent by reference. + let mut slots: std::collections::HashMap = std::collections::HashMap::new(); + for (page, list) in lists.iter().enumerate() { + for (index, entry) in list.iter().enumerate() { + if let Some(r) = entry.as_ref() { + slots.entry(final_ref(doc, r)).or_insert((page, index)); + } + } + } + for (page, list) in lists.iter().enumerate() { + for (index, entry) in list.iter().enumerate() { + let obj = entry.as_ref().map_or_else(|| doc.resolve(entry), |r| doc.get(final_ref(doc, r))); + let Some(d) = obj.as_dict() else { continue }; + let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; + if !is_comment_subtype(&subtype) { + continue; + } + let nums = |k: &[u8]| -> Vec { + d.get(k) + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0) as f32).collect())) + .unwrap_or_default() + }; + let r = nums(b"Rect"); + let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] }; + let c = nums(b"C"); + let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]); + if subtype == "FreeText" + && let Some(da) = text_value(doc, d, b"DA") + { + let t: Vec<&str> = da.split_whitespace().collect(); + if let Some(i) = t.iter().position(|x| *x == "rg") + && i >= 3 + { + let f = |k: usize| t[k].parse::().unwrap_or(0.0); + color = Some([f(i - 3), f(i - 2), f(i - 1)]); + } + } + let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec(); + // Keep in step with `pdfcraft_render::inspect`. + let points = match subtype.as_str() { + "Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(), + "PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(), + _ => Vec::new(), + }; + let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r)))); + let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); + let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied()); + out.push(Summary { + page, + index, + subtype, + author: text_value(doc, d, b"T"), + // Acrobat ends lines with CR, which the interface would draw as nothing (keep in + // step with `pdfcraft_render::inspect`). + contents: text_value(doc, d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), + modified: text_value(doc, d, b"M"), + name: text_value(doc, d, b"NM"), + in_reply_to, + parent, + grouped: d.name(b"RT") == Some(b"Group"), + rect, + color, + state: text_value(doc, d, b"State"), + quads, + locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, + intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), + fill_sign: is_fill_sign(doc, d), + no_rotate: drawn_upright(doc, d), + points, + }); + } + } + out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); + out +} + +/// Change a comment's author (`/T`), subject (`/Subj`) and, for notes, icon (`/Name`, redrawn). +/// `None` leaves a value as it is. +pub fn set_info( + doc: &mut Document, + page: usize, + index: usize, + author: Option<&str>, + subject: Option<&str>, + icon: Option, + meta: &Meta, +) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text"); + if icon.is_some() && !is_note { + return Err(AnnotError::Invalid("only sticky notes have an icon".into())); + } + doc.update_dict(r, |d| { + if let Some(a) = author { + d.set(b"T".to_vec(), PdfString::text(a)); + } + if let Some(s) = subject { + d.set(b"Subj".to_vec(), PdfString::text(s)); + } + if let Some(i) = icon { + d.set(b"Name".to_vec(), Object::name(i.name())); + } + touch(d, meta); + })?; + if icon.is_some() { + set_appearance(doc, r)?; + } + Ok(()) +} + +/// What Comment properties shows for one comment. +#[derive(Clone, Debug, PartialEq)] +pub struct Props { + pub subtype: String, + pub author: String, + pub subject: String, + pub color: Option, + pub opacity: f64, + /// Border / line width, for shapes, lines, drawings and text boxes. + pub width: Option, + /// Notes only. + pub icon: Option, + /// `/M`, as written. + pub modified: Option, + /// The appearance can be redrawn (so colour, opacity and width can change). + pub restylable: bool, + /// The Locked flag. + pub locked: bool, + /// `/LE`: two names for a line or polyline (`None` when unset), one for a callout. + pub endings: Option>, + /// Whether the comment has a fill control (see [`fillable`]). + pub fillable: bool, + /// The interior colour (`None`: no fill). + pub fill: Option, + /// Text boxes, typewriter text and callouts: the `/DA` font size in points. + pub font_size: Option, +} + +/// The current properties of the comment at `(page, index)`. +pub fn props(doc: &Document, page: usize, index: usize) -> Option { + let p = page_ref(doc, page).ok()?; + let entry = annots(doc, p).get(index).cloned()?; + let obj = doc.resolve(&entry); + let d = obj.as_dict()?; + let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); + let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); + let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; + let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { + if subtype == "FreeText" { + if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } + } else { + d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0) + } + }); + Some(Props { + author: text_value(doc, d, b"T").unwrap_or_default(), + subject: text_value(doc, d, b"Subj").unwrap_or_default(), + color, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0), + width, + icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), + modified: text_value(doc, d, b"M"), + restylable: appearance::build(d).is_some(), + locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, + fillable: fillable(&subtype), + fill: fill_of(d), + font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), + subtype, + endings: line_endings_of(d), + }) +} + +/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted. +fn fill_of(d: &Dict) -> Option { + let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" }; + let c: Vec = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); + let rgb = match c.as_slice() { + [g] => [*g; 3], + [r, g, b] => [*r, *g, *b], + [c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)], + _ => return None, + }; + finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0))) +} + +/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden. +pub(crate) fn line_endings_of(d: &Dict) -> Option> { + let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse); + match d.name(b"Subtype")? { + b"Line" | b"PolyLine" => match d.get(b"LE") { + None => Some(vec![LineEnding::None, LineEnding::None]), + Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(), + }, + b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]), + _ => None, + } +} diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index b7f9d62697..7bd0894c66 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -14,8 +14,38 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::{HashMap, HashSet}; + use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +mod create; +mod draw; +mod info; +mod lookup; +mod modify; +mod types; + +#[cfg(test)] +use create::num_array; +pub use create::{add_annotation, callout_attach, orient_image_stamp}; +pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached}; +pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries}; +#[cfg(test)] +use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref}; +use lookup::{annots, page_ref}; +pub use lookup::{ + has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image, +}; +pub(crate) use modify::n; +pub use modify::{ + add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, + set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, +}; +pub use types::{ + AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, + StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, +}; + pub mod appearance; pub mod links; @@ -43,2379 +73,3 @@ pub enum AnnotError { #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } - -pub type Rgb = [f64; 3]; - -/// Text markup kinds (§12.5.6.10). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Markup { - Highlight, - Underline, - StrikeOut, - Squiggly, -} - -impl Markup { - fn subtype(self) -> &'static str { - match self { - Markup::Highlight => "Highlight", - Markup::Underline => "Underline", - Markup::StrikeOut => "StrikeOut", - Markup::Squiggly => "Squiggly", - } - } -} - -/// The standard text-annotation icon names (§12.5.6.4, Table 175). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum NoteIcon { - #[default] - Comment, - Note, - Help, - Insert, - Key, - NewParagraph, - Paragraph, -} - -impl NoteIcon { - pub fn name(self) -> &'static str { - match self { - NoteIcon::Comment => "Comment", - NoteIcon::Note => "Note", - NoteIcon::Help => "Help", - NoteIcon::Insert => "Insert", - NoteIcon::Key => "Key", - NoteIcon::NewParagraph => "NewParagraph", - NoteIcon::Paragraph => "Paragraph", - } - } - - pub fn from_name(n: &str) -> Option { - [Self::Comment, Self::Note, Self::Help, Self::Insert, Self::Key, Self::NewParagraph, Self::Paragraph].into_iter().find(|i| i.name() == n) - } -} - -/// Fill & Sign marks (Acrobat's ✓, ✕, ●, ─). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FillMark { - Check, - Cross, - Dot, - Line, -} - -impl FillMark { - /// The `/Name` of the stamp PdfCraft draws for it. - pub fn name(self) -> &'static str { - match self { - FillMark::Check => "PCCheck", - FillMark::Cross => "PCCross", - FillMark::Dot => "PCDot", - FillMark::Line => "PCLine", - } - } - - pub fn label(self) -> &'static str { - match self { - FillMark::Check => "Checkmark", - FillMark::Cross => "Cross", - FillMark::Dot => "Dot", - FillMark::Line => "Line", - } - } -} - -/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum StampKind { - // Standard business. - Approved, - Completed, - Confidential, - Draft, - Final, - ForComment, - ForPublicRelease, - InformationOnly, - NotApproved, - NotForPublicRelease, - PreliminaryResults, - Void, - // Sign here. - Accepted, - InitialHere, - Rejected, - SignHere, - Witness, - // Dynamic (with a "By … at …" line). - DynApproved, - DynConfidential, - DynReceived, - DynReviewed, - DynRevised, -} - -/// The palette's sections. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StampGroup { - Dynamic, - SignHere, - StandardBusiness, -} - -impl StampKind { - pub const ALL: [StampKind; 22] = [ - StampKind::DynApproved, - StampKind::DynConfidential, - StampKind::DynReceived, - StampKind::DynReviewed, - StampKind::DynRevised, - StampKind::Accepted, - StampKind::InitialHere, - StampKind::Rejected, - StampKind::SignHere, - StampKind::Witness, - StampKind::Approved, - StampKind::Completed, - StampKind::Confidential, - StampKind::Draft, - StampKind::Final, - StampKind::ForComment, - StampKind::ForPublicRelease, - StampKind::InformationOnly, - StampKind::NotApproved, - StampKind::NotForPublicRelease, - StampKind::PreliminaryResults, - StampKind::Void, - ]; - - pub fn group(self) -> StampGroup { - use StampKind::*; - match self { - DynApproved | DynConfidential | DynReceived | DynReviewed | DynRevised => StampGroup::Dynamic, - Accepted | InitialHere | Rejected | SignHere | Witness => StampGroup::SignHere, - _ => StampGroup::StandardBusiness, - } - } - - /// The text on the stamp. - pub fn label(self) -> &'static str { - use StampKind::*; - match self { - Approved | DynApproved => "APPROVED", - Completed => "COMPLETED", - Confidential | DynConfidential => "CONFIDENTIAL", - Draft => "DRAFT", - Final => "FINAL", - ForComment => "FOR COMMENT", - ForPublicRelease => "FOR PUBLIC RELEASE", - InformationOnly => "INFORMATION ONLY", - NotApproved => "NOT APPROVED", - NotForPublicRelease => "NOT FOR PUBLIC RELEASE", - PreliminaryResults => "PRELIMINARY RESULTS", - Void => "VOID", - Accepted => "ACCEPTED", - InitialHere => "INITIAL HERE", - Rejected => "REJECTED", - SignHere => "SIGN HERE", - Witness => "WITNESS", - DynReceived => "RECEIVED", - DynReviewed => "REVIEWED", - DynRevised => "REVISED", - } - } - - /// `/Name`: the standard stamp names of ISO 32000-2 Table 184 where one exists. - pub fn name(self) -> &'static str { - use StampKind::*; - match self { - Approved => "Approved", - Confidential => "Confidential", - Draft => "Draft", - Final => "Final", - ForComment => "ForComment", - ForPublicRelease => "ForPublicRelease", - NotApproved => "NotApproved", - NotForPublicRelease => "NotForPublicRelease", - Completed => "PCCompleted", - InformationOnly => "PCInformationOnly", - PreliminaryResults => "PCPreliminaryResults", - Void => "PCVoid", - Accepted => "PCAccepted", - InitialHere => "PCInitialHere", - Rejected => "PCRejected", - SignHere => "PCSignHere", - Witness => "PCWitness", - DynApproved => "PCDynApproved", - DynConfidential => "PCDynConfidential", - DynReceived => "PCDynReceived", - DynReviewed => "PCDynReviewed", - DynRevised => "PCDynRevised", - } - } - - pub fn from_name(n: &[u8]) -> Option { - Self::ALL.into_iter().find(|k| k.name().as_bytes() == n) - } - - /// The stamp's colour: green for approval, red for refusal and restriction, blue otherwise. - pub fn color(self) -> Rgb { - use StampKind::*; - match self { - Approved | Completed | Final | Accepted | DynApproved | DynReceived | DynReviewed => [0.13, 0.55, 0.13], - NotApproved | Rejected | Void | Confidential | NotForPublicRelease | DynConfidential => [0.80, 0.10, 0.10], - SignHere | InitialHere | Witness => [0.85, 0.35, 0.05], - _ => [0.10, 0.30, 0.70], - } - } - - /// The stamp's size (points) for its label, as placed with a click. - pub fn size(self) -> (f64, f64) { - let w = appearance::text_width(self.label(), 16.0) * 1.12 + 24.0; - let h = if self.group() == StampGroup::Dynamic { 42.0 } else { 30.0 }; - let w = if self.group() == StampGroup::SignHere { w + 14.0 } else { w }; - (w.max(80.0), h) - } -} - -/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LineEnding { - None, - Square, - Circle, - Diamond, - OpenArrow, - ClosedArrow, - Butt, - ROpenArrow, - RClosedArrow, - Slash, -} - -impl LineEnding { - pub const ALL: [Self; 10] = [ - Self::None, - Self::Square, - Self::Circle, - Self::Diamond, - Self::OpenArrow, - Self::ClosedArrow, - Self::Butt, - Self::ROpenArrow, - Self::RClosedArrow, - Self::Slash, - ]; - - pub fn name(self) -> &'static str { - match self { - Self::None => "None", - Self::Square => "Square", - Self::Circle => "Circle", - Self::Diamond => "Diamond", - Self::OpenArrow => "OpenArrow", - Self::ClosedArrow => "ClosedArrow", - Self::Butt => "Butt", - Self::ROpenArrow => "ROpenArrow", - Self::RClosedArrow => "RClosedArrow", - Self::Slash => "Slash", - } - } - - pub fn parse(name: &str) -> Option { - Self::ALL.into_iter().find(|ending| ending.name() == name) - } -} - -/// Geometry of a new comment, in PDF user space of its page. -#[derive(Clone, Debug, PartialEq)] -pub enum Shape { - /// A sticky note whose icon's top-left corner is at `at`. - Note { - at: [f64; 2], - icon: NoteIcon, - }, - /// Text markup over quadrilaterals: `[x1 y1 x2 y2 x3 y3 x4 y4]` = top-left, top-right, - /// bottom-left, bottom-right (the order Acrobat writes, §12.5.6.10). - TextMarkup { - kind: Markup, - quads: Vec<[f64; 8]>, - }, - Rectangle { - rect: [f64; 4], - }, - Oval { - rect: [f64; 4], - }, - /// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool). - Line { - from: [f64; 2], - to: [f64; 2], - start: LineEnding, - end: LineEnding, - }, - /// Freehand strokes (Draw tool). - Ink { - strokes: Vec>, - }, - /// A text box (FreeText) showing the comment's contents. - TextBox { - rect: [f64; 4], - font_size: f64, - }, - /// Fill & Sign text typed onto the page (FreeText, typewriter intent, no border). - Typewriter { - rect: [f64; 4], - font_size: f64, - }, - /// A Fill & Sign mark in `rect`. - Mark { - rect: [f64; 4], - mark: FillMark, - }, - /// A drawn signature (ink strokes). - Signature { - strokes: Vec>, - }, - /// A rubber stamp from the stamp palette; `by` is the dynamic stamps' second line. - Stamp { - rect: [f64; 4], - stamp: StampKind, - by: Option, - }, - /// A typed signature or initials: filled outlines (each contour as points normalised to - /// `rect`, 0–1, y up), drawn with the even-odd rule. - TypedSignature { - rect: [f64; 4], - contours: Vec>, - }, - /// A custom stamp: a picture already in the document (an image XObject, or a form XObject - /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. - CustomStamp { - rect: [f64; 4], - name: String, - picture: ObjRef, - image: bool, - size: (f64, f64), - }, - /// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad - /// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`. - Redact { - quads: Vec<[f64; 8]>, - overlay: String, - look: OverlayLook, - }, - /// A closed polygon (Polygon tool); with `cloud`, Acrobat's Cloud tool: the same polygon - /// with a cloudy border (`/BE /S /C`, `/IT /PolygonCloud`). - Polygon { - vertices: Vec<[f64; 2]>, - cloud: bool, - }, - /// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last. - PolyLine { - vertices: Vec<[f64; 2]>, - start: LineEnding, - end: LineEnding, - }, - /// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via - /// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`). - Callout { - rect: [f64; 4], - knee: [f64; 2], - point: [f64; 2], - font_size: f64, - ending: LineEnding, - }, - /// Insert text: a caret in `rect` (its point at the top centre). - Caret { - rect: [f64; 4], - }, - /// Attach a file as a comment: an icon whose top-left corner is at `at`, holding `data` - /// (embedded) under the name `file`. - Attachment { - at: [f64; 2], - icon: AttachIcon, - file: String, - data: Vec, - }, -} - -/// The standard file attachment icons (§12.5.6.15). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum AttachIcon { - #[default] - PushPin, - Paperclip, - Graph, - Tag, -} - -impl AttachIcon { - pub const ALL: [AttachIcon; 4] = [AttachIcon::PushPin, AttachIcon::Paperclip, AttachIcon::Graph, AttachIcon::Tag]; - pub fn name(self) -> &'static str { - match self { - AttachIcon::PushPin => "PushPin", - AttachIcon::Paperclip => "Paperclip", - AttachIcon::Graph => "Graph", - AttachIcon::Tag => "Tag", - } - } - pub fn from_name(n: &str) -> Option { - Self::ALL.into_iter().find(|i| i.name().eq_ignore_ascii_case(n)) - } -} - -/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left, -/// bottom-right). -pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { - let [x0, y0, x1, y1] = normalize(r); - [x0, y1, x1, y1, x0, y0, x1, y0] -} - -impl Shape { - pub fn subtype(&self) -> &'static str { - match self { - Shape::Note { .. } => "Text", - Shape::TextMarkup { kind, .. } => kind.subtype(), - Shape::Rectangle { .. } => "Square", - Shape::Oval { .. } => "Circle", - Shape::Line { .. } => "Line", - Shape::Ink { .. } | Shape::Signature { .. } => "Ink", - Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText", - Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp", - Shape::Redact { .. } => "Redact", - Shape::Polygon { .. } => "Polygon", - Shape::PolyLine { .. } => "PolyLine", - Shape::Caret { .. } => "Caret", - Shape::Attachment { .. } => "FileAttachment", - } - } -} - -/// The standard fonts of redaction overlay text. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum OverlayFont { - #[default] - Helvetica, - Times, - Courier, -} - -impl OverlayFont { - pub const ALL: [OverlayFont; 3] = [OverlayFont::Helvetica, OverlayFont::Times, OverlayFont::Courier]; - - pub fn name(self) -> &'static str { - match self { - OverlayFont::Helvetica => "Helvetica", - OverlayFont::Times => "Times Roman", - OverlayFont::Courier => "Courier", - } - } - - /// The `/DA` font resource name (Acrobat's form font names). - pub fn resource(self) -> &'static str { - match self { - OverlayFont::Helvetica => "Helv", - OverlayFont::Times => "TiRo", - OverlayFont::Courier => "Cour", - } - } - - pub fn from_resource(n: &str) -> OverlayFont { - match n { - "TiRo" | "Times-Roman" | "TimesRoman" => OverlayFont::Times, - "Cour" | "Courier" => OverlayFont::Courier, - _ => OverlayFont::Helvetica, - } - } -} - -/// How redaction overlay text is drawn (Redaction Properties ▸ Appearance). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct OverlayLook { - pub font: OverlayFont, - /// Points; 0 = auto-size to fit the area. - pub size: f64, - pub color: Rgb, - /// 0 left, 1 centre, 2 right. - pub align: u8, - /// Repeat the text to fill the area. - pub repeat: bool, -} - -impl Default for OverlayLook { - fn default() -> Self { - Self { font: OverlayFont::Helvetica, size: 0.0, color: [1.0, 0.0, 0.0], align: 1, repeat: false } - } -} - -/// How a comment looks. -#[derive(Clone, Debug, PartialEq)] -pub struct Style { - /// Stroke colour (text colour for text boxes, icon colour for notes). - pub color: Rgb, - /// 0–1 (`/CA`). - pub opacity: f64, - /// Border / stroke width in points (0 = none). - pub width: f64, - /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. - pub fill: Option, -} - -impl Default for Style { - fn default() -> Self { - Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } - } -} - -impl Style { - /// The default look of each commenting tool. - pub fn default_for(shape: &Shape) -> Self { - let (color, width) = match shape { - Shape::Note { .. } => ([1.0, 0.82, 0.0], 1.0), - Shape::TextMarkup { kind: Markup::Highlight, .. } => ([1.0, 0.94, 0.0], 1.0), - Shape::TextMarkup { kind: Markup::Underline, .. } => ([0.0, 0.47, 0.84], 1.0), - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => ([0.89, 0.13, 0.13], 1.0), - Shape::TextMarkup { kind: Markup::Squiggly, .. } => ([0.18, 0.62, 0.36], 1.0), - Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } | Shape::Polygon { .. } | Shape::PolyLine { .. } => { - ([0.89, 0.13, 0.13], 2.0) - } - Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), - Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), - Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), - Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), - Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), - Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5), - Shape::Stamp { stamp, .. } => (stamp.color(), 2.0), - Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0), - Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0), - // Red outline while marked; a black box once applied. - Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) }, - }; - Self { color, opacity: 1.0, width, fill: None } - } -} - -/// A comment to add. -#[derive(Clone, Debug, PartialEq)] -pub struct NewAnnotation { - /// 0-based page index. - pub page: usize, - pub shape: Shape, - pub style: Style, - /// The comment text (`/Contents`); the text shown by a text box. - pub contents: String, - /// `/T` (shown as the comment's author). - pub author: String, -} - -/// Values stamped onto what an edit creates or changes, supplied by the caller so edits stay -/// deterministic: a PDF date (`D:…`) and a unique id for `/NM`. -#[derive(Clone, Debug, PartialEq, Default)] -pub struct Meta { - pub date: Option, - pub id: String, -} - -/// Review states a reply can set (§12.5.6.3, Table 172; Acrobat's "Set status"). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ReviewState { - None, - Accepted, - Rejected, - Cancelled, - Completed, -} - -impl ReviewState { - pub fn name(self) -> &'static str { - match self { - ReviewState::None => "None", - ReviewState::Accepted => "Accepted", - ReviewState::Rejected => "Rejected", - ReviewState::Cancelled => "Cancelled", - ReviewState::Completed => "Completed", - } - } - - pub fn from_name(n: &str) -> Option { - [Self::None, Self::Accepted, Self::Rejected, Self::Cancelled, Self::Completed].into_iter().find(|s| s.name().eq_ignore_ascii_case(n)) - } -} - -// ── pages and /Annots ─────────────────────────────────────────────────────────────────────── - -/// The page object of each leaf page, in order. -pub fn page_refs(doc: &Document) -> Result, AnnotError> { - let root = doc.root().ok_or(AnnotError::NoPageTree)?; - let pages = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).ok_or(AnnotError::NoPageTree)?; - let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut stack = vec![pages]; - while let Some(node) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - match d.get(b"Kids").map(|k| doc.resolve(k)) { - Some(kids) if d.name(b"Type") != Some(b"Page") => { - if let Some(a) = kids.as_array() { - stack.extend(a.iter().rev().filter_map(|k| k.as_ref())); - } - } - _ => out.push(node), - } - } - Ok(out) -} - -fn page_ref(doc: &Document, page: usize) -> Result { - page_refs(doc)?.get(page).copied().ok_or(AnnotError::NoSuchPage(page)) -} - -/// The entries of a page's `/Annots` (references or inline dictionaries). -fn annots(doc: &Document, page: ObjRef) -> Vec { - let obj = doc.get(page); - let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() }; - doc.resolve(a).as_array().cloned().unwrap_or_default() -} - -/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. -fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), AnnotError> { - let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots")); - match existing { - Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)), - _ => doc.update_dict(page, |d| { - if list.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(list)); - } - })?, - } - Ok(()) -} - -/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). -fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { - let p = page_ref(doc, page)?; - let mut list = annots(doc, p); - let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let r = match entry { - Object::Ref(r) if doc.get(r).as_dict().is_some() => r, - Object::Dict(d) => { - let r = doc.add(Object::Dict(d)); - list[index] = Object::Ref(r); - set_annots(doc, p, list)?; - r - } - _ => return Err(AnnotError::NoSuchAnnotation { page, index }), - }; - Ok((p, r)) -} - -fn annot_dict(doc: &Document, r: ObjRef) -> Dict { - doc.get(r).as_dict().cloned().unwrap_or_default() -} - -/// A Fill & Sign item: typed text, a check/cross/dot/line, or a typed, drawn or image signature. -/// -/// New items carry `/PCFillSign`. Older files are recognised from the dictionaries PdfCraft -/// already writes (`/IT /FreeTextTypeWriter`, stamp `/Name`, drawn ink whose subject is -/// "Signature"). -pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { - if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { - return true; - } - let subtype = d.name(b"Subtype").unwrap_or_default(); - if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") { - return true; - } - if subtype == b"Stamp" { - if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) { - return true; - } - if matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) - && matches!(d.get(b"PCPictureImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) - { - return true; - } - } - subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature") -} - -fn annotation_flags(doc: &Document, d: &Dict) -> i64 { - d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) -} - -/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden). -pub fn has_visible_fill_sign(doc: &Document) -> bool { - let Ok(pages) = page_refs(doc) else { return false }; - for page in pages { - for entry in annots(doc, page) { - let obj = doc.resolve(&entry); - let Some(d) = obj.as_dict() else { continue }; - let flags = annotation_flags(doc, d); - if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 { - continue; - } - if is_fill_sign(doc, d) { - return true; - } - } - } - false -} - -fn fill_sign_shape(shape: &Shape) -> bool { - match shape { - Shape::Typewriter { .. } | Shape::Mark { .. } | Shape::Signature { .. } | Shape::TypedSignature { .. } => true, - Shape::CustomStamp { name, image: true, .. } => name == "Signature" || name == "Initials", - _ => false, - } -} - -/// The embedded image of a Fill & Sign image signature or initials (0-based target). -/// Other stamps have appearances that can't be represented by this image alone. -pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result, AnnotError> { - let p = page_ref(doc, page)?; - let list = annots(doc, p); - let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let obj = doc.resolve(entry); - let Some(d) = obj.as_dict() else { return Ok(None) }; - Ok((d.name(b"Subtype") == Some(b"Stamp") - && matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) - && matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)))) - .then(|| d.reference(b"PCPicture")) - .flatten()) -} - -/// The page `/Rotate` the picture of the image signature at `(page, index)` is drawn turned back -/// by, so it reads upright on a page shown that way: 0 for one added to an unturned page, or by -/// another app. The picture appears turned by the page's current rotation less this. -pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result { - let p = page_ref(doc, page)?; - let list = annots(doc, p); - let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; - Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn)) -} - -// ── building ──────────────────────────────────────────────────────────────────────────────── - -/// Annotation flags (§12.5.3). -const FLAG_PRINT: i64 = 4; -const FLAG_NO_ZOOM: i64 = 8; -const FLAG_NO_ROTATE: i64 = 16; -const FLAG_HIDDEN: i64 = 2; -const FLAG_NO_VIEW: i64 = 32; -const FLAG_LOCKED: i64 = 128; - -/// Size of a note icon (points, unscaled by zoom). -pub const NOTE_SIZE: f64 = 20.0; - -fn num_array(v: &[f64]) -> Object { - Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) -} - -fn rgb(c: Rgb) -> Object { - num_array(&c.map(|x| x.clamp(0.0, 1.0))) -} - -fn finite(v: &[f64]) -> bool { - v.iter().all(|x| x.is_finite() && x.abs() < 1e7) -} - -fn normalize(r: [f64; 4]) -> [f64; 4] { - [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] -} - -fn bounds(points: impl Iterator) -> Option<[f64; 4]> { - points.fold(None, |acc, [x, y]| match acc { - None => Some([x, y, x, y]), - Some([a, b, c, d]) => Some([a.min(x), b.min(y), c.max(x), d.max(y)]), - }) -} - -fn grow(r: [f64; 4], by: f64) -> [f64; 4] { - [r[0] - by, r[1] - by, r[2] + by, r[3] + by] -} - -fn ending_draws(ending: LineEnding) -> bool { - ending != LineEnding::None -} - -fn ending_pair(start: LineEnding, end: LineEnding) -> Object { - Object::Array(vec![Object::name(start.name()), Object::name(end.name())]) -} - -/// Validate and compute `/Rect` for a new comment. -fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { - let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}")); - let half = style.width.max(0.0) / 2.0; - let r = match shape { - Shape::Note { at, .. } | Shape::Attachment { at, .. } => { - if !finite(at) { - return Err(bad("position")); - } - [at[0], at[1] - NOTE_SIZE, at[0] + NOTE_SIZE, at[1]] - } - Shape::TextMarkup { quads, .. } | Shape::Redact { quads, .. } => { - if quads.is_empty() || !quads.iter().all(|q| finite(q)) { - return Err(bad("text area (no quadrilaterals)")); - } - bounds(quads.iter().flat_map(|q| q.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect::>())) - .ok_or_else(|| bad("text area"))? - } - Shape::Rectangle { rect } - | Shape::Oval { rect } - | Shape::TextBox { rect, .. } - | Shape::Typewriter { rect, .. } - | Shape::Stamp { rect, .. } - | Shape::TypedSignature { rect, .. } - | Shape::Mark { rect, .. } => { - let r = normalize(*rect); - if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { - return Err(bad("rectangle (too small)")); - } - r - } - Shape::CustomStamp { rect, .. } => { - let r = normalize(*rect); - // Image signatures may be very thin; a positive PDF appearance box still works. - if !finite(rect) || r[2] <= r[0] || r[3] <= r[1] { - return Err(bad("rectangle (empty)")); - } - r - } - Shape::Line { from, to, start, end } => { - if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 { - return Err(bad("line (too short)")); - } - let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; - grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) - } - Shape::Ink { strokes } | Shape::Signature { strokes } => { - if strokes.iter().all(|s| s.is_empty()) || !strokes.iter().flatten().all(|p| finite(p)) { - return Err(bad("drawing (no points)")); - } - // Strokes of three or more points are drawn as curves, which stay within their - // points and control points. - let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| { - let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect(); - appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]]) - }); - grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0) - } - Shape::Polygon { vertices, cloud } => { - let b = bounds(vertices.iter().copied()) - .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); - let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?; - grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 }) - } - Shape::PolyLine { vertices, start, end } => { - let b = bounds(vertices.iter().copied()) - .filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0); - let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; - grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra) - } - Shape::Callout { rect, knee, point, .. } => { - let r = normalize(*rect); - if !finite(rect) || !finite(knee) || !finite(point) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { - return Err(bad("callout")); - } - let pad = half + appearance::arrow_size(style.width) + 1.0; - let lead = grow(bounds([*knee, *point].into_iter()).unwrap_or_default(), pad); - [r[0].min(lead[0]), r[1].min(lead[1]), r[2].max(lead[2]), r[3].max(lead[3])] - } - Shape::Caret { rect } => { - let r = normalize(*rect); - if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 { - return Err(bad("caret")); - } - r - } - }; - Ok(r) -} - -fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name(subtype)); - d.set(b"Rect".to_vec(), num_array(&rect)); - d.set(b"Contents".to_vec(), PdfString::text(contents)); - if !author.is_empty() { - d.set(b"T".to_vec(), PdfString::text(author)); - } - if let Some(date) = &meta.date { - d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - d.set(b"CreationDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - } - if !meta.id.is_empty() { - d.set(b"NM".to_vec(), PdfString::text(&meta.id)); - } - d.set(b"P".to_vec(), Object::Ref(page)); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT)); - d -} - -/// Acrobat's `/Subj` for each tool (shown as the comment type in other viewers). -fn subject(shape: &Shape) -> &'static str { - match shape { - Shape::Note { .. } => "Sticky Note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "Highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "Underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly", - Shape::Rectangle { .. } => "Rectangle", - Shape::Oval { .. } => "Oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", - Shape::Line { .. } => "Line", - Shape::Ink { .. } => "Pencil", - Shape::TextBox { .. } => "Text Box", - Shape::Typewriter { .. } => "Typewriter", - Shape::Mark { mark, .. } => mark.label(), - Shape::Signature { .. } => "Signature", - Shape::Redact { .. } => "Redact", - Shape::Stamp { stamp, .. } => match stamp.group() { - StampGroup::Dynamic => "Dynamic stamp", - StampGroup::SignHere => "Sign Here", - StampGroup::StandardBusiness => "Stamp", - }, - Shape::CustomStamp { .. } => "Stamp", - Shape::TypedSignature { .. } => "Signature", - Shape::Polygon { cloud: true, .. } => "Cloud", - Shape::Polygon { .. } => "Polygon", - Shape::PolyLine { .. } => "Polygonal Line", - Shape::Callout { .. } => "Callout", - Shape::Caret { .. } => "Inserted Text", - Shape::Attachment { .. } => "File Attachment", - } -} - -/// Where a callout's leader line meets its text box: the middle of the side facing `knee`. -pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { - let [x0, y0, x1, y1] = normalize(rect); - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - if knee[0] < x0 { - [x0, cy] - } else if knee[0] > x1 { - [x1, cy] - } else if knee[1] > y1 { - [cx, y1] - } else { - [cx, y0] - } -} - -/// Add a comment; returns its index in the page's `/Annots`. -pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result { - let page = page_ref(doc, new.page)?; - let style = &new.style; - if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { - return Err(AnnotError::Invalid("invalid style".into())); - } - let rect = rect_for(&new.shape, style)?; - let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); - d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); - let opacity = style.opacity.clamp(0.0, 1.0); - if opacity < 1.0 { - d.set(b"CA".to_vec(), Object::Real(opacity)); - } - let border = |d: &mut Dict| { - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); - bs.set(b"S".to_vec(), Object::name("S")); - d.set(b"BS".to_vec(), Object::Dict(bs)); - }; - let mut popup = None; - match &new.shape { - Shape::Note { icon, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(icon.name())); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - d.set(b"Open".to_vec(), Object::Bool(false)); - popup = Some([rect[2] + 10.0, rect[3] - 120.0, rect[2] + 210.0, rect[3]]); - } - Shape::TextMarkup { quads, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); - } - Shape::Redact { quads, overlay, look } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); - d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); - if !overlay.is_empty() { - d.set(b"OverlayText".to_vec(), PdfString::text(overlay)); - let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); - let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; - d.set( - b"DA".to_vec(), - PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()), - ); - d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); - if look.repeat { - d.set(b"Repeat".to_vec(), Object::Bool(true)); - } - } - } - Shape::Rectangle { .. } | Shape::Oval { .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - if let Some(f) = style.fill { - d.set(b"IC".to_vec(), rgb(f)); - } - border(&mut d); - } - Shape::Line { from, to, start, end } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]])); - if ending_draws(*start) || ending_draws(*end) { - d.set(b"LE".to_vec(), ending_pair(*start, *end)); - } - border(&mut d); - } - Shape::Mark { mark, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(mark.name())); - border(&mut d); - } - Shape::Stamp { stamp, by, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(stamp.name())); - // Marks the stamp as drawn by PdfCraft: other stamps with standard names keep - // their own artwork. - d.set(b"PCStamp".to_vec(), Object::Bool(true)); - if let Some(b) = by { - d.set(b"PCByLine".to_vec(), PdfString::text(b)); - } - // The label is drawn for the page as displayed. A later page rotation is a new edit. - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if matches!(turn, 90 | 180 | 270) { - d.set(b"PCStampRotate".to_vec(), Object::Int(turn)); - } - } - Shape::TypedSignature { contours, .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name("PCTypedSignature")); - let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::>())).collect(); - d.set(b"PCOutline".to_vec(), Object::Array(list)); - } - Shape::CustomStamp { name, picture, image, size, .. } => { - let clean: String = name.chars().filter(|c| c.is_ascii_alphanumeric()).collect(); - d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}"))); - d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); - d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); - d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); - // An image signature's rectangle is in user space; on a turned page its appearance is - // turned back (see `appearance::build`), so it reads upright as displayed. - if *image && matches!(clean.as_str(), "Signature" | "Initials") { - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if turn != 0 { - d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); - } - } - } - Shape::Ink { strokes } | Shape::Signature { strokes } => { - d.set(b"C".to_vec(), rgb(style.color)); - let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect(); - d.set(b"InkList".to_vec(), Object::Array(list)); - border(&mut d); - } - Shape::Polygon { vertices, cloud } => { - d.set(b"C".to_vec(), rgb(style.color)); - if let Some(f) = style.fill { - d.set(b"IC".to_vec(), rgb(f)); - } - d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); - if *cloud { - d.set(b"IT".to_vec(), Object::name("PolygonCloud")); - let mut be = Dict::new(); - be.set(b"S".to_vec(), Object::name("C")); - be.set(b"I".to_vec(), Object::Int(1)); - d.set(b"BE".to_vec(), Object::Dict(be)); - } - border(&mut d); - } - Shape::PolyLine { vertices, start, end } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); - if ending_draws(*start) || ending_draws(*end) { - d.set(b"LE".to_vec(), ending_pair(*start, *end)); - } - border(&mut d); - } - Shape::Caret { .. } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Sy".to_vec(), Object::name("None")); - } - Shape::Attachment { icon, file, data, .. } => { - if file.trim().is_empty() { - return Err(AnnotError::Invalid("the attached file needs a name".into())); - } - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"Name".to_vec(), Object::name(icon.name())); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - // The file: an embedded file stream inside a file specification (§7.11.4). - let mut params = Dict::new(); - params.set(b"Size".to_vec(), Object::Int(data.len() as i64)); - if let Some(date) = &meta.date { - params.set(b"ModDate".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - } - let mut ef = Dict::new(); - ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); - ef.set(b"Params".to_vec(), Object::Dict(params)); - let ef = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(ef, data))); - let mut efd = Dict::new(); - efd.set(b"F".to_vec(), Object::Ref(ef)); - efd.set(b"UF".to_vec(), Object::Ref(ef)); - let mut fs = Dict::new(); - fs.set(b"Type".to_vec(), Object::name("Filespec")); - fs.set(b"F".to_vec(), PdfString::text(file.trim())); - fs.set(b"UF".to_vec(), PdfString::text(file.trim())); - fs.set(b"EF".to_vec(), Object::Dict(efd)); - d.set(b"FS".to_vec(), Object::Ref(doc.add(Object::Dict(fs)))); - if new.contents.is_empty() { - d.set(b"Contents".to_vec(), PdfString::text(file.trim())); - } - } - Shape::Callout { rect: tb, knee, point, ending, .. } => { - let tb = normalize(*tb); - let attach = callout_attach(tb, *knee); - d.set(b"IT".to_vec(), Object::name("FreeTextCallout")); - d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]])); - d.set(b"LE".to_vec(), Object::name(ending.name())); - // The text box inside /Rect (§12.5.6.6 /RD). - d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]])); - } - _ => {} - } - match &new.shape { - Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } | Shape::Callout { font_size, .. } => { - if matches!(new.shape, Shape::Typewriter { .. }) { - d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter")); - } - let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 }; - let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); - d.set(b"Q".to_vec(), Object::Int(0)); - if let Some(f) = style.fill { - d.set(b"C".to_vec(), rgb(f)); - } - border(&mut d); - rich_text(&mut d); - // The text is laid out for the page as displayed, like a stamp's label. - if !matches!(new.shape, Shape::Callout { .. }) { - let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); - if matches!(turn, 90 | 180 | 270) { - d.set(b"PCTextRotate".to_vec(), Object::Int(turn)); - } - } - } - _ => {} - } - if fill_sign_shape(&new.shape) { - // Survives a later subject edit, so flatten-on-save still finds the mark. - d.set(b"PCFillSign".to_vec(), Object::Bool(true)); - } - // A redaction's overlay text becomes page content when it is applied, where `?` would be there - // for good: refused before anything is added. A comment's own text is stored as typed and - // drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`). - if d.name(b"Subtype") == Some(b"Redact") - && let Some((what, c)) = appearance::undrawable_text(&d) - { - return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c))); - } - let r = doc.add(Object::Dict(d.clone())); - draw_appearance(doc, r)?; - let mut list = annots(doc, page); - let index = list.len(); - list.push(Object::Ref(r)); - if let Some(pr) = popup { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), num_array(&pr)); - p.set(b"Parent".to_vec(), Object::Ref(r)); - p.set(b"Open".to_vec(), Object::Bool(false)); - p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - let pref = doc.add(Object::Dict(p)); - doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?; - list.push(Object::Ref(pref)); - } - set_annots(doc, page, list)?; - Ok(index) -} - -/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. -/// The picture may be an image or a PDF page. The normal appearance's transformed bounding -/// box is fitted to `/Rect` by PDF viewers. -pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> { - let matrix = match rotation { - 90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0], - 180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0], - 270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0], - _ => return Ok(()), - }; - let (_, r) = annot_ref(doc, page, index)?; - let d = annot_dict(doc, r); - if d.name(b"Subtype") != Some(b"Stamp") || d.get(b"PCPicture").is_none() { - return Err(AnnotError::Invalid("only a picture stamp can be oriented".into())); - } - let normal = d - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N"))) - .ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?; - let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else { - return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into())); - }; - stream.dict.set(b"Matrix".to_vec(), num_array(&matrix)); - doc.set(normal, Object::Stream(stream)); - Ok(()) -} - -/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would -/// replace those characters with `?`, so the edit is refused before the dictionary changes. -fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> { - let drawn_elsewhere = - d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none(); - if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) { - return Err(AnnotError::Undrawable(format!( - "PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)", - c.escape_debug(), - u32::from(c) - ))); - } - Ok(()) -} - -/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its -/// text (a restyle, a new size or colour). -/// -/// A comment whose text the standard fonts can't draw, with an appearance another program drew -/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in -/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?` -/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn. -pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { - refuse_foreign_text(doc, &annot_dict(doc, r))?; - draw_appearance(doc, r) -} - -/// Put `before` back when redrawing fails, so a refused edit does not keep the new rectangle or style. -fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) -> Result<(), AnnotError> { - if let Err(e) = set_appearance(doc, r) { - doc.set(r, Object::Dict(before)); - return Err(e); - } - Ok(()) -} - -/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard -/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`, -/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that -/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font -/// can't show, and for display-only copies. -fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { - let d = annot_dict(doc, r); - let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) }; - apply_appearance(doc, r, &d, stream) -} - -/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`]. -/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it. -fn build_appearance(doc: &Document, d: &Dict) -> Option { - let mut stream = appearance::build(d)?; - // Picture-stamp restyling must retain the placement's page-axis correction. - if d.get(b"PCPicture").is_some() { - let matrix = d.get(b"AP").and_then(|ap| { - let ap = doc.resolve(ap); - let normal = doc.resolve(ap.as_dict()?.get(b"N")?); - let Object::Stream(normal) = normal.as_ref() else { return None }; - normal.dict.get(b"Matrix").cloned() - }); - if let Some(matrix) = matrix { - stream.dict.set(b"Matrix".to_vec(), matrix); - } - } - Some(stream) -} - -/// Store a built appearance on the annotation: the cheap half of [`set_appearance`]. -fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> { - // Characters the standard fonts can't draw came out as `?`: mark the annotation with the first. - let missing = appearance::undrawable_text(d).map(|(_, c)| c); - let ap = doc.add(Object::Stream(stream)); - // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover - // appearances would show the previous look on press or hover, so they go with the old /N. - let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); - apd.remove(b"D"); - apd.remove(b"R"); - apd.set(b"N".to_vec(), Object::Ref(ap)); - doc.update_dict(r, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - d.remove(b"AS"); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - Ok(()) -} - -/// Display appearances built for annotations without any, reused while the annotation -/// dictionaries they were drawn from are unchanged. -/// -/// A content edit (moving a paragraph, retyping text, …) leaves every annotation dictionary -/// alone, but the display copy is rebuilt from the edited document on every refresh: without -/// this cache each refresh redrew every appearance (27k on one CAD file, ~0.7 s per edit -/// commit). Entries are keyed by annotation reference; object numbers are never reused within -/// a document lineage, so an entry stays valid until an annotation edit runs. Callers clear -/// the cache on any refresh that may change annotations (comments, structural and full -/// refreshes) and keep it for content-only ones. -#[derive(Clone, Debug, Default)] -pub struct AppearanceCache { - entries: std::collections::HashMap, - bytes: usize, - hits: u64, - builds: u64, -} - -impl AppearanceCache { - /// Cached appearance data restarts past this instead of growing without bound. - const MAX_BYTES: usize = 64 << 20; - - /// Forget every entry (an annotation edit may have changed the dictionaries). - pub fn clear(&mut self) { - *self = Self::default(); - } - - fn get(&mut self, r: ObjRef) -> Option { - let stream = self.entries.get(&r)?.clone(); - self.hits += 1; - Some(stream) - } - - fn insert(&mut self, r: ObjRef, stream: &Stream) { - self.builds += 1; - if self.entries.len() >= 1_000_000 { - self.clear(); - } - if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES { - self.bytes += stream.raw.len(); - self.entries.insert(r, stream.clone()); - } - // Else over budget: keep serving the entries already cached instead of growing. - } - - /// Cache hits so far (reused appearances); for tests. - pub fn hits(&self) -> u64 { - self.hits - } - - /// Appearances built so far; for tests. - pub fn builds(&self) -> u64 { - self.builds - } -} - -/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one -/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no -/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes, -/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file -/// as it was. Links, form fields and pop-ups keep their own handling. -pub fn with_missing_appearances(doc: &Document) -> Option { - with_missing_appearances_cached(doc, &mut AppearanceCache::default()) -} - -/// A copy of `doc` with [`AppearanceCache`] reuse across refreshes: annotations whose -/// dictionary is unchanged since the cached build keep their stored appearance instead of -/// redrawing it. See [`AppearanceCache`] for when entries stay valid. -pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option { - let mut copy: Option = None; - for page in page_refs(doc).ok()? { - for r in annots(doc, page).iter().filter_map(Object::as_ref) { - let d = annot_dict(doc, r); - if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) { - continue; - } - let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))); - if has_normal { - continue; - } - if let Some(stream) = cache.get(r) { - // Fails only when `r` isn't a dictionary, which the cached build ruled out. - apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); - continue; - } - let Some(stream) = build_appearance(doc, &d) else { continue }; - cache.insert(r, &stream); - // Fails only when `r` isn't a dictionary, which the build just ruled out. - apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); - } - } - copy -} - -/// Format a number for content streams and DA strings. -pub(crate) fn n(v: f64) -> String { - let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -// ── edits ─────────────────────────────────────────────────────────────────────────────────── - -/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its -/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing. -fn rich_text(d: &mut Dict) { - let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default(); - let (col, size) = appearance::parse_da(d); - let hex = format!( - "#{:02X}{:02X}{:02X}", - (col[0].clamp(0.0, 1.0) * 255.0).round() as u8, - (col[1].clamp(0.0, 1.0) * 255.0).round() as u8, - (col[2].clamp(0.0, 1.0) * 255.0).round() as u8 - ); - let align = match d.get(b"Q").and_then(Object::as_int) { - Some(1) => "center", - Some(2) => "right", - _ => "left", - }; - let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """); - let body: String = text.split('\n').map(|line| format!("

{}

", esc(line.trim_end_matches('\r')))).collect(); - let style = format!( - "font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal", - n(size) - ); - let rc = format!( - "{body}" - ); - d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size)))); - d.set(b"RC".to_vec(), PdfString::text(&rc)); -} - -fn touch(d: &mut Dict, meta: &Meta) { - if let Some(date) = &meta.date { - d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); - } -} - -/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does. -pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { - let p = page_ref(doc, page)?; - let list = annots(doc, p); - let target = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; - if let Some(r) = target.as_ref() { - unlocked(doc, r)?; - } - let mut doomed: Vec = target.as_ref().into_iter().collect(); - // Replies (`/IRT`) and pop-ups (`/Parent`) of anything doomed, to a fixpoint. - loop { - let before = doomed.len(); - for e in &list { - let Some(r) = e.as_ref() else { continue }; - if doomed.contains(&r) { - continue; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| doomed.contains(&t)); - if points_at(b"IRT") || points_at(b"Parent") { - doomed.push(r); - } - } - for r in doomed.clone() { - if let Some(pop) = doc.get(r).as_dict().and_then(|d| d.reference(b"Popup")) - && !doomed.contains(&pop) - { - doomed.push(pop); - } - } - if doomed.len() == before { - break; - } - } - let kept: Vec = - list.into_iter().enumerate().filter(|(i, e)| *i != index && !e.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(_, e)| e).collect(); - set_annots(doc, p, kept) -} - -/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the -/// new text: the wrap width and top edge stay, the height fits the wrapped lines. -pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText"); - doc.update_dict(r, |d| { - d.set(b"Contents".to_vec(), PdfString::text(text)); - // The rich-text version follows the new plain text (text boxes), or would contradict it. - if free_text { - rich_text(d); - } else { - d.remove(b"RC"); - } - touch(d, meta); - })?; - if free_text { - // The box grows and shrinks with the text instead of clipping it. A locked box keeps its - // text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even - // where the standard fonts can't draw some of it (marked and reported, see - // `draw_appearance`). - match fitted_box(doc, r, text) { - Some(rect) => apply_text_box(doc, r, rect, meta, true)?, - None => draw_appearance(doc, r)?, - } - } - Ok(()) -} - -/// The rectangle a FreeText annotation's text box needs for `text`: the current wrap width and -/// top edge stay and the height fits the wrapped lines, as at creation. `None` keeps the old -/// rectangle (the box can't be measured, so only the appearance is redrawn). -fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { - let d = annot_dict(doc, r); - let nums = |key: &[u8]| -> Option> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() }; - let (_, size) = appearance::parse_da(&d); - let pad = 2.0 + border_width_of(&d); - let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?; - let rect = [rect[0], rect[1], rect[2], rect[3]]; - // A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place. - let tb = if d.contains(b"CL") { - let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?; - [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]] - } else { - rect - }; - let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3])); - let w = tb[2] - tb[0]; - let w = if w.is_finite() && w >= 1.0 { - w - } else { - // No usable old width: fall back to the creation-time width (at most 300 pt). - let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); - (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0) - }; - let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64; - Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top]) -} - -/// Reply to a comment; returns the reply's index in the page's `/Annots`. -/// -/// A reply is a text annotation with `/IRT` pointing at its parent (§12.5.6.2). It gets the -/// parent's rectangle and an empty appearance, so it shows in comment lists but never paints a -/// second icon on the page. -pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, author: &str, meta: &Meta) -> Result { - reply(doc, page, index, text, author, meta, None) -} - -/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a -/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins. -pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result { - let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author }); - reply(doc, page, index, &text, author, meta, Some(state)) -} - -fn reply( - doc: &mut Document, - page: usize, - index: usize, - text: &str, - author: &str, - meta: &Meta, - state: Option, -) -> Result { - let (p, parent) = annot_ref(doc, page, index)?; - let pd = annot_dict(doc, parent); - if pd.name(b"Subtype") == Some(b"Popup") { - return Err(AnnotError::Invalid("pop-ups can't be replied to".into())); - } - let rect = pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0, 0.0, 0.0, 0.0])); - let mut d = base_dict("Text", [0.0; 4], p, text, author, meta); - d.set(b"Rect".to_vec(), rect); - d.set(b"IRT".to_vec(), Object::Ref(parent)); - d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - d.set(b"Name".to_vec(), Object::name("Comment")); - if let Some(c) = pd.get(b"C") { - d.set(b"C".to_vec(), c.clone()); - } - if let Some(s) = state { - d.set(b"State".to_vec(), PdfString::text(s.name())); - d.set(b"StateModel".to_vec(), PdfString::text("Review")); - d.set(b"Subj".to_vec(), PdfString::text("Status")); - } - // An empty form: nothing is drawn for the reply itself. - let mut fd = Dict::new(); - fd.set(b"Type".to_vec(), Object::name("XObject")); - fd.set(b"Subtype".to_vec(), Object::name("Form")); - fd.set(b"BBox".to_vec(), num_array(&[0.0, 0.0, 0.0, 0.0])); - let ap = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(fd, Vec::new()))); - let mut apd = Dict::new(); - apd.set(b"N".to_vec(), Object::Ref(ap)); - d.set(b"AP".to_vec(), Object::Dict(apd)); - let r = doc.add(Object::Dict(d)); - let mut list = annots(doc, p); - list.push(Object::Ref(r)); - let i = list.len() - 1; - set_annots(doc, p, list)?; - Ok(i) -} - -fn flags(doc: &Document, r: ObjRef) -> i64 { - doc.get(r).as_dict().map(|d| annotation_flags(doc, d)).unwrap_or(0) -} - -/// Refuse to change a locked comment (Acrobat: Properties ▸ Locked). -fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> { - if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) } -} - -/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized, -/// restyled or deleted; its text and replies stay editable, as in Acrobat. -pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - let f = if locked { flags(doc, r) | FLAG_LOCKED } else { flags(doc, r) & !FLAG_LOCKED }; - doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(f)))?; - Ok(()) -} - -/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with -/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status. -pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result { - let (p, parent) = annot_ref(doc, page, index)?; - let pd = annot_dict(doc, parent); - if pd.name(b"Subtype") == Some(b"Popup") { - return Err(AnnotError::Invalid("pop-ups can't be marked".into())); - } - let state = if marked { "Marked" } else { "Unmarked" }; - let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author }); - let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta); - d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4]))); - d.set(b"IRT".to_vec(), Object::Ref(parent)); - d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - d.set(b"State".to_vec(), PdfString::text(state)); - d.set(b"StateModel".to_vec(), PdfString::text("Marked")); - let r = doc.add(Object::Dict(d)); - let mut list = annots(doc, p); - list.push(Object::Ref(r)); - let i = list.len() - 1; - set_annots(doc, p, list)?; - Ok(i) -} - -/// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`. -pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> { - if !finite(&[dx, dy]) { - return Err(AnnotError::Invalid("invalid offset".into())); - } - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let shift = |o: &Object, every: bool| -> Option { - let a = o.as_array()?; - Some(Object::Array( - a.iter() - .enumerate() - .map(|(i, v)| match v.as_f64() { - Some(x) if every || i < 4 => Object::Real(x + if i % 2 == 0 { dx } else { dy }), - _ => v.clone(), - }) - .collect(), - )) - }; - let popup = annot_dict(doc, r).reference(b"Popup"); - doc.update_dict(r, |d| { - for (k, every) in [(&b"Rect"[..], false), (b"QuadPoints", true), (b"L", false), (b"CL", true), (b"Vertices", true)] { - if let Some(v) = d.get(k).and_then(|o| shift(o, every)) { - d.set(k.to_vec(), v); - } - } - if let Some(Object::Array(list)) = d.get(b"InkList").cloned() { - let moved = list.iter().map(|s| shift(s, true).unwrap_or_else(|| s.clone())).collect(); - d.set(b"InkList".to_vec(), Object::Array(moved)); - } - touch(d, meta); - })?; - if let Some(p) = popup - && doc.get(p).as_dict().is_some() - { - doc.update_dict(p, |d| { - if let Some(v) = d.get(b"Rect").and_then(|o| shift(o, false)) { - d.set(b"Rect".to_vec(), v); - } - })?; - } - Ok(()) -} - -/// Resize a rectangle, oval, text box, stamp or drawing to `rect`. Stamps keep their appearance, -/// which PDF viewers scale from its bounding box into the new rectangle; drawings scale their -/// strokes and get a new one. -pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let d = annot_dict(doc, r); - let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - if subtype == "Stamp" { - let rect = normalize(rect); - if !finite(&rect) || rect[2] <= rect[0] || rect[3] <= rect[1] { - return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); - } - doc.update_dict(r, |d| { - d.set(b"Rect".to_vec(), num_array(&rect)); - touch(d, meta); - })?; - return Ok(()); - } - if subtype == "Ink" { - return resize_ink(doc, r, normalize(rect), meta); - } - if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") { - return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized"))); - } - apply_text_box(doc, r, rect, meta, false) -} - -/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half -/// the line width and a little more) stays the same on each side, so the line isn't clipped. -fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { - let d = annot_dict(doc, r); - let strokes: Vec> = d - .get(b"InkList") - .map(|l| doc.resolve(l)) - .and_then(|l| l.as_array().cloned()) - .unwrap_or_default() - .iter() - .map(|s| { - let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); - v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() - }) - .collect(); - let Some(inner) = bounds(strokes.iter().flatten().copied()) else { - return Err(AnnotError::Invalid("the drawing has no strokes".into())); - }; - let old = d.get(b"Rect").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let old: Vec = old.iter().filter_map(|v| doc.resolve(v).as_f64()).collect(); - let old = match old[..] { - [a, b, c, e] => normalize([a, b, c, e]), - _ => inner, - }; - let margin = [inner[0] - old[0], inner[1] - old[1], old[2] - inner[2], old[3] - inner[3]].map(|m| m.max(0.0)); - let target = [rect[0] + margin[0], rect[1] + margin[1], rect[2] - margin[2], rect[3] - margin[3]]; - if !finite(&rect) || target[2] < target[0] || target[3] < target[1] || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); - } - // An axis with no extent (a straight stroke) is centred rather than scaled. - let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| if a1 > a0 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { (b0 + b1) / 2.0 }; - let scaled: Vec> = strokes - .iter() - .map(|s| { - s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect() - }) - .collect(); - let before = annot_dict(doc, r); - doc.update_dict(r, |d| { - d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect())); - d.set(b"Rect".to_vec(), num_array(&rect)); - touch(d, meta); - })?; - redraw_or_restore(doc, r, before) -} - -/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's -/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn -/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`). -fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> { - let rect = normalize(rect); - if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); - } - let d = annot_dict(doc, r); - if !new_text { - refuse_foreign_text(doc, &d)?; - } - // A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it. - let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::>()).filter(|l| l.len() == 6); - let (outer, cl) = match callout { - Some(mut l) => { - let attach = callout_attach(rect, [l[2], l[3]]); - (l[4], l[5]) = (attach[0], attach[1]); - let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0; - let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad); - ([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l)) - } - None => (rect, None), - }; - doc.update_dict(r, |d| { - d.set(b"Rect".to_vec(), num_array(&outer)); - match &cl { - Some(l) => { - d.set(b"CL".to_vec(), num_array(l)); - d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]])); - } - None => { - d.remove(b"RD"); - } - } - touch(d, meta); - })?; - let before = d; - if new_text { - if let Err(e) = draw_appearance(doc, r) { - doc.set(r, Object::Dict(before)); - return Err(e); - } - Ok(()) - } else { - redraw_or_restore(doc, r, before) - } -} - -fn border_width_of(d: &Dict) -> f64 { - d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0) -} - -/// Change a comment's colour, opacity, line width and/or line endings, and redraw it. -#[allow(clippy::too_many_arguments)] -pub fn set_style( - doc: &mut Document, - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - endings: Option<&[LineEnding]>, - meta: &Meta, -) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let d = annot_dict(doc, r); - let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - // Check before changing anything: a stale appearance would contradict the new style. - if appearance::build(&d).is_none() { - return Err(AnnotError::Unsupported(subtype.clone())); - } - if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { - return Err(AnnotError::Invalid("invalid style".into())); - } - if let Some(ends) = endings { - let callout = subtype == "FreeText" && d.contains(b"CL"); - let pair = matches!(subtype.as_str(), "Line" | "PolyLine"); - if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) { - return Err(AnnotError::Invalid("this comment has no line endings to change".into())); - } - } - refuse_foreign_text(doc, &d)?; - let free_text = subtype == "FreeText"; - let before = d; - doc.update_dict(r, |d| { - if let Some(c) = color { - if free_text { - let (_, size) = appearance::parse_da(d); - let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0)); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); - rich_text(d); - } else { - d.set(b"C".to_vec(), rgb(c)); - } - } - if let Some(o) = opacity { - let o = o.clamp(0.0, 1.0); - if o < 1.0 { - d.set(b"CA".to_vec(), Object::Real(o)); - } else { - d.remove(b"CA"); - } - } - if let Some(w) = width { - let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default(); - bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); - d.set(b"BS".to_vec(), Object::Dict(bs)); - d.remove(b"Border"); - } - if let Some(ends) = endings { - if ends.len() == 1 { - if let Some(ending) = ends.first() { - d.set(b"LE".to_vec(), Object::name(ending.name())); - } - } else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) { - d.set(b"LE".to_vec(), ending_pair(*start, *end)); - } - } - ensure_ending_room(d); - touch(d, meta); - })?; - redraw_or_restore(doc, r, before) -} - -/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the -/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new -/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt. -pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, meta: &Meta) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let d = annot_dict(doc, r); - if d.name(b"Subtype") != Some(b"FreeText") { - return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a font size".into())); - } - if !size.is_finite() || !(1.0..=400.0).contains(&size) { - return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into())); - } - refuse_foreign_text(doc, &d)?; - let before = d; - doc.update_dict(r, |d| { - let (col, _) = appearance::parse_da(d); - d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes())); - rich_text(d); - touch(d, meta); - })?; - let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default(); - let result = match fitted_box(doc, r, &text) { - Some(fit) => { - let cur = annot_dict(doc, r); - let nums: Vec = - cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); - // Keep the top and sides; only the bottom edge moves, and only downwards. - match four(&nums).map(normalize) { - Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false), - _ => apply_text_box(doc, r, fit, meta, false), - } - } - None => redraw_or_restore(doc, r, before.clone()), - }; - if result.is_err() { - doc.set(r, Object::Dict(before)); - } - result -} - -/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles, -/// ovals, polygons and clouds, §12.5.6.8–9). -pub fn fillable(subtype: &str) -> bool { - matches!(subtype, "Square" | "Circle" | "Polygon") -} - -/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and -/// redraw its appearance (Acrobat: Properties ▸ Fill Color). -pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option, meta: &Meta) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let d = annot_dict(doc, r); - let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - if !fillable(&subtype) { - return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into())); - } - // Check before changing anything: a stale appearance would contradict the new fill. - if appearance::build(&d).is_none() { - return Err(AnnotError::Unsupported(subtype)); - } - if fill.is_some_and(|c| !finite(&c)) { - return Err(AnnotError::Invalid("invalid fill colour".into())); - } - refuse_foreign_text(doc, &d)?; - let before = d; - doc.update_dict(r, |d| { - match fill { - Some(c) => d.set(b"IC".to_vec(), rgb(c)), - None => { - d.remove(b"IC"); - } - } - touch(d, meta); - })?; - redraw_or_restore(doc, r, before) -} - -/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent. -fn ensure_ending_room(d: &mut Dict) { - let subtype = d.name(b"Subtype").unwrap_or_default(); - if !matches!(subtype, b"Line" | b"PolyLine") { - return; - } - if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) { - return; - } - let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" }; - let Some(pts) = pair_points(d, key) else { return }; - let Some(bounds) = bounds(pts.iter().copied()) else { return }; - let w = border_width_of(d); - let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0); - let vals: Vec = d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); - let Some(rect) = four(&vals) else { return }; - let union = [rect[0].min(need[0]), rect[1].min(need[1]), rect[2].max(need[2]), rect[3].max(need[3])]; - if union != rect { - d.set(b"Rect".to_vec(), num_array(&union)); - } -} - -fn four(v: &[f64]) -> Option<[f64; 4]> { - Some([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?]) -} - -fn pair_points(d: &Dict, key: &[u8]) -> Option> { - let v: Vec = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); - if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) { - return None; - } - Some(v.as_chunks::<2>().0.to_vec()) -} - -// ── reading ───────────────────────────────────────────────────────────────────────────────── - -/// One comment as the viewer lists it (the same fields and rules as -/// `pdfcraft_render::Annotation`), read straight from the object graph. The engine uses it to -/// refresh the comment list after a comment edit without re-inspecting the whole document. -#[derive(Clone, Debug, PartialEq)] -pub struct Summary { - pub page: usize, - pub index: usize, - pub subtype: String, - pub author: Option, - pub contents: Option, - /// `/M` as written (a PDF date string). - pub modified: Option, - pub name: Option, - pub in_reply_to: Option, - pub rect: [f32; 4], - pub color: Option<[f32; 3]>, - pub state: Option, - pub quads: Vec<[f32; 8]>, - pub locked: bool, - pub intent: Option, - /// A Fill & Sign mark ([`is_fill_sign`]). - pub fill_sign: bool, -} - -fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option { - let o = doc.resolve(d.get(key)?); - let s = match &*o { - Object::String(s) => s.to_text(), - Object::Name(n) => String::from_utf8_lossy(n).into_owned(), - _ => return None, - }; - let s = s.trim_matches('\0').trim().to_string(); - (!s.is_empty()).then_some(s) -} - -/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup -/// multimedia and print-production annotations (ISO 32000-2 §12.5.6: Screen, Movie, RichMedia, -/// 3D, PrinterMark, TrapNet, Watermark) are not. -pub fn is_comment_subtype(subtype: &str) -> bool { - !matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") -} - -/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. -pub fn summaries(doc: &Document) -> Vec { - let mut out = Vec::new(); - let Ok(pages) = page_refs(doc) else { return out }; - for (page, p) in pages.iter().enumerate() { - for (index, entry) in annots(doc, *p).iter().enumerate() { - let obj = doc.resolve(entry); - let Some(d) = obj.as_dict() else { continue }; - let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; - if !is_comment_subtype(&subtype) { - continue; - } - let nums = |k: &[u8]| -> Vec { - d.get(k) - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0) as f32).collect())) - .unwrap_or_default() - }; - let r = nums(b"Rect"); - let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] }; - let c = nums(b"C"); - let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]); - if subtype == "FreeText" - && let Some(da) = text_value(doc, d, b"DA") - { - let t: Vec<&str> = da.split_whitespace().collect(); - if let Some(i) = t.iter().position(|x| *x == "rg") - && i >= 3 - { - let f = |k: usize| t[k].parse::().unwrap_or(0.0); - color = Some([f(i - 3), f(i - 2), f(i - 1)]); - } - } - let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec(); - let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); - out.push(Summary { - page, - index, - subtype, - author: text_value(doc, d, b"T"), - // Acrobat ends lines with CR, which the interface would draw as nothing (keep in - // step with `pdfcraft_render::inspect`). - contents: text_value(doc, d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), - modified: text_value(doc, d, b"M"), - name: text_value(doc, d, b"NM"), - in_reply_to, - rect, - color, - state: text_value(doc, d, b"State"), - quads, - locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, - intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), - fill_sign: is_fill_sign(doc, d), - }); - } - } - out.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); - out -} - -/// Change a comment's author (`/T`), subject (`/Subj`) and, for notes, icon (`/Name`, redrawn). -/// `None` leaves a value as it is. -pub fn set_info( - doc: &mut Document, - page: usize, - index: usize, - author: Option<&str>, - subject: Option<&str>, - icon: Option, - meta: &Meta, -) -> Result<(), AnnotError> { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let is_note = annot_dict(doc, r).name(b"Subtype") == Some(b"Text"); - if icon.is_some() && !is_note { - return Err(AnnotError::Invalid("only sticky notes have an icon".into())); - } - doc.update_dict(r, |d| { - if let Some(a) = author { - d.set(b"T".to_vec(), PdfString::text(a)); - } - if let Some(s) = subject { - d.set(b"Subj".to_vec(), PdfString::text(s)); - } - if let Some(i) = icon { - d.set(b"Name".to_vec(), Object::name(i.name())); - } - touch(d, meta); - })?; - if icon.is_some() { - set_appearance(doc, r)?; - } - Ok(()) -} - -/// What Comment properties shows for one comment. -#[derive(Clone, Debug, PartialEq)] -pub struct Props { - pub subtype: String, - pub author: String, - pub subject: String, - pub color: Option, - pub opacity: f64, - /// Border / line width, for shapes, lines and drawings. - pub width: Option, - /// Notes only. - pub icon: Option, - /// `/M`, as written. - pub modified: Option, - /// The appearance can be redrawn (so colour, opacity and width can change). - pub restylable: bool, - /// The Locked flag. - pub locked: bool, - /// `/LE`: two names for a line or polyline (`None` when unset), one for a callout. - pub endings: Option>, - /// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]). - pub fillable: bool, - /// `/IC`, the interior colour (`None`: no fill). - pub fill: Option, - /// Text boxes, typewriter text and callouts: the `/DA` font size in points. - pub font_size: Option, -} - -/// The current properties of the comment at `(page, index)`. -pub fn props(doc: &Document, page: usize, index: usize) -> Option { - let p = page_ref(doc, page).ok()?; - let entry = annots(doc, p).get(index).cloned()?; - let obj = doc.resolve(&entry); - let d = obj.as_dict()?; - let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); - let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); - let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; - let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine") - .then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)); - Some(Props { - author: text_value(doc, d, b"T").unwrap_or_default(), - subject: text_value(doc, d, b"Subj").unwrap_or_default(), - color, - opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0), - width, - icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)), - modified: text_value(doc, d, b"M"), - restylable: appearance::build(d).is_some(), - locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, - fillable: fillable(&subtype), - fill: fill_of(d), - font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), - subtype, - endings: line_endings_of(d), - }) -} - -/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted. -fn fill_of(d: &Dict) -> Option { - let c: Vec = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); - let rgb = match c.as_slice() { - [g] => [*g; 3], - [r, g, b] => [*r, *g, *b], - [c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)], - _ => return None, - }; - finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0))) -} - -/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden. -fn line_endings_of(d: &Dict) -> Option> { - let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse); - match d.name(b"Subtype")? { - b"Line" | b"PolyLine" => match d.get(b"LE") { - None => Some(vec![LineEnding::None, LineEnding::None]), - Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(), - }, - b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]), - _ => None, - } -} - -/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the -/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they -/// move, list and delete as one. Returns the strikeout's index. -/// -/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies -/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that -/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id -/// from the caller, so the edit stays deterministic. -#[allow(clippy::too_many_arguments)] -pub fn add_text_replacement( - doc: &mut Document, - page: usize, - quads: &[[f64; 8]], - replacement: &str, - author: &str, - strike: &Style, - caret: &Style, - meta: &Meta, - caret_meta: &Meta, -) -> Result { - let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; - let s = add_annotation( - doc, - &NewAnnotation { - page, - shape: Shape::TextMarkup { kind: Markup::StrikeOut, quads: quads.to_vec() }, - style: strike.clone(), - contents: String::new(), - author: author.into(), - }, - meta, - )?; - let (_, sr) = annot_ref(doc, page, s)?; - doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?; - // The caret sits on the baseline at the end of the last line, its point at the text. - let xs = [last[0], last[2], last[4], last[6]]; - let ys = [last[1], last[3], last[5], last[7]]; - let x = xs.iter().copied().fold(f64::MIN, f64::max); - let (y0, y1) = (ys.iter().copied().fold(f64::MAX, f64::min), ys.iter().copied().fold(f64::MIN, f64::max)); - let h = ((y1 - y0) * 0.6).clamp(4.0, 24.0); - let rect = [x - h / 2.0, y0 - h * 0.6, x + h / 2.0, y0 + h * 0.4]; - let c = add_annotation( - doc, - &NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() }, - caret_meta, - )?; - let (_, cr) = annot_ref(doc, page, c)?; - doc.update_dict(cr, |d| { - d.set(b"IRT".to_vec(), Object::Ref(sr)); - d.set(b"RT".to_vec(), Object::name("Group")); - d.set(b"Subj".to_vec(), PdfString::text("Replace Text")); - })?; - Ok(s) -} - -/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points -/// of `path`, splitting strokes where they are cut. A drawing with nothing left is deleted. -/// Returns whether anything was erased. -pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]], radius: f64, meta: &Meta) -> Result { - let (_, r) = annot_ref(doc, page, index)?; - unlocked(doc, r)?; - let d = annot_dict(doc, r); - if d.name(b"Subtype") != Some(b"Ink") { - return Err(AnnotError::Invalid("only drawings can be erased".into())); - } - if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 { - return Err(AnnotError::Invalid("invalid eraser path".into())); - } - let strokes: Vec> = d - .get(b"InkList") - .map(|l| doc.resolve(l)) - .and_then(|l| l.as_array().cloned()) - .unwrap_or_default() - .iter() - .map(|s| { - let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); - v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() - }) - .collect(); - // Distance from a point to the eraser's path (segments, or a single point). - let near = |p: [f64; 2]| -> bool { - if path.len() == 1 { - return (p[0] - path[0][0]).hypot(p[1] - path[0][1]) <= radius; - } - path.windows(2).any(|w| { - let (a, b) = (w[0], w[1]); - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - let len2 = dx * dx + dy * dy; - let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; - (p[0] - (a[0] + t * dx)).hypot(p[1] - (a[1] + t * dy)) <= radius - }) - }; - let mut kept: Vec> = Vec::new(); - let mut erased = false; - for s in &strokes { - // Densify so long segments are cut where the eraser crosses them. - let mut pts: Vec<[f64; 2]> = Vec::new(); - for w in s.windows(2) { - let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize; - for k in 0..n { - let t = k as f64 / n as f64; - pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]); - } - } - if let Some(last) = s.last() { - pts.push(*last); - } - let mut run: Vec<[f64; 2]> = Vec::new(); - for p in pts { - if near(p) { - erased = true; - if run.len() >= 2 { - kept.push(std::mem::take(&mut run)); - } - run.clear(); - } else { - run.push(p); - } - } - if run.len() >= 2 { - kept.push(run); - } - } - if !erased { - return Ok(false); - } - if kept.is_empty() { - delete_annotation(doc, page, index)?; - return Ok(true); - } - let width = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0); - let rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0); - doc.update_dict(r, |d| { - d.set(b"InkList".to_vec(), Object::Array(kept.iter().map(|s| num_array(&s.concat())).collect())); - d.set(b"Rect".to_vec(), num_array(&rect)); - touch(d, meta); - })?; - set_appearance(doc, r)?; - Ok(true) -} diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 4d3eef632b..7cf4328c7c 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -78,11 +78,15 @@ pub struct LinkItem { pub style: LinkStyle, } +/// Bytes of web-address text one listing keeps, counted after conversion to UTF-8; past it a link +/// lists as [`LinkAction::Other`]. +pub(crate) const MAX_URI_BYTES: usize = 16 << 20; + fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default() } -fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { +fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction { let dest_page = |dest: &Object| -> Option { let a = doc.resolve(dest); let first = a.as_array()?.first()?.clone(); @@ -97,9 +101,20 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction { } let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) }; match a.name(b"S") { - Some(b"URI") => LinkAction::Uri( - a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(), - ), + Some(b"URI") => { + let uri = a.get(b"URI").map(|u| doc.resolve(u)); + let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]); + // The raw length first, so an address already over the budget is never converted; then the + // converted text, which is up to three times longer where the bytes are not UTF-8. + let text = (bytes.len() <= *uri_budget).then(|| String::from_utf8_lossy(bytes)); + match text.and_then(|t| Some((uri_budget.checked_sub(t.len())?, t))) { + Some((left, text)) => { + *uri_budget = left; + LinkAction::Uri(text.into_owned()) + } + None => LinkAction::Other("web address too long".into()), + } + } Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page), Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()), None => LinkAction::Other("no action".into()), @@ -133,8 +148,16 @@ fn style_of(doc: &Document, d: &Dict) -> LinkStyle { pub fn list(doc: &Document) -> Vec { let Ok(pages) = page_refs(doc) else { return Vec::new() }; let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut uri_budget = MAX_URI_BYTES; for (pi, p) in pages.iter().enumerate() { for (k, a) in annots(doc, *p).iter().enumerate() { + // One annotation object listed under several entries or pages is one link. + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } let Some(d) = doc.resolve(a).as_dict().cloned() else { continue }; if d.name(b"Subtype") != Some(b"Link") { continue; @@ -148,7 +171,7 @@ pub fn list(doc: &Document) -> Vec { index: k, obj: a.as_ref(), rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])], - action: action_of(doc, &d, &pages), + action: action_of(doc, &d, &pages, &mut uri_budget), style: style_of(doc, &d), }); } @@ -232,6 +255,16 @@ fn checked_rect(rect: [f64; 4]) -> Result<[f64; 4], AnnotError> { /// Add a link over `rect` (user space). Returns its index in the page's `/Annots`. pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { let p = page_ref(doc, page)?; + let obj = new_link(doc, p, rect, action, style)?; + let mut list = annots(doc, p); + list.push(Object::Ref(obj)); + let index = list.len() - 1; + set_annots(doc, p, list)?; + Ok(index) +} + +/// A link annotation object for page `p`, not yet on the page's `/Annots`. +fn new_link(doc: &mut Document, p: ObjRef, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result { let r = checked_rect(rect)?; if matches!(action, LinkAction::Other(_)) { return Err(AnnotError::Invalid("choose a page or a web address".into())); @@ -244,12 +277,7 @@ pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, d.set(b"F".to_vec(), Object::Int(4)); write_style(&mut d, style); write_action(doc, &mut d, action)?; - let obj = doc.add(Object::Dict(d)); - let mut list = annots(doc, p); - list.push(Object::Ref(obj)); - let index = list.len() - 1; - set_annots(doc, p, list)?; - Ok(index) + Ok(doc.add(Object::Dict(d))) } fn link_at(doc: &Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { @@ -292,7 +320,7 @@ pub fn delete(doc: &mut Document, page: usize, index: usize) -> Result<(), Annot let (p, _) = link_at(doc, page, index)?; let mut list = annots(doc, p); list.remove(index); - set_annots(doc, p, list) + Ok(set_annots(doc, p, list)?) } /// Remove every link on `pages` (all when `None`). Returns how many went. @@ -414,12 +442,40 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { /// Create links for found URLs: `(page, quads, uri)` with quads in user space (one link per /// line rectangle). Returns how many links were added. pub fn add_many(doc: &mut Document, links: &[(usize, Vec<[f64; 4]>, String)], style: &LinkStyle) -> Result { + let pages = page_refs(doc)?; + // Check the whole batch first, so a bad entry fails it before any link object is created. + for (page, rects, uri) in links { + pages.get(*page).ok_or(AnnotError::NoSuchPage(*page))?; + if uri.trim().is_empty() { + return Err(AnnotError::Invalid("type the web address first".into())); + } + for r in rects { + checked_rect(*r)?; + } + } + // Each page's `/Annots` is extended once, with its new links in the order given. + let mut added: Vec<(ObjRef, Vec)> = Vec::new(); + let mut slot: std::collections::HashMap = std::collections::HashMap::new(); let mut n = 0; for (page, rects, uri) in links { + let p = pages.get(*page).copied().ok_or(AnnotError::NoSuchPage(*page))?; + let action = LinkAction::Uri(uri.clone()); for r in rects { - add(doc, *page, *r, &LinkAction::Uri(uri.clone()), style)?; + let obj = new_link(doc, p, *r, &action, style)?; + let k = *slot.entry(p).or_insert_with(|| { + added.push((p, Vec::new())); + added.len() - 1 + }); + if let Some((_, list)) = added.get_mut(k) { + list.push(Object::Ref(obj)); + } n += 1; } } + for (p, new) in added { + let mut list = annots(doc, p); + list.extend(new); + set_annots(doc, p, list)?; + } Ok(n) } diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs new file mode 100644 index 0000000000..c7a0fd9831 --- /dev/null +++ b/crates/annot/src/lookup.rs @@ -0,0 +1,260 @@ +use super::info::text_value; +use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape}; +use super::{AnnotError, Dict, Document, ObjRef, Object, appearance}; + +// ── pages and /Annots ─────────────────────────────────────────────────────────────────────── + +/// The page object of each leaf page, in order. +pub fn page_refs(doc: &Document) -> Result, AnnotError> { + let root = doc.root().ok_or(AnnotError::NoPageTree)?; + let pages = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).ok_or(AnnotError::NoPageTree)?; + Ok(PageWalk::new(doc, pages).collect()) +} + +/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, +/// never one entry per child, so a child listed many times or shared between nodes is skipped by +/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). +/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once, +/// and at most a million nodes and four million `/Kids` entries are read. +pub(crate) struct PageWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + pub(crate) pending: Vec<(std::sync::Arc, usize)>, + /// `/Kids` entries read so far. + pub(crate) examined: usize, +} + +impl<'a> PageWalk<'a> { + pub(crate) fn new(doc: &'a Document, top: ObjRef) -> Self { + let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); + Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 } + } +} + +impl Iterator for PageWalk<'_> { + type Item = ObjRef; + + fn next(&mut self) -> Option { + while let Some((kids, next)) = self.pending.last_mut() { + let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { + self.pending.pop(); + continue; + }; + let node = kid.as_ref(); + *next += 1; + self.examined += 1; + let Some(node) = node else { continue }; + if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 { + self.pending.clear(); + return None; + } + if !self.seen.insert(node) { + continue; + } + let obj = self.doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + match d.get(b"Kids") { + // A `/Kids` array object shared by several nodes is read under the first: under + // the others it would only list nodes already seen. + Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => { + if self.seen.insert(*array) { + self.pending.push((self.doc.get(*array), 0)); + } + } + Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)), + _ => return Some(node), + } + } + None + } +} + +pub(crate) fn page_ref(doc: &Document, page: usize) -> Result { + page_refs(doc)?.get(page).copied().ok_or(AnnotError::NoSuchPage(page)) +} + +/// The entries of a page's `/Annots` (references or inline dictionaries). +pub(crate) fn annots(doc: &Document, page: ObjRef) -> Vec { + let obj = doc.get(page); + let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() }; + let list = doc.resolve(a).as_array().cloned().unwrap_or_default(); + #[cfg(test)] + ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len())); + list +} + +#[cfg(test)] +thread_local! { + /// Entries copied out of pages' `/Annots` arrays. + pub(crate) static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty +/// list removes a direct `/Annots`. +pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), pdfcraft_cos::CosError> { + let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots")); + match existing { + Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)), + _ => doc.update_dict(page, |d| { + if list.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(list)); + } + })?, + } + Ok(()) +} + +/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted). +pub(crate) fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> { + let p = page_ref(doc, page)?; + let mut list = annots(doc, p); + let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let r = match entry { + Object::Ref(r) if doc.get(r).as_dict().is_some() => r, + Object::Dict(d) => { + let r = doc.add(Object::Dict(d)); + list[index] = Object::Ref(r); + set_annots(doc, p, list)?; + r + } + _ => return Err(AnnotError::NoSuchAnnotation { page, index }), + }; + Ok((p, r)) +} + +pub(crate) fn annot_dict(doc: &Document, r: ObjRef) -> Dict { + doc.get(r).as_dict().cloned().unwrap_or_default() +} + +/// A Fill & Sign item: typed text, a check/cross/dot/line, or a typed, drawn or image signature. +/// +/// New items carry `/PCFillSign`. Older files are recognised from the dictionaries PdfCraft +/// already writes (`/IT /FreeTextTypeWriter`, stamp `/Name`, drawn ink whose subject is +/// "Signature"). +pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { + if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { + return true; + } + let subtype = d.name(b"Subtype").unwrap_or_default(); + if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") { + return true; + } + if subtype == b"Stamp" { + if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) { + return true; + } + if matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) + && matches!(d.get(b"PCPictureImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) + { + return true; + } + } + subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature") +} + +pub(crate) fn annotation_flags(doc: &Document, d: &Dict) -> i64 { + d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) +} + +/// Whether the renderer keeps the annotation upright on a turned page: the NoRotate flag, with +/// `/F` read as the renderer reads it (a real truncated, a value that isn't a `u32` no flags). +pub(crate) fn drawn_upright(doc: &Document, d: &Dict) -> bool { + let f = match d.get(b"F").map(|f| doc.resolve(f)).as_deref() { + Some(Object::Int(i)) => *i, + Some(Object::Real(r)) => *r as i64, + _ => 0, + }; + u32::try_from(f).is_ok_and(|f| i64::from(f) & FLAG_NO_ROTATE != 0) +} + +/// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden). +pub fn has_visible_fill_sign(doc: &Document) -> bool { + let Ok(pages) = page_refs(doc) else { return false }; + for page in pages { + for entry in annots(doc, page) { + let obj = doc.resolve(&entry); + let Some(d) = obj.as_dict() else { continue }; + let flags = annotation_flags(doc, d); + if flags & (FLAG_HIDDEN | FLAG_NO_VIEW) != 0 { + continue; + } + if is_fill_sign(doc, d) { + return true; + } + } + } + false +} + +pub(crate) fn fill_sign_shape(shape: &Shape) -> bool { + match shape { + Shape::Typewriter { .. } | Shape::Mark { .. } | Shape::Signature { .. } | Shape::TypedSignature { .. } => true, + Shape::CustomStamp { name, image: true, .. } => name == "Signature" || name == "Initials", + _ => false, + } +} + +/// The embedded image of a Fill & Sign image signature or initials (0-based target). +/// Other stamps have appearances that can't be represented by this image alone. +pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result, AnnotError> { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let obj = doc.resolve(entry); + let Some(d) = obj.as_dict() else { return Ok(None) }; + Ok((d.name(b"Subtype") == Some(b"Stamp") + && matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials")) + && matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)))) + .then(|| d.reference(b"PCPicture")) + .flatten()) +} + +/// The page `/Rotate` the picture of the image signature at `(page, index)` is drawn turned back +/// by, so it reads upright on a page shown that way: 0 for one added to an unturned page, or by +/// another app. The picture appears turned by the page's current rotation less this. +pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn)) +} + +/// Leave page `page` of (a copy of) a document showing only its annotation at `index`, without +/// the page's content or other annotations, so a render draws that annotation alone: to lay it +/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no +/// normal appearance (`/AP /N`) for that render to draw. +pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result { + isolate_annotations(doc, page, &[index]) +} + +/// [`isolate_annotation`] for several annotations of one page: those of them with a normal +/// appearance stay, in their order on the page. `false`, with `doc` unchanged, when none has one. +/// An index that isn't one of the page's annotations is an error even when the others are, and +/// leaves `doc` unchanged too. +pub fn isolate_annotations(doc: &mut Document, page: usize, indices: &[usize]) -> Result { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let mut kept = Vec::new(); + for (index, entry) in list.iter().enumerate() { + if !indices.contains(&index) { + continue; + } + let obj = doc.resolve(entry); + if obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) { + kept.push(entry.clone()); + } + } + if let Some(&index) = indices.iter().find(|&&i| i >= list.len()) { + return Err(AnnotError::NoSuchAnnotation { page, index }); + } + if kept.is_empty() { + return Ok(false); + } + set_annots(doc, p, kept)?; + doc.update_dict(p, |d| { + d.remove(b"Contents"); + })?; + Ok(true) +} diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs new file mode 100644 index 0000000000..2e3ae0b7d0 --- /dev/null +++ b/crates/annot/src/modify.rs @@ -0,0 +1,840 @@ +use super::create::{ + add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb, +}; +use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; +use super::info::{line_endings_of, text_value}; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; +use super::types::{ + FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, + StyleChange, TextReplacement, +}; +use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; + +/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would +/// replace those characters with `?`, so the edit is refused before the dictionary changes. +pub(crate) fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotError> { + let drawn_elsewhere = + d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(d).is_none(); + if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(d) { + return Err(AnnotError::Undrawable(format!( + "PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)", + c.escape_debug(), + u32::from(c) + ))); + } + Ok(()) +} + +/// Format a number for content streams and DA strings. +pub(crate) fn n(v: f64) -> String { + let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +// ── edits ─────────────────────────────────────────────────────────────────────────────────── + +/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its +/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing. +pub(crate) fn rich_text(d: &mut Dict) { + let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default(); + let (col, size) = appearance::parse_da(d); + let hex = format!( + "#{:02X}{:02X}{:02X}", + (col[0].clamp(0.0, 1.0) * 255.0).round() as u8, + (col[1].clamp(0.0, 1.0) * 255.0).round() as u8, + (col[2].clamp(0.0, 1.0) * 255.0).round() as u8 + ); + let align = match d.get(b"Q").and_then(Object::as_int) { + Some(1) => "center", + Some(2) => "right", + _ => "left", + }; + let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """); + let body: String = text.split('\n').map(|line| format!("

{}

", esc(line.trim_end_matches('\r')))).collect(); + let style = format!( + "font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal", + n(size) + ); + let rc = format!( + "{body}" + ); + d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size)))); + d.set(b"RC".to_vec(), PdfString::text(&rc)); +} + +pub(crate) fn touch(d: &mut Dict, meta: &Meta) { + if let Some(date) = &meta.date { + d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + } +} + +/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does. +/// Refused while it, or a reply that would go with it, is locked. +pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { + deletable(doc, page, index)?; + lift_annotation(doc, page, index) +} + +/// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without +/// changing anything: what a Delete command asks before it is offered. It is refused while the +/// annotation is locked, or a reply that would go with it is. +pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> { + let list = annots(doc, page_ref(doc, page)?); + let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + if let Some(r) = target.as_ref() { + unlocked(doc, r)?; + } + let doomed = with_replies_and_popups(doc, &list, seed(target)); + // A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it + // answers either. Pop-ups follow their comment. + for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) { + let obj = doc.resolve(e); + if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice())) + && annotation_flags(doc, d) & FLAG_LOCKED != 0 + { + return Err(AnnotError::Invalid("a reply to it is locked".into())); + } + } + Ok(()) +} + +/// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as +/// [`delete_annotation`] does but whatever their locks: for drawing the page beneath it on a +/// copy of the document (a stamp or signature being dragged), which changes nothing. +pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let doomed = with_replies_and_popups(doc, &list, seed(target)); + let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect(); + Ok(set_annots(doc, p, kept)?) +} + +/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline +/// comment (which has none to collect) the pop-up it names, which still goes. +fn seed(target: &Object) -> Option { + target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))) +} + +/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is +/// one of them, or it is written inline (a file can, though the standard asks for references) and +/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference +/// for the walk to collect, so it is matched here. +pub fn goes_with(entry: &Object, going: &HashSet) -> bool { + match entry.as_ref() { + Some(r) => going.contains(&r), + None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))), + } +} + +/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or +/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their +/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no +/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or +/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also +/// matches inline entries. +pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator) -> HashSet { + let mut hanging: HashMap> = HashMap::new(); + for e in list { + let obj = doc.resolve(e); + let Some(d) = obj.as_dict() else { continue }; + // What goes with the annotation this entry hangs off: the entry, or for an inline one the + // pop-up it names. + let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue }; + for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() { + hanging.entry(on).or_default().push(goes); + } + } + let mut going = HashSet::new(); + let mut todo: Vec = doomed.into_iter().collect(); + while let Some(r) = todo.pop() { + if !going.insert(r) { + continue; + } + todo.extend(hanging.get(&r).into_iter().flatten()); + todo.extend(doc.get(r).as_dict().and_then(|d| d.reference(b"Popup"))); + } + going +} + +/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the +/// new text: the wrap width and top edge stay, the height fits the wrapped lines. +pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText"); + doc.update_dict(r, |d| { + d.set(b"Contents".to_vec(), PdfString::text(text)); + // The rich-text version follows the new plain text (text boxes), or would contradict it. + if free_text { + rich_text(d); + } else { + d.remove(b"RC"); + } + touch(d, meta); + })?; + if free_text { + // The box grows and shrinks with the text instead of clipping it. A locked box keeps its + // text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even + // where the standard fonts can't draw some of it (marked and reported, see + // `draw_appearance`). + match fitted_box(doc, r, text) { + Some(rect) => apply_text_box(doc, r, rect, meta, true)?, + None => draw_appearance(doc, r)?, + } + } + Ok(()) +} + +/// The rectangle a FreeText annotation's text box needs for `text`: the current wrap width and +/// top edge stay and the height fits the wrapped lines, as at creation. `None` keeps the old +/// rectangle (the box can't be measured, so only the appearance is redrawn). +fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> { + let d = annot_dict(doc, r); + let nums = |key: &[u8]| -> Option> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() }; + let (_, size) = appearance::parse_da(&d); + let pad = 2.0 + border_width_of(&d); + let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?; + let rect = [rect[0], rect[1], rect[2], rect[3]]; + // A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place. + let tb = if d.contains(b"CL") { + let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?; + [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]] + } else { + rect + }; + let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3])); + let w = tb[2] - tb[0]; + let w = if w.is_finite() && w >= 1.0 { + w + } else { + // No usable old width: fall back to the creation-time width (at most 300 pt). + let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max); + (longest + 2.0 * pad + 4.0).clamp(40.0, 300.0) + }; + let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64; + Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top]) +} + +/// Reply to a comment; returns the reply's index in the page's `/Annots`. +/// +/// A reply is a text annotation with `/IRT` pointing at its parent (§12.5.6.2). It gets the +/// parent's rectangle and an empty appearance, so it shows in comment lists but never paints a +/// second icon on the page. +pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, author: &str, meta: &Meta) -> Result { + reply(doc, page, index, text, author, meta, None) +} + +/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a +/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins. +pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result { + let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author }); + reply(doc, page, index, &text, author, meta, Some(state)) +} + +fn reply( + doc: &mut Document, + page: usize, + index: usize, + text: &str, + author: &str, + meta: &Meta, + state: Option, +) -> Result { + let (p, parent) = annot_ref(doc, page, index)?; + let pd = annot_dict(doc, parent); + if pd.name(b"Subtype") == Some(b"Popup") { + return Err(AnnotError::Invalid("pop-ups can't be replied to".into())); + } + let rect = pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0, 0.0, 0.0, 0.0])); + let mut d = base_dict("Text", [0.0; 4], p, text, author, meta); + d.set(b"Rect".to_vec(), rect); + d.set(b"IRT".to_vec(), Object::Ref(parent)); + d.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"Name".to_vec(), Object::name("Comment")); + if let Some(c) = pd.get(b"C") { + d.set(b"C".to_vec(), c.clone()); + } + if let Some(s) = state { + d.set(b"State".to_vec(), PdfString::text(s.name())); + d.set(b"StateModel".to_vec(), PdfString::text("Review")); + d.set(b"Subj".to_vec(), PdfString::text("Status")); + } + // An empty form: nothing is drawn for the reply itself. + let mut fd = Dict::new(); + fd.set(b"Type".to_vec(), Object::name("XObject")); + fd.set(b"Subtype".to_vec(), Object::name("Form")); + fd.set(b"BBox".to_vec(), num_array(&[0.0, 0.0, 0.0, 0.0])); + let ap = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(fd, Vec::new()))); + let mut apd = Dict::new(); + apd.set(b"N".to_vec(), Object::Ref(ap)); + d.set(b"AP".to_vec(), Object::Dict(apd)); + let r = doc.add(Object::Dict(d)); + let mut list = annots(doc, p); + list.push(Object::Ref(r)); + let i = list.len() - 1; + set_annots(doc, p, list)?; + Ok(i) +} + +fn flags(doc: &Document, r: ObjRef) -> i64 { + doc.get(r).as_dict().map(|d| annotation_flags(doc, d)).unwrap_or(0) +} + +/// Refuse to change a locked comment (Acrobat: Properties ▸ Locked). +pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> { + if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) } +} + +/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized, +/// restyled or deleted; its text and replies stay editable, as in Acrobat. +pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + let f = if locked { flags(doc, r) | FLAG_LOCKED } else { flags(doc, r) & !FLAG_LOCKED }; + doc.update_dict(r, |d| d.set(b"F".to_vec(), Object::Int(f)))?; + Ok(()) +} + +/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with +/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status. +pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result { + let (p, parent) = annot_ref(doc, page, index)?; + let pd = annot_dict(doc, parent); + if pd.name(b"Subtype") == Some(b"Popup") { + return Err(AnnotError::Invalid("pop-ups can't be marked".into())); + } + let state = if marked { "Marked" } else { "Unmarked" }; + let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author }); + let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta); + d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4]))); + d.set(b"IRT".to_vec(), Object::Ref(parent)); + d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"State".to_vec(), PdfString::text(state)); + d.set(b"StateModel".to_vec(), PdfString::text("Marked")); + let r = doc.add(Object::Dict(d)); + let mut list = annots(doc, p); + list.push(Object::Ref(r)); + let i = list.len() - 1; + set_annots(doc, p, list)?; + Ok(i) +} + +/// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`. +pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> { + if !finite(&[dx, dy]) { + return Err(AnnotError::Invalid("invalid offset".into())); + } + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let shift = |o: &Object, every: bool| -> Option { + let a = o.as_array()?; + Some(Object::Array( + a.iter() + .enumerate() + .map(|(i, v)| match v.as_f64() { + Some(x) if every || i < 4 => Object::Real(x + if i % 2 == 0 { dx } else { dy }), + _ => v.clone(), + }) + .collect(), + )) + }; + let popup = annot_dict(doc, r).reference(b"Popup"); + doc.update_dict(r, |d| { + for (k, every) in [(&b"Rect"[..], false), (b"QuadPoints", true), (b"L", false), (b"CL", true), (b"Vertices", true)] { + if let Some(v) = d.get(k).and_then(|o| shift(o, every)) { + d.set(k.to_vec(), v); + } + } + if let Some(Object::Array(list)) = d.get(b"InkList").cloned() { + let moved = list.iter().map(|s| shift(s, true).unwrap_or_else(|| s.clone())).collect(); + d.set(b"InkList".to_vec(), Object::Array(moved)); + } + touch(d, meta); + })?; + if let Some(p) = popup + && doc.get(p).as_dict().is_some() + { + doc.update_dict(p, |d| { + if let Some(v) = d.get(b"Rect").and_then(|o| shift(o, false)) { + d.set(b"Rect".to_vec(), v); + } + })?; + } + Ok(()) +} + +/// Resize a rectangle, oval, text box, stamp or drawing to `rect`. Stamps keep their appearance, +/// which PDF viewers scale from its bounding box into the new rectangle; drawings scale their +/// strokes and get a new one. +pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if subtype == "Stamp" { + let rect = normalize(rect); + if !finite(&rect) || rect[2] <= rect[0] || rect[3] <= rect[1] { + return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); + } + doc.update_dict(r, |d| { + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + return Ok(()); + } + if subtype == "Ink" { + return resize_ink(doc, r, normalize(rect), meta); + } + if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") { + return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized"))); + } + apply_text_box(doc, r, rect, meta, false) +} + +/// At most this many points reshape a comment (a drawn polygon has a handful). +const MAX_POINTS: usize = 100_000; + +/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two +/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the +/// points with room for the line's width and endings, as when the comment was drawn, and the +/// appearance is redrawn. A measurement keeps the points it measured. +pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.contains(b"Measure") { + return Err(AnnotError::Invalid("a measurement's points are where it measured".into())); + } + if points.len() > MAX_POINTS { + return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); + } + let style = Style { width: border_width_of(&d), ..Style::default() }; + let ends = line_endings_of(&d).unwrap_or_default(); + let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None)); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) { + ("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"), + ("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())), + ("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"), + ("Polygon", _) => { + let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); + (Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices") + } + (other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))), + }; + // Checks the points too: finite, and far enough apart to draw. + let rect = rect_for(&shape, &style)?; + let before = d; + doc.update_dict(r, |d| { + d.set(key.to_vec(), num_array(&points.concat())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half +/// the line width and a little more) stays the same on each side, so the line isn't clipped. +fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> { + let d = annot_dict(doc, r); + let strokes: Vec> = d + .get(b"InkList") + .map(|l| doc.resolve(l)) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|s| { + let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() + }) + .collect(); + let Some(inner) = bounds(strokes.iter().flatten().copied()) else { + return Err(AnnotError::Invalid("the drawing has no strokes".into())); + }; + let old = d.get(b"Rect").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let old: Vec = old.iter().filter_map(|v| doc.resolve(v).as_f64()).collect(); + let old = match old[..] { + [a, b, c, e] => normalize([a, b, c, e]), + _ => inner, + }; + let margin = [inner[0] - old[0], inner[1] - old[1], old[2] - inner[2], old[3] - inner[3]].map(|m| m.max(0.0)); + let target = [rect[0] + margin[0], rect[1] + margin[1], rect[2] - margin[2], rect[3] - margin[3]]; + if !finite(&rect) || target[2] < target[0] || target[3] < target[1] || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); + } + // An axis with no extent (a straight stroke) is centred rather than scaled. + let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| if a1 > a0 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { (b0 + b1) / 2.0 }; + let scaled: Vec> = strokes + .iter() + .map(|s| { + s.iter().map(|p| [axis(p[0], inner[0], inner[2], target[0], target[2]), axis(p[1], inner[1], inner[3], target[1], target[3])]).collect() + }) + .collect(); + let before = annot_dict(doc, r); + doc.update_dict(r, |d| { + d.set(b"InkList".to_vec(), Object::Array(scaled.iter().map(|s| num_array(&s.concat())).collect())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's +/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn +/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`). +fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> { + let rect = normalize(rect); + if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return Err(AnnotError::Invalid("invalid rectangle (too small)".into())); + } + let d = annot_dict(doc, r); + if !new_text { + refuse_foreign_text(doc, &d)?; + } + // A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it. + let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::>()).filter(|l| l.len() == 6); + let (outer, cl) = match callout { + Some(mut l) => { + let attach = callout_attach(rect, [l[2], l[3]]); + (l[4], l[5]) = (attach[0], attach[1]); + let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0; + let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad); + ([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l)) + } + None => (rect, None), + }; + doc.update_dict(r, |d| { + d.set(b"Rect".to_vec(), num_array(&outer)); + match &cl { + Some(l) => { + d.set(b"CL".to_vec(), num_array(l)); + d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]])); + } + None => { + d.remove(b"RD"); + } + } + touch(d, meta); + })?; + let before = d; + if new_text { + if let Err(e) = draw_appearance(doc, r) { + doc.set(r, Object::Dict(before)); + return Err(e); + } + Ok(()) + } else { + redraw_or_restore(doc, r, before) + } +} + +fn border_width_of(d: &Dict) -> f64 { + d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0) +} + +/// Change a comment's colour, opacity, line width and/or line endings, and redraw it. +pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { + let StyleChange { color, opacity, width, endings } = change; + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + // Check before changing anything: a stale appearance would contradict the new style. + if appearance::build(&d).is_none() { + return Err(AnnotError::Unsupported(subtype.clone())); + } + if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { + return Err(AnnotError::Invalid("invalid style".into())); + } + if let Some(ends) = &endings { + let callout = subtype == "FreeText" && d.contains(b"CL"); + let pair = matches!(subtype.as_str(), "Line" | "PolyLine"); + if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) { + return Err(AnnotError::Invalid("this comment has no line endings to change".into())); + } + } + refuse_foreign_text(doc, &d)?; + let free_text = subtype == "FreeText"; + let before = d; + doc.update_dict(r, |d| { + if let Some(c) = color { + if free_text { + let (_, size) = appearance::parse_da(d); + let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0)); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + rich_text(d); + } else { + d.set(b"C".to_vec(), rgb(c)); + } + } + if let Some(o) = opacity { + let o = o.clamp(0.0, 1.0); + if o < 1.0 { + d.set(b"CA".to_vec(), Object::Real(o)); + } else { + d.remove(b"CA"); + } + } + if let Some(w) = width { + let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default(); + bs.set(b"W".to_vec(), Object::Real(w.max(0.0))); + d.set(b"BS".to_vec(), Object::Dict(bs)); + d.remove(b"Border"); + } + if let Some(ends) = endings { + if ends.len() == 1 { + if let Some(ending) = ends.first() { + d.set(b"LE".to_vec(), Object::name(ending.name())); + } + } else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } + } + ensure_ending_room(d); + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the +/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new +/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt. +pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"FreeText") { + return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a font size".into())); + } + if !size.is_finite() || !(1.0..=400.0).contains(&size) { + return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into())); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let (col, _) = appearance::parse_da(d); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes())); + rich_text(d); + touch(d, meta); + })?; + let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default(); + let result = match fitted_box(doc, r, &text) { + Some(fit) => { + let cur = annot_dict(doc, r); + let nums: Vec = + cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); + // Keep the top and sides; only the bottom edge moves, and only downwards. + match four(&nums).map(normalize) { + Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false), + _ => apply_text_box(doc, r, fit, meta, false), + } + } + None => redraw_or_restore(doc, r, before.clone()), + }; + if result.is_err() { + doc.set(r, Object::Dict(before)); + } + result +} + +/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6). +pub fn fillable(subtype: &str) -> bool { + matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText") +} + +/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and +/// redraw its appearance (Acrobat: Properties ▸ Fill Color). +pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); + if !fillable(&subtype) { + return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into())); + } + // Check before changing anything: a stale appearance would contradict the new fill. + if appearance::build(&d).is_none() { + return Err(AnnotError::Unsupported(subtype)); + } + if fill.is_some_and(|c| !finite(&c)) { + return Err(AnnotError::Invalid("invalid fill colour".into())); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" }; + match fill { + Some(c) => d.set(key.to_vec(), rgb(c)), + None => { + d.remove(key); + } + } + touch(d, meta); + })?; + redraw_or_restore(doc, r, before) +} + +/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent. +fn ensure_ending_room(d: &mut Dict) { + let subtype = d.name(b"Subtype").unwrap_or_default(); + if !matches!(subtype, b"Line" | b"PolyLine") { + return; + } + if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) { + return; + } + let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" }; + let Some(pts) = pair_points(d, key) else { return }; + let Some(bounds) = bounds(pts.iter().copied()) else { return }; + let w = border_width_of(d); + let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0); + let vals: Vec = d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); + let Some(rect) = four(&vals) else { return }; + let union = [rect[0].min(need[0]), rect[1].min(need[1]), rect[2].max(need[2]), rect[3].max(need[3])]; + if union != rect { + d.set(b"Rect".to_vec(), num_array(&union)); + } +} + +fn four(v: &[f64]) -> Option<[f64; 4]> { + Some([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?]) +} + +fn pair_points(d: &Dict, key: &[u8]) -> Option> { + let v: Vec = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); + if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) { + return None; + } + Some(v.as_chunks::<2>().0.to_vec()) +} + +/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the +/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they +/// move, list and delete as one. Returns the strikeout's index. +/// +/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies +/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that +/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id +/// from the caller, so the edit stays deterministic. +pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result { + let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement; + let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; + let s = add_annotation( + doc, + &NewAnnotation { + page, + shape: Shape::TextMarkup { kind: Markup::StrikeOut, quads: quads.to_vec() }, + style: strike.clone(), + contents: String::new(), + author: author.into(), + }, + meta, + )?; + let (_, sr) = annot_ref(doc, page, s)?; + doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?; + // The caret sits on the baseline at the end of the last line, its point at the text. + let xs = [last[0], last[2], last[4], last[6]]; + let ys = [last[1], last[3], last[5], last[7]]; + let x = xs.iter().copied().fold(f64::MIN, f64::max); + let (y0, y1) = (ys.iter().copied().fold(f64::MAX, f64::min), ys.iter().copied().fold(f64::MIN, f64::max)); + let h = ((y1 - y0) * 0.6).clamp(4.0, 24.0); + let rect = [x - h / 2.0, y0 - h * 0.6, x + h / 2.0, y0 + h * 0.4]; + let c = add_annotation( + doc, + &NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() }, + caret_meta, + )?; + let (_, cr) = annot_ref(doc, page, c)?; + doc.update_dict(cr, |d| { + d.set(b"IRT".to_vec(), Object::Ref(sr)); + d.set(b"RT".to_vec(), Object::name("Group")); + d.set(b"Subj".to_vec(), PdfString::text("Replace Text")); + })?; + Ok(s) +} + +/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points +/// of `path`, splitting strokes where they are cut. A drawing with nothing left is deleted. +/// Returns whether anything was erased. +pub fn erase_ink(doc: &mut Document, page: usize, index: usize, path: &[[f64; 2]], radius: f64, meta: &Meta) -> Result { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"Ink") { + return Err(AnnotError::Invalid("only drawings can be erased".into())); + } + if path.is_empty() || !path.iter().all(|p| finite(p)) || !radius.is_finite() || radius <= 0.0 { + return Err(AnnotError::Invalid("invalid eraser path".into())); + } + let strokes: Vec> = d + .get(b"InkList") + .map(|l| doc.resolve(l)) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|s| { + let v: Vec = doc.resolve(s).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); + v.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect() + }) + .collect(); + // Distance from a point to the eraser's path (segments, or a single point). + let near = |p: [f64; 2]| -> bool { + if path.len() == 1 { + return (p[0] - path[0][0]).hypot(p[1] - path[0][1]) <= radius; + } + path.windows(2).any(|w| { + let (a, b) = (w[0], w[1]); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (p[0] - (a[0] + t * dx)).hypot(p[1] - (a[1] + t * dy)) <= radius + }) + }; + let mut kept: Vec> = Vec::new(); + let mut erased = false; + for s in &strokes { + // Densify so long segments are cut where the eraser crosses them. + let mut pts: Vec<[f64; 2]> = Vec::new(); + for w in s.windows(2) { + let n = ((w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1]) / (radius / 2.0)).ceil().clamp(1.0, 2000.0) as usize; + for k in 0..n { + let t = k as f64 / n as f64; + pts.push([w[0][0] + (w[1][0] - w[0][0]) * t, w[0][1] + (w[1][1] - w[0][1]) * t]); + } + } + if let Some(last) = s.last() { + pts.push(*last); + } + let mut run: Vec<[f64; 2]> = Vec::new(); + for p in pts { + if near(p) { + erased = true; + if run.len() >= 2 { + kept.push(std::mem::take(&mut run)); + } + run.clear(); + } else { + run.push(p); + } + } + if run.len() >= 2 { + kept.push(run); + } + } + if !erased { + return Ok(false); + } + if kept.is_empty() { + delete_annotation(doc, page, index)?; + return Ok(true); + } + let width = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0); + let rect = grow(bounds(kept.iter().flatten().copied()).unwrap_or_default(), width / 2.0 + 1.0); + doc.update_dict(r, |d| { + d.set(b"InkList".to_vec(), Object::Array(kept.iter().map(|s| num_array(&s.concat())).collect())); + d.set(b"Rect".to_vec(), num_array(&rect)); + touch(d, meta); + })?; + set_appearance(doc, r)?; + Ok(true) +} diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index d3d448e520..a3dc14e262 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -229,6 +229,75 @@ fn delete_takes_popup_and_reply_chain() { assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 })); } +/// A locked reply can't be deleted, so it doesn't go with what it answers either: deleting the +/// reply above it, or the comment, is refused and changes nothing; once it is unlocked they go. +#[test] +fn a_locked_reply_keeps_what_it_answers() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap(); + let reply = add_reply(&mut doc, 0, note, "first", "Bob", &meta("r1")).unwrap(); + let answer = add_reply(&mut doc, 0, reply, "locked answer", "Ada", &meta("r2")).unwrap(); + set_locked(&mut doc, 0, answer, true).unwrap(); + let before = list(&doc, 0).len(); + assert_eq!(delete_annotation(&mut doc, 0, reply), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(delete_annotation(&mut doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(list(&doc, 0).len(), before, "nothing went"); + // Asked first, read-only, as a Delete command does: the same answer, and the answer's own + // lock is its own. + assert_eq!(deletable(&doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(deletable(&doc, 0, answer), Err(AnnotError::Invalid("the comment is locked".into()))); + // Lifted on a copy, to draw the page beneath, the locks don't count. + let mut copy = doc.clone(); + lift_annotation(&mut copy, 0, note).unwrap(); + assert!(list(©, 0).len() < before, "the thread is lifted off the copy"); + set_locked(&mut doc, 0, answer, false).unwrap(); + assert_eq!(deletable(&doc, 0, reply), Ok(())); + delete_annotation(&mut doc, 0, reply).unwrap(); + let left: Vec = list(&doc, 0).iter().map(|d| text(d, b"Contents")).collect(); + assert!(!left.iter().any(|t| t == "first" || t == "locked answer"), "the reply and its answer went: {left:?}"); +} + +/// A reply or pop-up written inline in `/Annots` (a file can, though the standard asks for +/// references) goes with the comment it points at, and so does a pop-up an inline reply or an +/// inline comment names. An unrelated inline comment stays until it is deleted itself. +#[test] +fn delete_takes_inline_replies_and_popups() { + let mut doc = fixture(); + let mut popup = |note: &str| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Popup")); + d.set(b"Contents".to_vec(), PdfString::text(note)); + doc.add(Object::Dict(d)) + }; + let (reply_popup, square_popup) = (popup("of the reply"), popup("of the square")); + let inline = |subtype: &str, links: &[(&[u8], ObjRef)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name(subtype)); + links.iter().for_each(|(key, r)| d.set(key.to_vec(), Object::Ref(*r))); + Object::Dict(d) + }; + let stamp = ObjRef::new(6, 0); + let entries = vec![ + Object::Ref(stamp), + inline("Text", &[(b"IRT", stamp), (b"Popup", reply_popup)]), + Object::Ref(reply_popup), + inline("Popup", &[(b"Parent", stamp)]), + inline("Square", &[(b"Popup", square_popup)]), + Object::Ref(square_popup), + ]; + doc.set(ObjRef::new(5, 0), Object::Array(entries)); + let subtypes = |doc: &Document| -> Vec { + list(doc, 1).iter().map(|d| String::from_utf8_lossy(d.name(b"Subtype").unwrap()).into_owned() + ":" + &text(d, b"Contents")).collect() + }; + delete_annotation(&mut doc, 1, 0).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(subtypes(&doc), ["Square:", "Popup:of the square"]); + delete_annotation(&mut doc, 1, 0).unwrap(); + assert!(subtypes(&reopen(&doc)).is_empty(), "the inline comment's pop-up went with it"); +} + #[test] fn deleting_the_last_comment_removes_annots() { let mut doc = fixture(); @@ -262,7 +331,7 @@ fn move_shifts_all_geometry_and_the_popup() { fn restyle_and_resize_redraw_the_appearance() { let mut doc = fixture(); let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap(); - set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap(); + set_style(&mut doc, 0, r, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None), &meta("")).unwrap(); set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap(); let doc2 = reopen(&doc); let d = &list(&doc2, 0)[r]; @@ -272,7 +341,10 @@ fn restyle_and_resize_redraw_the_appearance() { assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5)); // A stamp's appearance can't be regenerated, so restyling it changes nothing. let before = list(&doc, 1)[0].clone(); - assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into()))); + assert_eq!( + set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")), + Err(AnnotError::Unsupported("Stamp".into())) + ); assert_eq!(list(&doc, 1)[0], before); // Lines can't be resized as rectangles. let l = add_annotation( @@ -333,7 +405,7 @@ fn text_box_text_and_colour_changes_are_drawn() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap(); set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[t]; assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%"); @@ -515,8 +587,8 @@ fn fill_and_sign_items_are_drawn() { assert_eq!(text(&all[s], b"Subj"), "Signature"); // Other stamps still can't be restyled; ours can. let mut doc = doc; - set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); - assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err()); + set_style(&mut doc, 1, marks[0], StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err()); } #[test] @@ -803,7 +875,20 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; - let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); + let i = add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret"), + }, + ) + .unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -825,7 +910,22 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); + assert!( + add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &[], + replacement: "x", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret") + } + ) + .is_err() + ); } #[test] @@ -991,6 +1091,112 @@ fn malformed_optional_interior_color_does_not_hide_shape_strokes() { assert!(list(&doc, 0)[0].get(b"AP").is_none(), "display generation leaves the source document unchanged"); } +/// #3175: viewers draw a note without an appearance as a fixed-size icon at the upper-left corner +/// of its `/Rect`. The display copy must not stretch the icon over a large `/Rect` or shrink it +/// to nothing in one with no width. +#[test] +fn a_note_without_an_appearance_is_shown_at_icon_size() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + b"<< /Type /Annot /Subtype /Text /Rect [-23 240 480 468] /C [1 0 0] /Open true >>", + b"<< /Type /Annot /Subtype /Text /Rect [310 380 310 392] /Name /Comment /F 4 >>", + b"<< /Type /Annot /Subtype /FileAttachment /Rect [100 50 300 10] >>", + ]); + let display = with_missing_appearances(&doc).expect("the notes get a display appearance"); + let rects = |doc: &Document| -> Vec> { + let rect = |d: &Dict| d.get(b"Rect").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(Object::as_f64).collect::>()); + list(doc, 0).iter().map(|d| rect(d).unwrap_or_default()).collect() + }; + assert_eq!(rects(&display), [vec![-23.0, 448.0, -3.0, 468.0], vec![310.0, 372.0, 330.0, 392.0], vec![100.0, 30.0, 120.0, 50.0]]); + let original = [vec![-23.0, 240.0, 480.0, 468.0], vec![310.0, 380.0, 310.0, 392.0], vec![100.0, 50.0, 300.0, 10.0]]; + assert_eq!(rects(&doc), original, "the source document is unchanged"); +} + +/// The fourteen rubber stamp names viewers draw without an appearance (ISO 32000-2 Table 184). +const TABLE_184_STAMPS: [(&str, &str); 14] = [ + ("Approved", "APPROVED"), + ("Experimental", "EXPERIMENTAL"), + ("NotApproved", "NOT APPROVED"), + ("AsIs", "AS IS"), + ("Expired", "EXPIRED"), + ("NotForPublicRelease", "NOT FOR PUBLIC RELEASE"), + ("Confidential", "CONFIDENTIAL"), + ("Final", "FINAL"), + ("Sold", "SOLD"), + ("Departmental", "DEPARTMENTAL"), + ("ForComment", "FOR COMMENT"), + ("TopSecret", "TOP SECRET"), + ("Draft", "DRAFT"), + ("ForPublicRelease", "FOR PUBLIC RELEASE"), +]; + +fn stamp_dict(extra: &str) -> Dict { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + format!("<< /Type /Annot /Subtype /Stamp /Rect [20 40 140 100] {extra} >>").as_bytes(), + ]); + list(&doc, 0).remove(0) +} + +fn drawn(d: &Dict) -> String { + String::from_utf8_lossy(&appearance::display_stamp(d).expect("a stamp is drawn").decoded().unwrap()).into_owned() +} + +/// A stamp another program wrote without an appearance draws the stamp its `/Name` names, Draft +/// when it has none, and a frame with both diagonals for any other name, never the name itself. +#[test] +fn a_stamp_without_an_appearance_draws_the_stamp_it_names() { + for (name, label) in TABLE_184_STAMPS { + let content = drawn(&stamp_dict(&format!("/Name /{name}"))); + assert!(content.contains(&format!("({label}) Tj")), "{name}: {content}"); + } + assert!(drawn(&stamp_dict("")).contains("(DRAFT) Tj"), "Table 184's default"); + assert!(drawn(&stamp_dict("/IT /Stamp")).contains("(DRAFT) Tj"), "a stamp intent keeps the default"); + // Any other name, a name in another case (names are compared byte for byte, §7.3.5), and an + // image stamp (`/IT`, which has no `/Name` to default): the frame and its diagonals. + for extra in ["/Name /MyCustomStamp", "/Name /draft", "/Name /APPROVED", "/IT /StampImage", "/Name /PCVoid"] { + let content = drawn(&stamp_dict(extra)); + assert!(!content.contains("Tj") && content.contains(" re\n") && content.matches(" l\n").count() == 2, "{extra}: {content}"); + } + // `/C` colours the stamp, as it does PdfCraft's own. + assert!(drawn(&stamp_dict("/Name /Final /C [1 0 1]")).contains("1 0 1 RG")); + // The placeholder stays grey whatever the `/C`: it is not the stamp, so it takes none of its colour. + let placeholder = drawn(&stamp_dict("/Name /MyCustomStamp /C [1 0 0]")); + assert!(placeholder.contains("0.45 0.45 0.45 RG") && !placeholder.contains("1 0 0 RG"), "{placeholder}"); + // A stamp's own artwork is never replaced, and the drawing is for display only: edits + // (restyling, flattening) still find nothing they may write. + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /AP << /N 9 0 R >>")).is_none()); + assert!(appearance::build(&stamp_dict("/Name /Draft")).is_none()); + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /PCStamp true")).is_none(), "PdfCraft's own stamps are drawn by `build`"); + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /Rect [5 5 5 50]")).is_none(), "no width"); +} + +/// The display copy draws a stamp that has no normal appearance, and keeps one that has. +#[test] +fn the_display_copy_draws_stamps_without_a_normal_appearance_only() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 40 140 100] /Name /TopSecret >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 140 140 200] /Name /Draft /AP << /N 8 0 R >> >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 240 140 300] /Name /Sold /AP << /D 8 0 R >> >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 340 140 400] /Name /Unheard >>", + b"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 8 >>\nstream\n1 0 0 rg\nendstream", + ]); + let display = with_missing_appearances(&doc).expect("three stamps need an appearance"); + let shown = list(&display, 0); + assert!(ap_content(&display, &shown[0]).contains("(TOP SECRET) Tj")); + assert_eq!(shown[1].get(b"AP"), list(&doc, 0)[1].get(b"AP"), "an existing appearance is kept"); + assert!(ap_content(&display, &shown[2]).contains("(SOLD) Tj"), "an /AP without /N has nothing to keep"); + assert!(!ap_content(&display, &shown[3]).contains("Tj"), "an unknown name gets the placeholder"); + assert!(list(&doc, 0)[0].get(b"AP").is_none(), "the document itself is untouched"); +} + #[test] fn every_line_ending_is_drawn() { let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow]; @@ -1030,7 +1236,7 @@ fn every_line_ending_is_drawn() { ) .unwrap(); let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line")); - set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, None, Some(vec![LineEnding::None, LineEnding::Square])), &meta("style")).unwrap(); let mut doc = reopen(&doc); let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"); let after = rect(&line); @@ -1098,7 +1304,7 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() { let other_dict = list(&doc, 0)[other].clone(); let source = other_dict.get(b"AP").unwrap(); let before = doc.resolve(source); - set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, first, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(doc.resolve(source), before, "a shared AP must not change"); for full in [false, true] { let bytes = if full { @@ -1220,7 +1426,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { .unwrap(); assert_eq!(mark(&doc, s), Some('张')); // Our own `?` appearance is redrawn on a restyle; Western European text clears the mark. - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(mark(&doc, t), Some('签')); set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap(); assert_eq!(mark(&doc, t), None); @@ -1248,7 +1454,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { set_annots(&mut doc, page, on_page).unwrap(); let k = list(&doc, 0).len() - 1; let before = list(&doc, 0)[k].clone(); - let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err(); + let err = set_style(&mut doc, 0, k, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None), &meta("")).unwrap_err(); assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}"); assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance"); let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err(); @@ -1293,21 +1499,43 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() { .expect("strips"); let mut cache = AppearanceCache::default(); let first = with_missing_appearances_cached(&doc, &mut cache).expect("builds"); - assert_eq!((cache.builds(), cache.hits()), (1, 0), "only the note needs an appearance"); + assert_eq!((cache.builds(), cache.hits()), (2, 0), "the note and the file's stamp need an appearance"); let second = with_missing_appearances_cached(&doc, &mut cache).expect("reuses"); - assert_eq!((cache.builds(), cache.hits()), (1, 1), "nothing is redrawn"); + assert_eq!((cache.builds(), cache.hits()), (2, 2), "nothing is redrawn"); let [a, b] = [&first, &second].map(|copy| ap_content(copy, &list(copy, 0)[idx])); assert!(!a.is_empty()); assert_eq!(a, b, "the reused appearance is byte-identical"); } +/// A comment that `/Annots` lists many times, and that a second page lists too, is drawn once in +/// the display copy. Every listing used to add another appearance stream, so a short array of +/// repeated references multiplied the display bytes the engine writes on each refresh. +#[test] +fn a_comment_listed_many_times_is_drawn_once_for_display() { + let first = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", vec!["5 0 R"; 1_000].join(" ")); + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>", + first.as_bytes(), + b"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] >>", + ]); + let mut cache = AppearanceCache::default(); + let display = with_missing_appearances_cached(&doc, &mut cache).expect("the square needs an appearance"); + assert_eq!(display.object_numbers().len(), doc.object_numbers().len() + 1, "one appearance stream, not one per listing"); + assert_eq!((cache.builds(), cache.hits()), (1, 0)); + assert!(list(&display, 0).iter().chain(&list(&display, 1)).all(|d| d.contains(b"AP")), "every listing shows it"); + with_missing_appearances_cached(&doc, &mut cache).expect("a refresh"); + assert_eq!((cache.builds(), cache.hits()), (1, 1), "a refresh reuses the cached stream once"); +} + /// #1908: a text box's font size changes in `/DA` (colour kept), the rich text follows, the box /// grows to hold the bigger text but never shrinks, and bad sizes and other subtypes are refused. #[test] fn text_box_font_size_changes_the_da_and_grows_the_box() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap(); let height = |doc: &Document| { let rect: Vec = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); @@ -1335,3 +1563,634 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() { assert_eq!(props(&doc, 0, i).unwrap().font_size, None); } } + +#[test] +fn text_boxes_and_callouts_can_change_fill_and_border() { + let mut doc = fixture(); + let shapes = [ + Shape::TextBox { rect: [300.0, 600.0, 500.0, 660.0], font_size: 12.0 }, + Shape::Callout { + rect: [300.0, 600.0, 500.0, 660.0], + knee: [260.0, 560.0], + point: [200.0, 520.0], + font_size: 12.0, + ending: LineEnding::OpenArrow, + }, + ]; + for shape in shapes { + let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap(); + assert!(props(&doc, 0, index).unwrap().fillable); + set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, Some(3.0), None), &meta("test")).unwrap(); + let saved = reopen(&doc); + let properties = props(&saved, 0, index).unwrap(); + assert_eq!(properties.fill, Some([1.0; 3])); + assert_eq!(properties.width, Some(3.0)); + let ap = ap_content(&saved, &list(&saved, 0)[index]); + assert!(ap.contains("1 1 1 rg") && ap.contains(" re f") && ap.contains("3 w"), "{ap}"); + set_fill(&mut doc, 0, index, None, &meta("test")).unwrap(); + assert_eq!(props(&doc, 0, index).unwrap().fill, None); + } +} + +#[test] +fn free_text_properties_show_legacy_or_absent_border_width() { + let mut doc = fixture(); + let index = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 100.0, 200.0, 140.0], font_size: 12.0 }), &meta("test")).unwrap(); + let (_, reference) = annot_ref(&mut doc, 0, index).unwrap(); + doc.update_dict(reference, |d| { + d.remove(b"BS"); + d.remove(b"Border"); + }) + .unwrap(); + assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0)); + doc.update_dict(reference, |d| { + d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect())); + }) + .unwrap(); + assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0)); +} + +/// A line's ends and the corners of connected lines and polygons can be moved after drawing: +/// the points change, `/Rect` follows them (with room for an arrowhead), and the appearance is +/// drawn anew. A line needs two ends far enough apart; a locked comment keeps its shape. +#[test] +fn lines_and_polygons_are_reshaped_by_their_points() { + let mut doc = fixture(); + let l = add_annotation( + &mut doc, + &new(0, Shape::Line { from: [100.0, 100.0], to: [200.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow }), + &meta("l"), + ) + .unwrap(); + let before = ap_content(&doc, &list(&doc, 0)[l]); + set_points(&mut doc, 0, l, &[[100.0, 100.0], [100.0, 300.0]], &meta("")).unwrap(); + let doc2 = reopen(&doc); + let d = &list(&doc2, 0)[l]; + let ends: Vec = d.get(b"L").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect(); + assert_eq!(ends, [100.0, 100.0, 100.0, 300.0]); + let r = rect(d); + assert!(r[0] < 100.0 && r[2] > 100.0 && r[1] < 100.0 && r[3] > 300.0, "the rectangle holds the line and its arrowhead: {r:?}"); + assert!(r[2] - r[0] < 40.0, "and no more: {r:?}"); + assert_ne!(ap_content(&doc2, d), before, "drawn anew"); + assert_eq!(summaries(&doc2).into_iter().find(|s| s.index == l).unwrap().points, vec![[100.0, 100.0], [100.0, 300.0]]); + // Two ends exactly, far enough apart. + assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0], [1.2, 1.0]], &meta("")), Err(AnnotError::Invalid(_)))); + assert!(matches!(set_points(&mut doc, 0, l, &[[f64::NAN, 1.0], [10.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_)))); + // A polygon's corner. + let g = add_annotation( + &mut doc, + &new(0, Shape::Polygon { vertices: vec![[300.0, 300.0], [400.0, 300.0], [350.0, 380.0]], cloud: false }), + &meta("g"), + ) + .unwrap(); + set_points(&mut doc, 0, g, &[[300.0, 300.0], [450.0, 300.0], [350.0, 380.0], [300.0, 360.0]], &meta("")).unwrap(); + let d = &list(&doc, 0)[g]; + let v: Vec = d.get(b"Vertices").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect(); + assert_eq!(v.len(), 8); + assert!(rect(d)[2] > 450.0); + // Locked: refused, and nothing changes. + set_locked(&mut doc, 0, l, true).unwrap(); + assert!(set_points(&mut doc, 0, l, &[[0.0, 0.0], [50.0, 50.0]], &meta("")).is_err()); + // Other comments have no points to move. + let s = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("s")).unwrap(); + assert!(matches!(set_points(&mut doc, 0, s, &[[0.0, 0.0], [50.0, 50.0]], &meta("")), Err(AnnotError::Invalid(_)))); +} + +#[test] +fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { + let mut doc = fixture(); + let root = ObjRef { num: 2, generation: 0 }; + let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 }); + // A cycle back to the root, then both pages 128 times over. + let mut kids = vec![Object::Ref(root)]; + kids.extend([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(256)); + doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap(); + let mut walk = PageWalk::new(&doc, root); + assert_eq!(walk.next(), Some(first)); + assert!(walk.pending.len() <= 2, "repeated kids retained {} pending entries", walk.pending.len()); + assert_eq!(walk.next(), Some(second)); + assert_eq!(walk.next(), None); + assert_eq!(page_refs(&doc).unwrap(), [first, second]); +} + +/// #1786: TrueType glyph contours (such as Inter's composite Ђ and ћ) overlap with the same +/// winding direction where strokes cross, so typed signature appearances must fill with the +/// non-zero winding operator (`f`), not even-odd (`f*`). +#[test] +fn typed_signature_uses_nonzero_winding_fill() { + let mut doc = fixture(); + let contours = vec![vec![[0.4, 0.0], [0.6, 0.0], [0.6, 1.0], [0.4, 1.0]], vec![[0.0, 0.7], [1.0, 0.7], [1.0, 0.85], [0.0, 0.85]]]; + let idx = add_annotation(&mut doc, &new(0, Shape::TypedSignature { rect: [100.0, 100.0, 200.0, 140.0], contours }), &meta("sig")).unwrap(); + let ap = ap_content(&doc, &list(&doc, 0)[idx]); + assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}"); + assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}"); +} + +/// Many page tree nodes sharing one `/Kids` array read it once, not once per node. +#[test] +fn page_walk_reads_a_shared_kids_array_once() { + let mut doc = fixture(); + let root = ObjRef { num: 2, generation: 0 }; + let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 }); + let shared = doc.add(Object::Array([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(2_000).collect())); + let nodes: Vec = (0..1_000) + .map(|_| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Pages")); + d.set(b"Kids".to_vec(), Object::Ref(shared)); + Object::Ref(doc.add(d)) + }) + .collect(); + doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(nodes))).unwrap(); + let mut walk = PageWalk::new(&doc, root); + let pages: Vec = walk.by_ref().collect(); + assert_eq!(pages, [first, second]); + assert!(walk.examined <= 10_000, "{} kids entries read", walk.examined); +} + +/// Adding many links to a page extends its `/Annots` once, not once per link. +#[test] +fn many_links_extend_a_page_annotation_list_once() { + let mut doc = fixture(); + let before = annots(&doc, page_refs(&doc).unwrap()[0]).len(); + let n = 2_000; + let rects: Vec<[f64; 4]> = (0..n).map(|i| [10.0, i as f64 * 0.1, 50.0, i as f64 * 0.1 + 5.0]).collect(); + ANNOTS_ENTRIES_COPIED.with(|c| c.set(0)); + let added = links::add_many(&mut doc, &[(0, rects, "https://example.com".into())], &links::LinkStyle::default()).unwrap(); + let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get); + assert_eq!(added, n); + assert_eq!(annots(&doc, page_refs(&doc).unwrap()[0]).len(), before + n); + assert!(copied <= 4 * n, "{copied} annotation entries copied for {n} links"); +} + +/// A batch with one bad entry adds nothing: no link objects are left behind unattached. +#[test] +fn a_link_batch_with_a_bad_entry_creates_no_objects() { + let mut doc = fixture(); + let page = page_refs(&doc).unwrap()[0]; + let before = (annots(&doc, page).len(), doc.scan_objects().count()); + let good = [10.0, 10.0, 50.0, 20.0]; + let style = links::LinkStyle::default(); + for bad in [ + vec![(0, vec![good], "https://example.com".to_string()), (0, vec![good, [f64::NAN, 0.0, 10.0, 10.0]], "https://example.com".into())], + vec![(0, vec![good], "https://example.com".to_string()), (0, vec![good], " ".into())], + vec![(0, vec![good], "https://example.com".to_string()), (99, vec![good], "https://example.com".into())], + ] { + assert!(links::add_many(&mut doc, &bad, &style).is_err()); + assert_eq!((annots(&doc, page).len(), doc.scan_objects().count()), before); + } +} + +fn doc_of(objs: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") +} + +#[test] +fn link_listing_skips_repeated_refs_and_caps_web_address_text() { + // One link referenced 1000 times, and 300 small links sharing one 64 KiB address. + let annots = format!("{}{}", " 4 0 R".repeat(1000), "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >>".repeat(300)); + let doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A 5 0 R >>".into(), + "<< /S /URI /URI 6 0 R >>".into(), + format!("({})", "u".repeat(64 << 10)), + ]); + let all = links::list(&doc); + assert_eq!(all.iter().filter(|l| l.obj == Some(ObjRef::new(4, 0))).count(), 1); + assert_eq!(all.len(), 301, "every distinct link is still listed"); + let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum(); + assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept"); + assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10)); +} + +#[test] +fn web_address_cap_counts_the_text_kept_after_conversion() { + // 200 links sharing a 64 KiB address of invalid UTF-8: 12.5 MiB of bytes, each byte kept as a + // three-byte replacement character. + let annots = "<< /Subtype /Link /Rect [0 0 1 1] /A 4 0 R >>".repeat(200); + let doc = doc_of(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + "<< /S /URI /URI 5 0 R >>".into(), + format!("<{}>", "FF".repeat(64 << 10)), + ]); + let all = links::list(&doc); + assert_eq!(all.len(), 200); + let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum(); + assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept"); + assert!(kept > 0); +} + +#[test] +fn cloud_outline_has_a_bump_budget() { + // 50 long edges would each ask for 10,000 bumps of radius 1. + let pts: Vec<(f64, f64)> = (0..50).map(|i| (f64::from(i) * std::f64::consts::TAU / 50.0).sin_cos()).map(|(s, c)| (1e7 * c, 1e7 * s)).collect(); + let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count(); + assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps"); +} + +/// A note's drawn icon (its built appearance's content) for `/Name` (none when `None`) and `/C`. +fn note_drawing(name: Option<&str>, color: [f64; 3]) -> Vec { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Text")); + d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 20.0, 20.0].map(Object::Real).to_vec())); + d.set(b"C".to_vec(), Object::Array(color.map(Object::Real).to_vec())); + if let Some(n) = name { + d.set(b"Name".to_vec(), Object::name(n)); + } + crate::appearance::build(&d).expect("a note is drawn").decoded().unwrap() +} + +/// ISO 32000-2 §12.5.6.4, Table 175: a viewer shall provide icons for the seven standard names, +/// and may support more. Each of the fourteen names PdfCraft offers draws a shape of its own, so +/// a note shows the icon it names; a name it doesn't know, and a note without one, draw `Note` +/// (the table's default). +#[test] +fn every_note_icon_draws_its_own_shape() { + let yellow = [1.0, 0.82, 0.0]; + let drawn: Vec> = NoteIcon::ALL.iter().map(|i| note_drawing(Some(i.name()), yellow)).collect(); + for (i, a) in drawn.iter().enumerate() { + for (j, b) in drawn.iter().enumerate().skip(i + 1) { + assert_ne!(a, b, "{} and {} look alike", NoteIcon::ALL[i].name(), NoteIcon::ALL[j].name()); + } + } + let note = note_drawing(Some("Note"), yellow); + assert_eq!(note_drawing(Some("Sparkle"), yellow), note, "an unknown name"); + assert_eq!(note_drawing(None, yellow), note, "no name"); + for icon in NoteIcon::ALL { + assert_eq!(NoteIcon::from_name(icon.name()), Some(icon)); + } +} + +/// A note named with one of the further icons reads as that icon, and a colour change redraws it +/// as itself, not as a page. +#[test] +fn a_star_note_keeps_its_star_when_recoloured() { + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>", + ]); + assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star)); + set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + let doc = reopen(&doc); + let d = &list(&doc, 0)[0]; + assert_eq!(d.name(b"Name"), Some(&b"Star"[..])); + let n = d.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + assert_eq!(s.decoded().unwrap(), note_drawing(Some("Star"), [0.0, 0.0, 1.0])); +} + +/// The built appearance's content for an annotation dictionary written in PDF syntax. +fn content_of(src: &str) -> String { + let doc = document_of(&[src.as_bytes()]); + let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("a dictionary"); + let stream = appearance::build(&d).expect("drawn"); + String::from_utf8_lossy(&stream.decoded().unwrap()).into_owned() +} + +/// ISO 32000-2 Table 166: `/Border [hr vr w [dash]]`; the optional fourth element, a dash array, +/// used to hide the width (drawn 1 pt solid instead of 3 pt dashed). +#[test] +fn legacy_border_width_and_dash_are_drawn() { + let square = |border: &str| content_of(&format!("<< /Subtype /Square /Rect [0 0 100 100] /C [1 0 0] {border} >>")); + let c = square("/Border [0 0 3 [6 3]]"); + assert!(c.contains("3 w") && c.contains("[6 3] 0 d"), "{c}"); + assert!(c.contains("1.5 1.5 97 97 re"), "the stroke is inset by half its width: {c}"); + let c = square("/Border [0 0 3]"); + assert!(c.contains("3 w") && !c.contains(" d\n"), "no dash array: solid: {c}"); + // `/BS` overrides `/Border` (Table 166), for the width and the dashes alike. + let c = square("/Border [0 0 3 [6 3]] /BS << /W 2 >>"); + assert!(c.contains("2 w") && !c.contains(" d\n"), "{c}"); + let c = square("/Border [0 0 3 [6 3]] /BS << /W 2 /S /D /D [4 1] >>"); + assert!(c.contains("2 w") && c.contains("[4 1] 0 d"), "{c}"); + // A /BS without /W means its default width 1, not the ignored /Border's. + let c = square("/Border [0 0 3] /BS << /S /S >>"); + assert!(c.contains("1 w") && !c.contains("3 w"), "{c}"); + // A FreeText border and a line take the legacy dash array too. + let c = content_of("<< /Subtype /FreeText /Rect [0 0 100 40] /Contents (x) /Border [0 0 2 [5 2]] >>"); + assert!(c.contains("2 w") && c.contains("[5 2] 0 d"), "{c}"); + let c = content_of("<< /Subtype /Line /Rect [0 0 100 100] /L [10 10 90 90] /C [0 0 1] /Border [0 0 2 [5 2]] >>"); + assert!(c.contains("[5 2] 0 d"), "{c}"); +} + +/// Dash arrays that can't be drawn (§8.4.3.6: all zeros is invalid; negative or non-numeric +/// lengths, and absurdly long arrays) draw solid, without a panic. A non-finite or negative +/// width falls back to 1. +#[test] +fn bad_dash_arrays_draw_solid() { + let square = |border: &str| content_of(&format!("<< /Subtype /Square /Rect [0 0 100 100] /C [1 0 0] {border} >>")); + let long = format!("[{}]", vec!["1"; 65].join(" ")); + for bad in ["[0 0]", "[-3 2]", "[]", "[(a) 2]", "5", long.as_str()] { + let c = square(&format!("/Border [0 0 3 {bad}]")); + assert!(c.contains("3 w") && !c.contains(" d\n"), "{bad}: {c}"); + let c = square(&format!("/BS << /W 3 /S /D /D {bad} >>")); + assert!(c.contains("3 w") && !c.contains(" d\n"), "{bad}: {c}"); + } + assert!(square(&format!("/Border [0 0 3 [{}]]", vec!["1"; 64].join(" "))).contains(" 0 d\n"), "64 elements are drawn"); + // `/S /D` without `/D` uses the default `[3]` (Table 168). + assert!(square("/BS << /W 3 /S /D >>").contains("[3] 0 d")); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Square")); + d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 100.0, 100.0].map(Object::Real).to_vec())); + d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].map(Object::Real).to_vec())); + for w in [f64::NAN, f64::INFINITY, -2.0] { + let dash = Object::Array([f64::NAN, 2.0].map(Object::Real).to_vec()); + d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(w), dash])); + let c = String::from_utf8_lossy(&appearance::build(&d).unwrap().decoded().unwrap()).into_owned(); + assert!(c.contains("1 w") && !c.contains(" d\n") && !c.contains("NaN") && !c.contains("inf"), "{w}: {c}"); + } +} + +/// Table 182: an ink annotation's `/BS` gives the width and dash pattern its paths are drawn with. +#[test] +fn ink_draws_its_border_style_dashes() { + let ink = |style: &str| content_of(&format!("<< /Subtype /Ink /Rect [0 0 100 100] /C [1 0 0] /InkList [[5 5 50 95 95 5]] {style} >>")); + let c = ink("/BS << /W 3 /S /D /D [6 3] >>"); + assert!(c.contains("3 w 1 J 1 j\n[6 3] 0 d"), "{c}"); + assert!(!ink("/BS << /W 3 >>").contains(" d\n")); + assert!(ink("/Border [0 0 2 [4 4]]").contains("[4 4] 0 d")); +} + +/// Table 183: `/Sy /P` associates a new paragraph symbol (¶) with the caret. It is drawn to the +/// caret's right in the caret's colour, inside `/Rect`; `/Sy /None` (the default) draws none. +#[test] +fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() { + let caret = |extra: &str| { + let doc = document_of(&[format!("<< /Subtype /Caret /Rect [30 30 60 60] /C [0.9 0.1 0.1] {extra} >>").as_bytes()]); + let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap(); + let s = appearance::build(&d).unwrap(); + (s.decoded().unwrap(), s.dict.get(b"Resources").and_then(Object::as_dict).is_some_and(|r| r.contains(b"Font"))) + }; + let has_pilcrow = |c: &[u8]| c.windows(5).any(|w| w == b"(\xb6) T"); + let (c, font) = caret("/Sy /P"); + assert!(has_pilcrow(&c) && font, "{}", String::from_utf8_lossy(&c)); + let text = String::from_utf8_lossy(&c).into_owned(); + assert!(text.starts_with("0.9 0.1 0.1 rg"), "{text}"); + // The caret gives up room on the right for the symbol, which stays within /Rect. + let td: Vec = text.split("Tf ").nth(1).unwrap().split_whitespace().take(2).map(|v| v.parse().unwrap()).collect(); + let size: f64 = text.split("/Helv ").nth(1).unwrap().split_whitespace().next().unwrap().parse().unwrap(); + assert!(td[0] > 45.0 && td[0] + appearance::text_width("\u{b6}", size) <= 60.0 + 1e-6 && td[1] >= 30.0, "{text}"); + assert!(!text.contains("60 30 c h f"), "the caret no longer reaches the right edge: {text}"); + for none in ["/Sy /None", ""] { + let (c, font) = caret(none); + assert!(!has_pilcrow(&c) && !font, "{none}"); + assert!(String::from_utf8_lossy(&c).contains("60 30 c h f"), "{none}: the caret fills /Rect as before"); + } + // With /RD leaving room on the right, the caret keeps its box and the symbol fits beside it. + let (c, _) = caret("/Sy /P /RD [0 0 15 0]"); + let text = String::from_utf8_lossy(&c).into_owned(); + assert!(text.contains("45 30 c h f") && has_pilcrow(&c), "{text}"); + // Malformed /RD and a degenerate /Rect don't panic. + for rd in ["/RD [40 0 40 0]", "/RD [-1 0 0 0]", "/RD [1 2]", "/RD (x)"] { + assert!(has_pilcrow(&caret(&format!("/Sy /P {rd}")).0), "{rd}"); + } + let doc = document_of(&[b"<< /Subtype /Caret /Rect [30 30 30 30] /Sy /P >>"]); + let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap(); + assert!(appearance::build(&d).is_some()); +} + +// ── Line annotations: leader lines and captions (ISO 32000-2 §12.5.6.7, Table 178) ────────── + +/// A red Line from `l` with a 1 pt border, a generous `/Rect`, and `extra` entries. +fn table_178_line(l: [f64; 4], extra: &[(&str, Object)]) -> Dict { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Line")); + d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 600.0, 800.0].map(Object::Real).to_vec())); + d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].map(Object::Real).to_vec())); + d.set(b"L".to_vec(), Object::Array(l.map(Object::Real).to_vec())); + for (k, v) in extra { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d +} + +/// The built appearance's content. +fn line_drawing(d: &Dict) -> String { + let s = crate::appearance::build(d).expect("the line is drawn"); + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() +} + +/// Every straight two-point stroke (`x0 y0 m x1 y1 l S`) in a drawing. +fn segments(content: &str) -> Vec<[f64; 4]> { + content + .lines() + .filter_map(|line| { + let t: Vec<&str> = line.split_whitespace().collect(); + if t.len() != 7 || t[2] != "m" || t[5] != "l" || t[6] != "S" { + return None; + } + let v: Vec = [t[0], t[1], t[3], t[4]].iter().map(|x| x.parse().unwrap()).collect(); + Some([v[0], v[1], v[2], v[3]]) + }) + .collect() +} + +/// The caption's text matrix (`a b c d e f Tm`), if any. +fn caption_matrix(content: &str) -> Option<[f64; 6]> { + let at = content.find(" Tm")?; + let before: Vec = content[..at].split_whitespace().rev().take(6).map(|x| x.parse().unwrap()).collect(); + Some([before[5], before[4], before[3], before[2], before[1], before[0]]) +} + +fn has(segs: &[[f64; 4]], want: [f64; 4]) -> bool { + segs.iter().any(|s| s.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01)) +} + +#[test] +fn a_positive_leader_length_puts_the_line_above_a_left_to_right_line_and_a_negative_one_below() { + let up = segments(&line_drawing(&table_178_line([72.0, 620.0, 272.0, 620.0], &[("LL", Object::Real(20.0))]))); + assert!(has(&up, [72.0, 640.0, 272.0, 640.0]), "the line is 20 above /L: {up:?}"); + assert!(has(&up, [72.0, 620.0, 72.0, 640.0]) && has(&up, [272.0, 620.0, 272.0, 640.0]), "leader lines from /L up to it: {up:?}"); + assert!(!has(&up, [72.0, 620.0, 272.0, 620.0]), "nothing along /L itself: {up:?}"); + + let down = segments(&line_drawing(&table_178_line([72.0, 540.0, 272.0, 540.0], &[("LL", Object::Real(-20.0)), ("LLE", Object::Real(10.0))]))); + assert!(has(&down, [72.0, 520.0, 272.0, 520.0]), "the line is 20 below /L: {down:?}"); + assert!(has(&down, [72.0, 540.0, 72.0, 510.0]) && has(&down, [272.0, 540.0, 272.0, 510.0]), "leaders run 10 past it: {down:?}"); +} + +#[test] +fn a_leader_offset_leaves_a_gap_and_the_endings_sit_on_the_line_itself() { + let d = table_178_line( + [72.0, 460.0, 272.0, 460.0], + &[("LL", Object::Real(20.0)), ("LLO", Object::Real(10.0)), ("LE", Object::Array(vec![Object::name("OpenArrow"), Object::name("OpenArrow")]))], + ); + let content = line_drawing(&d); + let segs = segments(&content); + assert!(has(&segs, [72.0, 490.0, 272.0, 490.0]), "the line sits |LL| + LLO above /L: {segs:?}"); + assert!(has(&segs, [72.0, 470.0, 72.0, 490.0]) && has(&segs, [272.0, 470.0, 272.0, 490.0]), "leaders start LLO from /L: {segs:?}"); + // The open arrows' tips (`tip l`) are at the ends of the line, not of /L. + assert!(content.contains(" 72 490 l ") && content.contains(" 272 490 l "), "arrows at the moved line: {content}"); + assert!(!content.contains(" 72 460 l "), "no arrow on /L: {content}"); +} + +#[test] +fn an_inline_caption_breaks_the_line_around_its_text() { + let d = + table_178_line([72.0, 380.0, 272.0, 380.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("Inline 50 mm")))]); + let content = line_drawing(&d); + assert!(content.contains("(Inline 50 mm) Tj"), "the caption is drawn: {content}"); + assert!(content.contains("/Helv 10 Tf"), "in Helvetica 10: {content}"); + let m = caption_matrix(&content).expect("text matrix"); + let width = crate::appearance::text_width("Inline 50 mm", 10.0); + assert!((m[4] - (172.0 - width / 2.0)).abs() < 0.01 && m[5] < 380.0 && m[5] > 380.0 - 10.0, "centred on the line: {m:?}"); + let segs = segments(&content); + assert_eq!(segs.len(), 2, "two pieces: {segs:?}"); + assert!(segs.iter().all(|s| s[2] <= 172.0 - width / 2.0 || s[0] >= 172.0 + width / 2.0), "no stroke under the text: {segs:?}"); + assert!(has(&segs, [72.0, 380.0, 172.0 - width / 2.0 - 2.0, 380.0]), "the left piece starts at the line's start: {segs:?}"); +} + +#[test] +fn a_top_caption_sits_above_the_unbroken_line() { + let d = table_178_line( + [72.0, 300.0, 272.0, 300.0], + &[("Cap", Object::Bool(true)), ("CP", Object::name("Top")), ("Contents", Object::String(PdfString::text("Top 50 mm")))], + ); + let content = line_drawing(&d); + let m = caption_matrix(&content).expect("text matrix"); + assert!(m[5] > 300.0 && m[5] < 305.0, "baseline just above the line: {m:?}"); + assert_eq!(segments(&content), vec![[72.0, 300.0, 272.0, 300.0]], "the line is whole"); +} + +#[test] +fn a_caption_offset_moves_it_along_and_off_the_line_with_a_tick() { + let co = |h: f64, v: f64| Object::Array(vec![Object::Real(h), Object::Real(v)]); + let text = ("Contents", Object::String(PdfString::text("Offset 15"))); + let base = caption_matrix(&line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), text.clone()]))).unwrap(); + let content = line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), ("CO", co(0.0, 15.0)), text.clone()])); + let up = caption_matrix(&content).unwrap(); + assert!((up[5] - base[5] - 15.0).abs() < 0.01 && (up[4] - base[4]).abs() < 0.01, "15 up: {base:?} → {up:?}"); + let segs = segments(&content); + assert!(has(&segs, [330.0, 700.0, 530.0, 700.0]), "a caption off the line leaves it whole: {segs:?}"); + assert!(segs.iter().any(|s| s[0] == 430.0 && s[2] == 430.0 && s[1] == 700.0 && s[3] > 705.0), "a tick from the midpoint up: {segs:?}"); + let along = + caption_matrix(&line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), ("CO", co(-30.0, 0.0)), text]))) + .unwrap(); + assert!((along[4] - base[4] + 30.0).abs() < 0.01 && (along[5] - base[5]).abs() < 0.01, "30 left: {base:?} → {along:?}"); +} + +#[test] +fn without_cap_true_no_caption_is_drawn() { + for cap in [None, Some(Object::Bool(false)), Some(Object::Int(1))] { + let mut extra = vec![("Contents", Object::String(PdfString::text("Hidden caption")))]; + if let Some(c) = cap.clone() { + extra.push(("Cap", c)); + } + let d = table_178_line([330.0, 620.0, 530.0, 620.0], &extra); + let content = line_drawing(&d); + assert!(!content.contains("Tj"), "{cap:?}: {content}"); + assert_eq!(segments(&content), vec![[330.0, 620.0, 530.0, 620.0]]); + assert!(crate::appearance::undrawable_text(&d).is_none()); + } +} + +#[test] +fn a_diagonal_lines_leaders_are_perpendicular_and_its_caption_turns_with_it() { + let d = table_178_line( + [330.0, 460.0, 530.0, 560.0], + &[ + ("LL", Object::Real(20.0)), + ("LLE", Object::Real(10.0)), + ("Cap", Object::Bool(true)), + ("Contents", Object::String(PdfString::text("Diagonal"))), + ], + ); + let content = line_drawing(&d); + let segs = segments(&content); + let (ux, uy) = (200.0 / 100f64.hypot(200.0), 100.0 / 100f64.hypot(200.0)); + let leaders: Vec<&[f64; 4]> = segs.iter().filter(|s| (s[2] - s[0]) * ux + (s[3] - s[1]) * uy < 1e-2).collect(); + assert_eq!(leaders.len(), 2, "two leader lines across the line: {segs:?}"); + for l in leaders { + let (dx, dy) = (l[2] - l[0], l[3] - l[1]); + assert!((dx.hypot(dy) - 30.0).abs() < 0.01, "|LL| + LLE long: {l:?}"); + assert!(dx < 0.0 && dy > 0.0, "up and to the left (left of travel): {l:?}"); + } + let m = caption_matrix(&content).expect("caption"); + assert!((m[0] - ux).abs() < 1e-3 && (m[1] - uy).abs() < 1e-3, "the text runs along the line: {m:?}"); + // Drawn right to left, it still reads left to right. + let back = caption_matrix(&line_drawing(&table_178_line( + [530.0, 560.0, 330.0, 460.0], + &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("x")))], + ))) + .unwrap(); + assert!(back[0] > 0.0, "{back:?}"); +} + +#[test] +fn a_caption_from_rich_text_and_one_the_font_cant_draw() { + let rc = "

Dim & 50 mm <x>

"; + let d = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("RC", Object::String(PdfString::text(rc)))]); + let s = crate::appearance::build(&d).unwrap().decoded().unwrap(); + assert!(s.windows(17).any(|w| w == b"(Dim & 50 mm )"), "{}", String::from_utf8_lossy(&s)); + let cjk = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("長さ")))]); + assert_eq!(crate::appearance::undrawable_text(&cjk), Some(("the line's caption", '長'))); + // A PdfCraft measurement's label is its caption: the comment text isn't drawn a second time. + let mut measured = cjk.clone(); + measured.set(b"PCMeasureValue".to_vec(), Object::String(PdfString::text("5 mm"))); + assert_eq!(crate::appearance::undrawable_text(&measured), None); + let content = line_drawing(&measured); + assert_eq!(content.matches(" Tj").count(), 1, "{content}"); +} + +#[test] +fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { + let cap = ("Cap", Object::Bool(true)); + let text = ("Contents", Object::String(PdfString::text(&"w".repeat(10_000)))); + // A zero-length line has no direction: no leader lines, no caption, no NaN. + let point = + line_drawing(&table_178_line([50.0, 50.0, 50.0, 50.0], &[("LL", Object::Real(20.0)), ("LLE", Object::Real(5.0)), cap.clone(), text.clone()])); + assert!(!point.contains("NaN") && !point.contains("inf") && !point.contains("Tj"), "{point}"); + assert_eq!(segments(&point).len(), 1); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MAX] { + let d = table_178_line( + [0.0, 0.0, 100.0, 0.0], + &[ + ("LL", Object::Real(bad)), + ("LLE", Object::Real(bad)), + ("LLO", Object::Real(bad)), + ("CO", Object::Array(vec![Object::Real(bad), Object::Real(bad)])), + cap.clone(), + text.clone(), + ], + ); + let content = line_drawing(&d); + assert!(!content.contains("NaN") && !content.contains("inf"), "{bad}: {content}"); + for s in segments(&content) { + assert!(s.iter().all(|x| x.abs() <= 50_000.0), "{bad}: {s:?}"); + } + } + // Negative LLE and LLO count as 0. + let neg = segments(&line_drawing(&table_178_line( + [0.0, 0.0, 100.0, 0.0], + &[("LL", Object::Real(10.0)), ("LLE", Object::Real(-5.0)), ("LLO", Object::Real(-5.0))], + ))); + assert!(has(&neg, [0.0, 0.0, 0.0, 10.0]) && has(&neg, [0.0, 10.0, 100.0, 10.0]), "{neg:?}"); + // Huge endpoints whose midpoint would overflow fall back to the plain line. + let huge = line_drawing(&table_178_line([f64::MAX, f64::MAX, f64::MAX / 2.0, f64::MAX], &[("LL", Object::Real(20.0)), cap, text])); + assert!(!huge.contains("NaN") && !huge.contains("inf"), "{huge}"); + // The caption is cut to 256 characters. + let long = line_drawing(&table_178_line( + [0.0, 0.0, 100.0, 0.0], + &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text(&"w".repeat(10_000))))], + )); + assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); + assert!(!long.contains(&"w".repeat(257))); +} diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs new file mode 100644 index 0000000000..bd3409b5ae --- /dev/null +++ b/crates/annot/src/types.rs @@ -0,0 +1,690 @@ +use super::create::normalize; +use super::{ObjRef, appearance}; + +pub type Rgb = [f64; 3]; + +/// Text markup kinds (§12.5.6.10). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Markup { + Highlight, + Underline, + StrikeOut, + Squiggly, +} + +impl Markup { + fn subtype(self) -> &'static str { + match self { + Markup::Highlight => "Highlight", + Markup::Underline => "Underline", + Markup::StrikeOut => "StrikeOut", + Markup::Squiggly => "Squiggly", + } + } +} + +/// Text-annotation icon names (`/Name`). The first seven are the standard names ISO 32000-2 +/// §12.5.6.4, Table 175 asks every viewer to draw; the rest are further names in common use, which +/// the same table allows ("Additional names may be supported as well"). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum NoteIcon { + #[default] + Comment, + Note, + Help, + Insert, + Key, + NewParagraph, + Paragraph, + Check, + Circle, + Cross, + CrossHairs, + Star, + RightArrow, + UpArrow, +} + +impl NoteIcon { + pub fn name(self) -> &'static str { + match self { + NoteIcon::Comment => "Comment", + NoteIcon::Note => "Note", + NoteIcon::Help => "Help", + NoteIcon::Insert => "Insert", + NoteIcon::Key => "Key", + NoteIcon::NewParagraph => "NewParagraph", + NoteIcon::Paragraph => "Paragraph", + NoteIcon::Check => "Check", + NoteIcon::Circle => "Circle", + NoteIcon::Cross => "Cross", + NoteIcon::CrossHairs => "CrossHairs", + NoteIcon::Star => "Star", + NoteIcon::RightArrow => "RightArrow", + NoteIcon::UpArrow => "UpArrow", + } + } + + /// Every icon, the standard names first. + pub const ALL: [NoteIcon; 14] = [ + Self::Comment, + Self::Note, + Self::Help, + Self::Insert, + Self::Key, + Self::NewParagraph, + Self::Paragraph, + Self::Check, + Self::Circle, + Self::Cross, + Self::CrossHairs, + Self::Star, + Self::RightArrow, + Self::UpArrow, + ]; + + pub fn from_name(n: &str) -> Option { + Self::ALL.into_iter().find(|i| i.name() == n) + } +} + +/// Fill & Sign marks (Acrobat's ✓, ✕, ●, ─). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FillMark { + Check, + Cross, + Dot, + Line, +} + +impl FillMark { + /// The `/Name` of the stamp PdfCraft draws for it. + pub fn name(self) -> &'static str { + match self { + FillMark::Check => "PCCheck", + FillMark::Cross => "PCCross", + FillMark::Dot => "PCDot", + FillMark::Line => "PCLine", + } + } + + pub fn label(self) -> &'static str { + match self { + FillMark::Check => "Checkmark", + FillMark::Cross => "Cross", + FillMark::Dot => "Dot", + FillMark::Line => "Line", + } + } + + /// The user-space box of the mark placed at `at` on a page turned by `rotation`: centred + /// there and sized in displayed axes, so a line runs across the page as displayed (#2392). + pub fn rect_at(self, at: [f64; 2], rotation: i64) -> [f64; 4] { + let (w, h) = if self == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; + let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) }; + [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0] + } +} + +/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum StampKind { + // Standard business. + Approved, + Completed, + Confidential, + Draft, + Final, + ForComment, + ForPublicRelease, + InformationOnly, + NotApproved, + NotForPublicRelease, + PreliminaryResults, + Void, + // Sign here. + Accepted, + InitialHere, + Rejected, + SignHere, + Witness, + // Dynamic (with a "By … at …" line). + DynApproved, + DynConfidential, + DynReceived, + DynReviewed, + DynRevised, +} + +/// The palette's sections. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StampGroup { + Dynamic, + SignHere, + StandardBusiness, +} + +impl StampKind { + pub const ALL: [StampKind; 22] = [ + StampKind::DynApproved, + StampKind::DynConfidential, + StampKind::DynReceived, + StampKind::DynReviewed, + StampKind::DynRevised, + StampKind::Accepted, + StampKind::InitialHere, + StampKind::Rejected, + StampKind::SignHere, + StampKind::Witness, + StampKind::Approved, + StampKind::Completed, + StampKind::Confidential, + StampKind::Draft, + StampKind::Final, + StampKind::ForComment, + StampKind::ForPublicRelease, + StampKind::InformationOnly, + StampKind::NotApproved, + StampKind::NotForPublicRelease, + StampKind::PreliminaryResults, + StampKind::Void, + ]; + + pub fn group(self) -> StampGroup { + use StampKind::*; + match self { + DynApproved | DynConfidential | DynReceived | DynReviewed | DynRevised => StampGroup::Dynamic, + Accepted | InitialHere | Rejected | SignHere | Witness => StampGroup::SignHere, + _ => StampGroup::StandardBusiness, + } + } + + /// The text on the stamp. + pub fn label(self) -> &'static str { + use StampKind::*; + match self { + Approved | DynApproved => "APPROVED", + Completed => "COMPLETED", + Confidential | DynConfidential => "CONFIDENTIAL", + Draft => "DRAFT", + Final => "FINAL", + ForComment => "FOR COMMENT", + ForPublicRelease => "FOR PUBLIC RELEASE", + InformationOnly => "INFORMATION ONLY", + NotApproved => "NOT APPROVED", + NotForPublicRelease => "NOT FOR PUBLIC RELEASE", + PreliminaryResults => "PRELIMINARY RESULTS", + Void => "VOID", + Accepted => "ACCEPTED", + InitialHere => "INITIAL HERE", + Rejected => "REJECTED", + SignHere => "SIGN HERE", + Witness => "WITNESS", + DynReceived => "RECEIVED", + DynReviewed => "REVIEWED", + DynRevised => "REVISED", + } + } + + /// `/Name`: the standard stamp names of ISO 32000-2 Table 184 where one exists. + pub fn name(self) -> &'static str { + use StampKind::*; + match self { + Approved => "Approved", + Confidential => "Confidential", + Draft => "Draft", + Final => "Final", + ForComment => "ForComment", + ForPublicRelease => "ForPublicRelease", + NotApproved => "NotApproved", + NotForPublicRelease => "NotForPublicRelease", + Completed => "PCCompleted", + InformationOnly => "PCInformationOnly", + PreliminaryResults => "PCPreliminaryResults", + Void => "PCVoid", + Accepted => "PCAccepted", + InitialHere => "PCInitialHere", + Rejected => "PCRejected", + SignHere => "PCSignHere", + Witness => "PCWitness", + DynApproved => "PCDynApproved", + DynConfidential => "PCDynConfidential", + DynReceived => "PCDynReceived", + DynReviewed => "PCDynReviewed", + DynRevised => "PCDynRevised", + } + } + + pub fn from_name(n: &[u8]) -> Option { + Self::ALL.into_iter().find(|k| k.name().as_bytes() == n) + } + + /// The stamp's colour: green for approval, red for refusal and restriction, blue otherwise. + pub fn color(self) -> Rgb { + use StampKind::*; + match self { + Approved | Completed | Final | Accepted | DynApproved | DynReceived | DynReviewed => [0.13, 0.55, 0.13], + NotApproved | Rejected | Void | Confidential | NotForPublicRelease | DynConfidential => [0.80, 0.10, 0.10], + SignHere | InitialHere | Witness => [0.85, 0.35, 0.05], + _ => [0.10, 0.30, 0.70], + } + } + + /// The stamp's size (points) for its label, as placed with a click. + pub fn size(self) -> (f64, f64) { + let w = appearance::text_width(self.label(), 16.0) * 1.12 + 24.0; + let h = if self.group() == StampGroup::Dynamic { 42.0 } else { 30.0 }; + let w = if self.group() == StampGroup::SignHere { w + 14.0 } else { w }; + (w.max(80.0), h) + } +} + +/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineEnding { + None, + Square, + Circle, + Diamond, + OpenArrow, + ClosedArrow, + Butt, + ROpenArrow, + RClosedArrow, + Slash, +} + +impl LineEnding { + pub const ALL: [Self; 10] = [ + Self::None, + Self::Square, + Self::Circle, + Self::Diamond, + Self::OpenArrow, + Self::ClosedArrow, + Self::Butt, + Self::ROpenArrow, + Self::RClosedArrow, + Self::Slash, + ]; + + pub fn name(self) -> &'static str { + match self { + Self::None => "None", + Self::Square => "Square", + Self::Circle => "Circle", + Self::Diamond => "Diamond", + Self::OpenArrow => "OpenArrow", + Self::ClosedArrow => "ClosedArrow", + Self::Butt => "Butt", + Self::ROpenArrow => "ROpenArrow", + Self::RClosedArrow => "RClosedArrow", + Self::Slash => "Slash", + } + } + + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|ending| ending.name() == name) + } +} + +/// Geometry of a new comment, in PDF user space of its page. +#[derive(Clone, Debug, PartialEq)] +pub enum Shape { + /// A sticky note whose icon's top-left corner is at `at`. + Note { + at: [f64; 2], + icon: NoteIcon, + }, + /// Text markup over quadrilaterals: `[x1 y1 x2 y2 x3 y3 x4 y4]` = top-left, top-right, + /// bottom-left, bottom-right (the order Acrobat writes, §12.5.6.10). + TextMarkup { + kind: Markup, + quads: Vec<[f64; 8]>, + }, + Rectangle { + rect: [f64; 4], + }, + Oval { + rect: [f64; 4], + }, + /// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool). + Line { + from: [f64; 2], + to: [f64; 2], + start: LineEnding, + end: LineEnding, + }, + /// Freehand strokes (Draw tool). + Ink { + strokes: Vec>, + }, + /// A text box (FreeText) showing the comment's contents. + TextBox { + rect: [f64; 4], + font_size: f64, + }, + /// Fill & Sign text typed onto the page (FreeText, typewriter intent, no border). + Typewriter { + rect: [f64; 4], + font_size: f64, + }, + /// A Fill & Sign mark in `rect`. + Mark { + rect: [f64; 4], + mark: FillMark, + }, + /// A drawn signature (ink strokes). + Signature { + strokes: Vec>, + }, + /// A rubber stamp from the stamp palette; `by` is the dynamic stamps' second line. + Stamp { + rect: [f64; 4], + stamp: StampKind, + by: Option, + }, + /// A typed signature or initials: filled outlines (each contour as points normalised to + /// `rect`, 0–1, y up), drawn with the non-zero winding rule. + TypedSignature { + rect: [f64; 4], + contours: Vec>, + }, + /// A custom stamp: a picture already in the document (an image XObject, or a form XObject + /// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. + CustomStamp { + rect: [f64; 4], + name: String, + picture: ObjRef, + image: bool, + size: (f64, f64), + }, + /// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad + /// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`. + Redact { + quads: Vec<[f64; 8]>, + overlay: String, + look: OverlayLook, + }, + /// A closed polygon (Polygon tool); with `cloud`, Acrobat's Cloud tool: the same polygon + /// with a cloudy border (`/BE /S /C`, `/IT /PolygonCloud`). + Polygon { + vertices: Vec<[f64; 2]>, + cloud: bool, + }, + /// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last. + PolyLine { + vertices: Vec<[f64; 2]>, + start: LineEnding, + end: LineEnding, + }, + /// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via + /// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`). + Callout { + rect: [f64; 4], + knee: [f64; 2], + point: [f64; 2], + font_size: f64, + ending: LineEnding, + }, + /// Insert text: a caret in `rect` (its point at the top centre). + Caret { + rect: [f64; 4], + }, + /// Attach a file as a comment: an icon whose top-left corner is at `at`, holding `data` + /// (embedded) under the name `file`. + Attachment { + at: [f64; 2], + icon: AttachIcon, + file: String, + data: Vec, + }, +} + +/// The standard file attachment icons (§12.5.6.15). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AttachIcon { + #[default] + PushPin, + Paperclip, + Graph, + Tag, +} + +impl AttachIcon { + pub const ALL: [AttachIcon; 4] = [AttachIcon::PushPin, AttachIcon::Paperclip, AttachIcon::Graph, AttachIcon::Tag]; + pub fn name(self) -> &'static str { + match self { + AttachIcon::PushPin => "PushPin", + AttachIcon::Paperclip => "Paperclip", + AttachIcon::Graph => "Graph", + AttachIcon::Tag => "Tag", + } + } + pub fn from_name(n: &str) -> Option { + Self::ALL.into_iter().find(|i| i.name().eq_ignore_ascii_case(n)) + } +} + +/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left, +/// bottom-right). +pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { + let [x0, y0, x1, y1] = normalize(r); + [x0, y1, x1, y1, x0, y0, x1, y0] +} + +impl Shape { + pub fn subtype(&self) -> &'static str { + match self { + Shape::Note { .. } => "Text", + Shape::TextMarkup { kind, .. } => kind.subtype(), + Shape::Rectangle { .. } => "Square", + Shape::Oval { .. } => "Circle", + Shape::Line { .. } => "Line", + Shape::Ink { .. } | Shape::Signature { .. } => "Ink", + Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText", + Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp", + Shape::Redact { .. } => "Redact", + Shape::Polygon { .. } => "Polygon", + Shape::PolyLine { .. } => "PolyLine", + Shape::Caret { .. } => "Caret", + Shape::Attachment { .. } => "FileAttachment", + } + } +} + +/// The standard fonts of redaction overlay text. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OverlayFont { + #[default] + Helvetica, + Times, + Courier, +} + +impl OverlayFont { + pub const ALL: [OverlayFont; 3] = [OverlayFont::Helvetica, OverlayFont::Times, OverlayFont::Courier]; + + pub fn name(self) -> &'static str { + match self { + OverlayFont::Helvetica => "Helvetica", + OverlayFont::Times => "Times Roman", + OverlayFont::Courier => "Courier", + } + } + + /// The `/DA` font resource name (Acrobat's form font names). + pub fn resource(self) -> &'static str { + match self { + OverlayFont::Helvetica => "Helv", + OverlayFont::Times => "TiRo", + OverlayFont::Courier => "Cour", + } + } + + pub fn from_resource(n: &str) -> OverlayFont { + match n { + "TiRo" | "Times-Roman" | "TimesRoman" => OverlayFont::Times, + "Cour" | "Courier" => OverlayFont::Courier, + _ => OverlayFont::Helvetica, + } + } +} + +/// How redaction overlay text is drawn (Redaction Properties ▸ Appearance). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OverlayLook { + pub font: OverlayFont, + /// Points; 0 = auto-size to fit the area. + pub size: f64, + pub color: Rgb, + /// 0 left, 1 centre, 2 right. + pub align: u8, + /// Repeat the text to fill the area. + pub repeat: bool, +} + +impl Default for OverlayLook { + fn default() -> Self { + Self { font: OverlayFont::Helvetica, size: 0.0, color: [1.0, 0.0, 0.0], align: 1, repeat: false } + } +} + +/// How a comment looks. +#[derive(Clone, Debug, PartialEq)] +pub struct Style { + /// Stroke colour (text colour for text boxes, icon colour for notes). + pub color: Rgb, + /// 0–1 (`/CA`). + pub opacity: f64, + /// Border / stroke width in points (0 = none). + pub width: f64, + /// Interior fill for rectangles and ovals (`/IC`), background for text boxes. + pub fill: Option, +} + +/// The style properties changed by one annotation edit. `None` leaves a property unchanged. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StyleChange { + pub color: Option, + pub opacity: Option, + pub width: Option, + pub endings: Option>, +} + +impl StyleChange { + pub fn new(color: Option, opacity: Option, width: Option, endings: Option>) -> Self { + Self { color, opacity, width, endings } + } +} + +/// Inputs for replacing selected text with a grouped strikeout and caret annotation. +pub struct TextReplacement<'a> { + pub page: usize, + pub quads: &'a [[f64; 8]], + pub replacement: &'a str, + pub author: &'a str, + pub strike: &'a Style, + pub caret: &'a Style, + pub strike_meta: &'a Meta, + pub caret_meta: &'a Meta, +} + +impl Default for Style { + fn default() -> Self { + Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } + } +} + +impl Style { + /// The default look of each commenting tool. + pub fn default_for(shape: &Shape) -> Self { + let (color, width) = match shape { + Shape::Note { .. } => ([1.0, 0.82, 0.0], 1.0), + Shape::TextMarkup { kind: Markup::Highlight, .. } => ([1.0, 0.94, 0.0], 1.0), + Shape::TextMarkup { kind: Markup::Underline, .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => ([0.89, 0.13, 0.13], 1.0), + Shape::TextMarkup { kind: Markup::Squiggly, .. } => ([0.18, 0.62, 0.36], 1.0), + Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } | Shape::Polygon { .. } | Shape::PolyLine { .. } => { + ([0.89, 0.13, 0.13], 2.0) + } + Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0), + Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0), + Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0), + Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), + Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), + Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5), + Shape::Stamp { stamp, .. } => (stamp.color(), 2.0), + Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0), + Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0), + // Red outline while marked; a black box once applied. + Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) }, + }; + Self { color, opacity: 1.0, width, fill: None } + } +} + +/// A comment to add. +#[derive(Clone, Debug, PartialEq)] +pub struct NewAnnotation { + /// 0-based page index. + pub page: usize, + pub shape: Shape, + pub style: Style, + /// The comment text (`/Contents`); the text shown by a text box. + pub contents: String, + /// `/T` (shown as the comment's author). + pub author: String, +} + +/// Values stamped onto what an edit creates or changes, supplied by the caller so edits stay +/// deterministic: a PDF date (`D:…`) and a unique id for `/NM`. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Meta { + pub date: Option, + pub id: String, +} + +/// Review states a reply can set (§12.5.6.3, Table 172; Acrobat's "Set status"). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReviewState { + None, + Accepted, + Rejected, + Cancelled, + Completed, +} + +impl ReviewState { + pub fn name(self) -> &'static str { + match self { + ReviewState::None => "None", + ReviewState::Accepted => "Accepted", + ReviewState::Rejected => "Rejected", + ReviewState::Cancelled => "Cancelled", + ReviewState::Completed => "Completed", + } + } + + pub fn from_name(n: &str) -> Option { + [Self::None, Self::Accepted, Self::Rejected, Self::Cancelled, Self::Completed].into_iter().find(|s| s.name().eq_ignore_ascii_case(n)) + } +} + +// ── building ──────────────────────────────────────────────────────────────────────────────── + +/// Annotation flags (§12.5.3). +pub(crate) const FLAG_PRINT: i64 = 4; + +pub(crate) const FLAG_NO_ZOOM: i64 = 8; + +pub(crate) const FLAG_NO_ROTATE: i64 = 16; + +pub(crate) const FLAG_HIDDEN: i64 = 2; + +pub(crate) const FLAG_NO_VIEW: i64 = 32; + +pub(crate) const FLAG_LOCKED: i64 = 128; + +/// Size of a note icon (points, unscaled by zoom). +pub const NOTE_SIZE: f64 = 20.0; diff --git a/crates/audit/README.md b/crates/audit/README.md index bda5cc977e..cfa2979c21 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -10,7 +10,7 @@ It reports two kinds of finding: - **faux redaction**: an opaque filled rectangle painted *after* text it overlaps, dark enough against the text's colour (WCAG contrast below 3) to hide it, while the text is still - extractable underneath; + extractable underneath; opaque filled Square annotations count as covers too; - **unapplied mark**: a Redact annotation that was marked but never applied. A properly applied redaction removes the text, so it never flags. Page backgrounds, table @@ -33,7 +33,12 @@ Limits, by design and documented rather than hidden: less than 40% of its words (a sliver of a long value) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper -than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings". +than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-word +comparisons on a page, or more than 64 MiB of covered text in the report is an error, never "no +findings". + +A document whose permissions withhold copying (Table 22, bit 5) still gets every finding, with +an empty `covered_text`: the audit hands out no text that copying would refuse. Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 3df666c190..041ba9ddd8 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -16,7 +16,7 @@ //! Positions compare lexicographically in paint order. use pdfcraft_content::{Matrix, Op, Pieces, parse}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::AuditError; @@ -109,6 +109,9 @@ struct Walker<'a> { pieces: &'a Pieces, out: Vec, ops_seen: usize, + /// Decoded bytes and form entries left for the page, its forms included: spending either + /// fails the audit. + budget: DecodeBudget, } impl<'a> Walker<'a> { @@ -144,10 +147,11 @@ impl<'a> Walker<'a> { match op.op.as_slice() { b"q" => stack.push(gs.clone()), b"Q" => { + // The current path is not graphics state (ISO 32000-2 §8.4.1): `re Q f` still + // fills the rectangle, whose corners are already in user space. if let Some(g) = stack.pop() { *gs = g; } - path = Path::Empty; } b"cm" => { let before = gs.ctm; @@ -258,7 +262,7 @@ impl<'a> Walker<'a> { let resolved = self.doc.resolve(entry); let Object::Stream(stream) = &*resolved else { return Ok(()) }; let dict = &stream.dict; - if dict.name(b"Subtype") != Some(b"Form") { + if crate::subtype(self.doc, dict)?.as_deref() != Some(b"Form") { return Ok(()); } let matrix = dict @@ -271,8 +275,11 @@ impl<'a> Walker<'a> { }) .unwrap_or(Matrix::IDENTITY); let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + if !self.budget.enter_form() { + return Err(AuditError::TooManyForms); + } // A form stream that cannot be decoded fails the audit: it might hide a cover. - let data = stream.decoded_strict()?; + let data = self.budget.decode_strict(stream)?; let ops = parse(&data).ops; // The XObject matrix maps form space into the user space active at `Do`. let mut sub_gs = gs.clone(); @@ -304,7 +311,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { /// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint /// positions. A page's content streams are walked once, joined, with one graphics state, so /// state set in an earlier stream is still in force in the later ones. Any content stream that -/// cannot be decoded is an audit failure, never an empty result. +/// cannot be decoded, or content and forms that decode or enter past one page's [`DecodeBudget`] +/// in all, is an audit failure, never an empty result. pub fn filled_rects(doc: &Document, page: &Dict) -> Result, AuditError> { let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); let contents: Vec = match page.get(b"Contents") { @@ -314,15 +322,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi _ => vec![c.clone()], }, }; + let mut budget = DecodeBudget::page(); let mut streams = Vec::with_capacity(contents.len()); for c in &contents { if let Object::Stream(s) = &*doc.resolve(c) { - streams.push(s.decoded_strict()?); + streams.push(budget.decode_strict(s)?); } } let pieces = Pieces::join(&streams); let parsed = pieces.parse(); - let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 }; + let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget }; let mut gs = Gs::default(); let mut stack = Vec::new(); w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?; diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 723d086d63..f2bcf63358 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -12,8 +12,9 @@ //! Detection limits (documented, not hidden): `re` rectangles and single closed `m`/`l` //! subpaths whose four corners form an axis-aligned rectangle in user space (#1510) are //! considered when closed by a fill operator — other filled paths, shadings and patterns are -//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; annotation -//! rectangles are compared in unrotated user space, so pages with `/Rotate` may misalign; +//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; opaque +//! filled `/Square` annotations count as covers, but annotation rectangles are compared in +//! unrotated user space, so pages with `/Rotate` may misalign; //! covers inside form XObjects are found, but only text drawn directly in the page content //! is checked under them, so text inside a form XObject is not audited. //! @@ -64,7 +65,7 @@ pub struct AuditFinding { /// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`. pub rect: [f64; 4], /// The extractable text the cover hides, lines joined with single spaces and trimmed. - /// Possibly empty when nothing decodable remains under the cover. + /// Empty when the document's permissions withhold copying (see [`audit_redactions`]). pub covered_text: String, } @@ -76,8 +77,14 @@ pub enum AuditError { TooManyOps, #[error("form XObjects nest deeper than the audit follows")] TooDeep, + #[error("content walk entered more form XObjects than the audit follows")] + TooManyForms, #[error("page has a malformed /Annots entry")] BadAnnots, + #[error("page has more covers and text lines than the audit compares")] + TooManyComparisons, + #[error("covered text exceeds the audit's report budget")] + TooMuchOutput, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), #[error(transparent)] @@ -94,39 +101,122 @@ const WORD_FRACTION: f64 = 0.5; const COVER_CONTRAST: f64 = 3.0; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any -/// content that cannot be decoded is returned as an error, never as an empty finding list. +/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is +/// returned as an error, never as an empty finding list. +/// +/// When the document's permissions withhold copying (ISO 32000-2 Table 22, bit 5), findings are +/// still reported but their `covered_text` is empty: the audit hands out no text that copying +/// would refuse. pub fn audit_redactions(doc: &Document) -> Result, AuditError> { + audit(doc, Limits::DEFAULT) +} + +/// Work and output bounds for one audit. +#[derive(Clone, Copy)] +struct Limits { + /// Cover-against-line comparisons per page, counting a line once per word box it has (at + /// least once), since [`covers`] walks them. + comparisons: usize, + /// Bytes of covered text the findings report. + covered_text: usize, +} + +impl Limits { + /// Every cover is compared with every line of its page, and each finding repeats the text + /// it covers, so both grow with the square of the content: a few megabytes of covers over + /// one long line would otherwise take hours and gigabytes. + const DEFAULT: Limits = Limits { comparisons: 50_000_000, covered_text: 64 << 20 }; +} + +/// The work and report the audit has spent against its [`Limits`]. +struct Budget { + limits: Limits, + comparisons: usize, + /// What comparing one cover with every line of the current page costs. + page_cost: usize, + covered_text: usize, +} + +impl Budget { + /// Resets the comparison count for a page with these `lines`. A line costs its word boxes + /// or its runs, whichever are more (at least one): a cover check walks the boxes, and the + /// covered text walks the runs, which can draw no words at all (`( ) Tj`). + fn start_page(&mut self, lines: &[TextLine]) { + self.comparisons = 0; + self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(line.runs().count()).max(1))); + } + + /// The text `cover` hides in the `lines` that `hides` ([`covered_words`]), joined with single + /// spaces, or `None` when they show no text. Without `copying` the text is empty and never + /// assembled: one long line under many covers would otherwise be copied once per cover, + /// outside both bounds. + fn hidden_text( + &mut self, + lines: &[TextLine], + copying: bool, + cover: &[f64; 4], + hides: impl Fn(&TextLine) -> bool, + ) -> Result, AuditError> { + self.comparisons = self.comparisons.saturating_add(self.page_cost); + if self.comparisons > self.limits.comparisons { + return Err(AuditError::TooManyComparisons); + } + if !copying { + return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new)); + } + let mut covered = String::new(); + for line in lines.iter().filter(|l| hides(l)) { + if !covered.is_empty() { + covered.push(' '); + } + covered_words(cover, line, &mut covered); + } + Ok((!covered.trim().is_empty()).then_some(covered)) + } + + /// The text a finding reports for `covered`: none when copying is withheld. Charges what it + /// reports against the output bound. + fn report_text(&mut self, covered: String, copying: bool) -> Result { + let reported = if copying { covered } else { String::new() }; + self.covered_text = self.covered_text.saturating_add(reported.len()); + if self.covered_text > self.limits.covered_text { + return Err(AuditError::TooMuchOutput); + } + Ok(reported) + } +} + +fn audit(doc: &Document, limits: Limits) -> Result, AuditError> { + let copying = doc.permissions().is_none_or(|p| p.copy()); + let mut budget = Budget { limits, comparisons: 0, page_cost: 0, covered_text: 0 }; let mut out = Vec::new(); for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() { - let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?; + // The glyphs themselves: `/ActualText` is the file's own claim about them, and an empty + // or substitute one must not hide text a cover sits on. + let lines = pdfcraft_edit::glyph_text_lines(doc, page).map_err(|e| AuditError::Text(n, e.to_string()))?; + budget.start_page(&lines); // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { - let mut covered = String::new(); - for line in &lines { - if is_cover(&r, line) { - if !covered.is_empty() { - covered.push(' '); - } - covered.push_str(line.text.trim()); - } - } - if !covered.trim().is_empty() { - out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered }); + if let Some(covered) = budget.hidden_text(&lines, copying, &r.rect, |line| is_cover(&r, line))? { + let covered_text = budget.report_text(covered, copying)?; + out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text }); } } - // 2. Redact annotations that were never applied. + // 2. Annotation covers over extractable text. for annot in annots(doc, &page.dict)? { - let mut covered = String::new(); - for line in &lines { - if covers(&annot, line) { - if !covered.is_empty() { - covered.push(' '); - } - covered.push_str(line.text.trim()); - } - } - if !covered.trim().is_empty() { - out.push(AuditFinding { page: n, kind: FindingKind::UnappliedMark, rect: annot, covered_text: covered }); + let cover = match &annot { + AnnotationCover::Redact(rect) | AnnotationCover::Square { rect, .. } => *rect, + }; + if let Some(covered) = budget.hidden_text(&lines, copying, &cover, |line| match &annot { + AnnotationCover::Redact(rect) => covers(rect, line), + AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, + })? { + let (kind, rect) = match annot { + AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect), + AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect), + }; + let covered_text = budget.report_text(covered, copying)?; + out.push(AuditFinding { page: n, kind, rect, covered_text }); } } } @@ -134,11 +224,13 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> } /// A filled rectangle is a faux-redaction cover for a text line iff it was painted strictly -/// after the line, overlaps it substantially, and is dark enough against the line's color to -/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the -/// contrast check and are not flagged. +/// after the start of the line, overlaps it substantially, and is dark enough against the line's +/// color to make the text unreadable. Light covers (table shading, highlights) over dark text fail +/// the contrast check and are not flagged. A line can join runs painted before and after the +/// cover; the later ones are drawn on it in the same low-contrast color, so they are unreadable +/// too. fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { - rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST + rect.pos > line.first_paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } /// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough: @@ -170,6 +262,34 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool { inked || (boxed && (line.ink.is_empty() || word_intersects())) } +/// Appends the text of `line` that `cover` lies on to `out`: the runs whose words it overlaps +/// (#4164), so the words of the line painted beside the cover, which stay visible, are not +/// reported as hidden. Neighbouring runs are joined as the line joins them, so a word split +/// across runs stays one word; runs with others left out between them are spaced. The whole +/// line when its runs can't say: a single run, a line whose text was reordered for reading, or +/// a cover that overlaps the line's box but none of its words. +fn covered_words(cover: &[f64; 4], line: &TextLine, out: &mut String) { + let overlaps = |w: &[f64; 4]| area(&[cover[0].max(w[0]), cover[1].max(w[1]), cover[2].min(w[2]), cover[3].min(w[3])]) > 0.0; + let mut words = String::new(); + let mut last = None; + for (i, (text, ink, spaced)) in line.runs().enumerate() { + if text.trim().is_empty() || !ink.iter().any(overlaps) { + continue; + } + let apart = last.is_some_and(|l: usize| l.saturating_add(1) != i || spaced); + if apart && !words.ends_with(char::is_whitespace) && !text.starts_with(char::is_whitespace) { + words.push(' '); + } + words.push_str(text); + last = Some(i); + } + if line.runs().nth(1).is_none() || last.is_none() { + out.push_str(line.text.trim()); + } else { + out.push_str(words.trim()); + } +} + /// The area of a rectangle, 0 when it is empty or inverted. fn area(r: &[f64; 4]) -> f64 { (r[2] - r[0]).max(0.0) * (r[3] - r[1]).max(0.0) @@ -191,9 +311,14 @@ fn contrast(a: [f64; 3], b: [f64; 3]) -> f64 { (hi + 0.05) / (lo + 0.05) } -/// Rectangles of `/Redact` annotations on a page (unrotated user space). A missing `/Annots` -/// means no marks; a malformed one fails the audit rather than silently passing. -fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { +enum AnnotationCover { + Redact([f64; 4]), + Square { rect: [f64; 4], rgb: [f64; 3] }, +} + +/// Opaque annotation covers on a page (unrotated user space). A missing `/Annots` means no +/// covers; a malformed one fails the audit rather than silently passing. +fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) }; let resolved = doc.resolve(a); let items: Vec = match &*resolved { @@ -207,16 +332,56 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { pdfcraft_cos::Object::Dict(d) => d.clone(), _ => continue, }; - if dict.name(b"Subtype") != Some(b"Redact") { - continue; - } let v: Vec = match dict.get(b"Rect") { None => continue, Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(), }; - if v.len() == 4 && v.iter().all(|x| x.is_finite()) { - out.push([v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]); + if v.len() != 4 || !v.iter().all(|x| x.is_finite()) { + continue; + } + let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; + match subtype(doc, &dict)?.as_deref() { + Some(b"Redact") => out.push(AnnotationCover::Redact(rect)), + Some(b"Square") => { + let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & 2 != 0 || flags & 32 != 0 { + continue; + } + let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0); + if alpha >= 1.0 + && let Some(rgb) = annotation_color(doc, &dict, b"IC") + { + out.push(AnnotationCover::Square { rect, rgb }); + } + } + _ => {} } } Ok(out) } + +/// The `/Subtype` name of `dict`, following an indirect reference. A reference that cannot be +/// loaded fails the audit: the subtype decides whether the object is checked at all. +fn subtype(doc: &Document, dict: &Dict) -> Result>, AuditError> { + let name = match dict.get(b"Subtype") { + Some(pdfcraft_cos::Object::Ref(r)) => doc.try_get(r.num)?.as_name().map(<[u8]>::to_vec), + other => other.and_then(pdfcraft_cos::Object::as_name).map(<[u8]>::to_vec), + }; + Ok(name) +} + +fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> { + let values: Vec = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::>()?; + if !values.iter().all(|v| v.is_finite()) { + return None; + } + match values.as_slice() { + [g] => Some([g.clamp(0.0, 1.0); 3]), + [r, g, b] => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]), + [c, m, y, k] => { + let (c, m, y, k) = (c.clamp(0.0, 1.0), m.clamp(0.0, 1.0), y.clamp(0.0, 1.0), k.clamp(0.0, 1.0)); + Some([(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]) + } + _ => None, + } +} diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 63d9220cca..e5fb9ab683 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -216,6 +216,69 @@ fn a_box_grazing_a_word_is_not_flagged() { assert!(findings.is_empty(), "{findings:?}"); } +#[test] +fn opaque_filled_square_annotation_over_text_is_found() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(content); + b.add(FONT); + b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] >>"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::CoveredText); + assert_eq!(findings[0].rect, [95.0, 685.0, 325.0, 725.0]); + assert_eq!(findings[0].covered_text, "SECRET DATA 123"); +} + +/// A Hidden Square (`/F 2`) is never drawn, so the text under it is not a finding. +#[test] +fn hidden_square_annotation_is_not_a_finding() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(content); + b.add(FONT); + b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 2 >>"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); +} + +/// A NoView Square (`/F 32`) is never drawn on screen, so the text under it is not a finding. +#[test] +fn no_view_square_annotation_is_not_a_finding() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(content); + b.add(FONT); + b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 32 >>"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); +} + +/// A Print-only Square (`/F 4`) is still drawn on screen, so the text under it is a finding. +#[test] +fn print_flag_square_annotation_is_still_a_finding() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(content); + b.add(FONT); + b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 4 >>"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::CoveredText); +} + #[test] fn background_before_text_is_not_flagged() { // An opaque dark rectangle painted *before* the text is a background, not a cover. @@ -529,3 +592,231 @@ fn reading_blocks_respect_stream_comments_and_split_operands() { assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); } } + +/// The current path is not graphics state: a rectangle built before `Q` and filled after it +/// paints the box, so it is a cover. +#[test] +fn rectangle_filled_after_restore_is_a_cover() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\nq 100 688 220 32 re Q f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); +} + +/// A `/Subtype` may be an indirect reference to the name, like any other value: a `/Redact` +/// annotation and a covering form XObject named that way are still checked. +#[test] +fn indirect_subtype_is_resolved() { + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n"); + b.add(FONT); + b.add("<< /Type /Annot /Subtype 7 0 R /Rect [95 685 305 725] >>"); + b.add("/Redact"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::UnappliedMark); + + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> /XObject << /X 5 0 R >> >> >>"); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n"); + b.add_stream_with(b"0 g\n100 688 220 32 re f\n", "/Type /XObject /Subtype 7 0 R /BBox [0 0 612 792]"); + b.add(FONT); + b.add("/Form"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::CoveredText); +} + +/// Text drawn after a cover on the same baseline joins the covered text in one line. The cover +/// still hides the words painted before it. +#[test] +fn later_text_on_the_line_does_not_clear_an_earlier_cover() { + let content = b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"; + let doc = open(one_page(content, "")); + let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); + assert_eq!(lines.len(), 1, "the runs join one line: {:?}", lines.iter().map(|l| &l.text).collect::>()); + let findings = audit_redactions(&doc).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); +} + +/// #4164: a cover over part of a line reports the words under it, not the words of the same +/// line painted beside it, which stay visible. +#[test] +fn covered_text_leaves_out_the_words_beside_the_cover() { + let covered = |content: &[u8]| { + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + findings.into_iter().map(|f| f.covered_text).collect::>() + }; + assert_eq!(covered(b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"), ["SECRET"]); + // A word shown in two runs with nothing between them stays one word (review on #4198). + assert_eq!(covered(b"0 g\nBT /F1 24 Tf 100 700 Td (SE) Tj (CRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"), ["SECRET"]); + + // The same for an unapplied redaction mark over the first run only. A mark over both runs + // reports both, with the space the line has between them (`tail` is set apart here). + for (rect, expected) in [("[98 688 192 720]", "SECRET"), ("[98 688 310 720]", "SECRET tail")] { + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\nBT /F1 24 Tf 250 700 Td (tail) Tj ET\n"); + b.add(FONT); + b.add(&format!("<< /Type /Annot /Subtype /Redact /Rect {rect} >>")); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.iter().map(|f| (f.kind.clone(), f.covered_text.as_str())).collect::>(), [(FindingKind::UnappliedMark, expected)]); + } +} + +/// Many covers over the same long line repeat its text in every finding: a small file must not +/// expand into an unbounded report. Past the budget the audit fails rather than truncate. +#[test] +fn covered_text_across_findings_is_bounded() { + let text = "X".repeat(60_000); + let mut content = format!("BT /F1 1 Tf 0 700 Td ({text}) Tj ET\n0 g\n").into_bytes(); + for i in 0..1_200 { + content.extend_from_slice(format!("-{i} 690 50000 20 re f\n").as_bytes()); + } + let result = audit_redactions(&open(one_page(&content, ""))); + assert!(matches!(result, Err(crate::AuditError::TooMuchOutput)), "{:?}", result.map(|f| f.len())); +} + +/// Covers compared against lines are bounded per page, so content that is quadratic in its +/// covers and lines fails the audit instead of running for hours. A line costs one comparison per +/// word: 20 covers against 20 two-word lines are 800. +#[test] +fn cover_and_line_comparisons_are_bounded() { + let mut content = Vec::new(); + for i in 0..20 { + content.extend_from_slice(format!("BT /F1 12 Tf 72 {} Td (Line {i}) Tj ET\n", 700 - 14 * i).as_bytes()); + } + for _ in 0..20 { + content.extend_from_slice(b"1 g 0 0 10 10 re f\n"); + } + let doc = open(one_page(&content, "")); + let limits = crate::Limits { comparisons: 799, covered_text: usize::MAX }; + assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); + let limits = crate::Limits { comparisons: 800, covered_text: usize::MAX }; + assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); +} + +/// A run that draws no word still costs a comparison: the covered text walks every run of a +/// covered line, so a word followed by many `( ) Tj` must not be checked for free (review on +/// #4198). +#[test] +fn runs_without_words_count_against_the_comparison_bound() { + let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj".to_vec(); + content.extend_from_slice(&b" ( ) Tj".repeat(50)); + content.extend_from_slice( + b" ET +0 g +98 688 94 32 re f +", + ); + let doc = open(one_page(&content, "")); + let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); + assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::>(), [(1, 51)]); + let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX }; + assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); + let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX }; + assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1); +} + +/// Covered text is document text: a document whose permissions withhold copying reports where +/// its covers are but not what they hide, unless it was opened with the owner password. +#[test] +fn copy_restricted_documents_withhold_covered_text() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n"; + let mut doc = open(one_page(content, "")); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [7; 32], + }) + .expect("encrypt"); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).expect("save")); + let user = Document::open_with_password(bytes.clone(), None).expect("open"); + let findings = audit_redactions(&user).expect("audit must run"); + assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}"); + assert_eq!(findings[0].covered_text, ""); + // The output bound counts the text reported, so withheld text costs nothing. + let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 }; + assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1); + let owner = Document::open_with_password(bytes, Some("owner")).expect("open"); + let findings = audit_redactions(&owner).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "SECRET DATA 123"); + let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() }; + assert!(crate::audit(&owner, exact).is_ok()); + let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact }; + assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput))); +} + +/// Withheld text is never assembled: covers over one long line would otherwise copy the line once +/// per cover, a volume neither the comparison nor the output bound charges. +#[test] +fn withheld_covered_text_is_not_assembled() { + const PAGE: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; + let doc = open(one_page(b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n", "")); + let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); + let mut budget = crate::Budget { limits: crate::Limits::DEFAULT, comparisons: 0, page_cost: 0, covered_text: 0 }; + budget.start_page(&lines); + let withheld = budget.hidden_text(&lines, false, &PAGE, |_| true).expect("within the budget"); + assert_eq!(withheld.map(|t| t.capacity()), Some(0), "a finding, with no text built for it"); + assert_eq!(budget.hidden_text(&lines, false, &PAGE, |_| false).expect("within the budget"), None); + let copied = budget.hidden_text(&lines, true, &PAGE, |_| true).expect("within the budget"); + assert_eq!(copied.as_deref(), Some("SECRET DATA 123")); +} + +/// `/ActualText` is metadata the file's author controls: an empty or substitute replacement must +/// not hide the glyphs a cover sits on. +#[test] +fn a_cover_over_text_with_empty_or_substitute_actual_text_is_found() { + for actual in ["()", "(nothing here)"] { + let content = + format!("/Span << /ActualText {actual} >> BDC\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\nEMC\n0 g\n100 688 220 32 re f\n"); + let findings = audit_redactions(&open(one_page(content.as_bytes(), ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{actual}: {findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{actual}: covered_text was {:?}", findings[0].covered_text); + } +} + +#[test] +fn repeated_stream_references_share_one_decode_budget() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + // One 1 MiB stream of spaces (a kilobyte on disk), named 300 times: by the page's /Contents, + // and by 300 draws of a form holding it. + let mut doc = Document::new_empty(); + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let contents: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(spaces); 300]))].into_iter().collect(); + assert!(crate::cover::filled_rects(&doc, &contents).is_err(), "repeated content streams"); + + let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::flate(form_dict, &vec![b' '; 1 << 20])); + let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(300).into_bytes())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); + assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws"); +} + +#[test] +fn form_entries_past_the_page_allowance_fail_the_audit() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + // An empty form decodes to no bytes, so only the count of entries bounds drawing it again. + let mut doc = Document::new_empty(); + let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::from_raw(form_dict, Vec::new())); + let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1).into_bytes())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); + assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms))); +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 60bd9eef4c..a6e43de1f0 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -17,8 +17,7 @@ pdfcraft-platform = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" -png = "0.18" -base64 = { version = "0.22", optional = true } +base64 = { version = "0.23", optional = true } [features] @@ -28,7 +27,11 @@ mcp = ["dep:base64"] [dev-dependencies] pdfcraft-fonts = { workspace = true } +png = "0.18" flate2.workspace = true pdfcraft-model = { workspace = true } pdfcraft-xfa = { workspace = true } image = { version = "0.25", default-features = false, features = ["jpeg"] } + +[target.'cfg(target_os = "macos")'.dev-dependencies] +rustix = { version = "1", features = ["fs"] } diff --git a/crates/automation/README.md b/crates/automation/README.md index 669ea61463..176946c175 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -15,6 +15,12 @@ tools() -> Vec // name, title, descrip mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JSON-RPC 2.0 ``` +`content_update.rotate` rotates an added image counter-clockwise by an integer multiple of +90 degrees. Large positive and negative signed 64-bit angles are normalized before combining +them with the image's current orientation. Full turns retain its appearance, rectangle, crop +and flips. Angles that are not multiples of 90 are refused without changing the document or +its undo/redo history. + `Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent). ## Conventions @@ -27,7 +33,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS - For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle. - `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss. - With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (` is outside the allowed directory `), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder. -- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root : `. It applies on top of `with_root`. +- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root : `. As with a root set, on Windows a path on another network share or device path is refused with that refusal without being contacted (#4003). It applies on top of `with_root`. ## Text search and cache limits @@ -106,13 +112,13 @@ The release packages ship `pdfcraft-cli` alongside the desktop app, so no build | Install | CLI | |---|---| -| Windows (MSI) | `C:\Program Files\PdfCraft\pdfcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\PdfCraft\pdfcraft-cli.exe` by default (`C:\Program Files\ArtCraft\PdfCraft\pdfcraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/pdfcraft-cli` | | macOS | the separate `pdfcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | ```sh -# Windows, default install folder -claude mcp add pdfcraft -- "C:\Program Files\PdfCraft\pdfcraft-cli.exe" mcp --root +# Windows (PowerShell), default install folder +claude mcp add pdfcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\PdfCraft\pdfcraft-cli.exe" mcp --root # Linux, or macOS with the CLI unzipped onto PATH claude mcp add pdfcraft -- pdfcraft-cli mcp --root ``` @@ -122,7 +128,7 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root 1. Add the engine capability first, with its tests (the tool is a thin adapter). 2. Add a `ToolDef` in `src/tools.rs`: a precise description, the schema, `ro()`/`destructive()`, and `cmd()` if a registry command exists. 3. Handle it in `Automation::call`, validating pages and positions with the existing helpers. -4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links. +4. Add an end-to-end test in `tests/it/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links. ## Moving several page objects @@ -175,6 +181,12 @@ working document unchanged. `content_list` or `object_list`. It removes only that item in a read-only preview and is mutually exclusive with `hide_text_block`. +When the renderer reports a warning for the page, `page_render` and `render_preview` return a +JSON part after the PNG, `{ "page": 1, "warnings": [...] }`, with the sentences +`pdfcraft-cli render` prints: "content past the page's safety budget was skipped" and "a colour +that could not be computed was painted in a fallback colour". A page without warnings returns +the PNG alone. + ## Existing text and document fonts @@ -208,6 +220,18 @@ unapplied with a warning. folders. The CLI also exposes it as `pdfcraft-cli batch`. See the [batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits. +## Editing button export values + +Use `form_set_props` with `field`, `export_value` and optional `widget` +(1-based, default 1) to change a checkbox or radio button export value. +`form_fields` returns `export_values` in widget order. The selected/default +appearance states are preserved; the operation is undoable and survives saving. +Widgets sharing an appearance state keep one export value. Bulk edits are not +supported for this widget-specific property. Empty values, `Off`, conflicting +choices and values over 4096 UTF-8 bytes return an error. + +Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. + ## Viewer navigation history A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through @@ -223,3 +247,10 @@ behavior. Back/Forward returns to page tops, does not restore zoom, does not switch documents, and is not saved across restarts. Standalone engine/MCP sessions have no viewport, so they do not have a separate navigation-history tool; use the opt-in UI control channel to navigate the displayed document. + +## Fill & Sign text appearance + +`fill_sign_add` accepts `font_size` (4–300 pt, default 10) and `color` +(`#RRGGBB`, default black) for `text` and `date`. The colour is saved in the +FreeText appearance and survives reopening. Other mark/signature types reject +`color`; their appearance controls are separate. diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 8130ed084d..1f0c9cb8a5 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -8,6 +8,10 @@ use std::collections::BTreeSet; use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic}; +mod documents; +mod form_tools; +mod ocr; + fn status_id(s: Status) -> &'static str { match s { Status::Passed => "passed", @@ -504,7 +508,7 @@ impl Automation { "object": t.object.num, "generation": t.object.generation, "type": t.kind, "parent": t.parent.map(|p| json!({ "object": p.num, "generation": p.generation })), "depth": t.depth, "title": t.title, "actual_text": t.actual_text, "alternate_text": t.alt, - "language": t.language, "page": t.page.map(|p| p + 1), + "language": t.language, "summary": t.summary, "scope": t.scope, "page": t.page.map(|p| p + 1), }) }) .collect(); @@ -574,441 +578,6 @@ impl Automation { } } -impl Automation { - pub(crate) fn ocr_settings(&self, a: &Args) -> Result { - let mut settings = pdfcraft_engine::ocr::OcrSettings::default(); - if let Some(d) = a.opt_num("dpi")? { - settings.dpi = d.clamp(72.0, 600.0) as f32; - } - if let Some(l) = a.opt_str("language")? { - if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) { - return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}"))); - } - settings.language = l.into(); - } - if let Some(s) = a.opt_bool("skip_text_pages")? { - settings.skip_text_pages = s; - } - Ok(settings) - } - - pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { - let settings = self.ocr_settings(a)?; - let folder = self.resolve(a.str("folder")?, true)?; - // A path that leaves the root refuses the whole call before any work starts, like the - // other tools (the write check passes missing files, which stay per-file errors below). - let paths = a.strs("paths")?; - for p in &paths { - self.resolve_for(p, crate::file_access::Access::Read, true)?; - } - std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; - let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; - let mut out = Vec::new(); - for p in paths { - let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); - let result = - self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| { - pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true) - }); - out.push(match result { - Ok(r) => { - let target = child(&folder, &name); - write_atomic(&target, &r.bytes)?; - let skipped: Vec = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect(); - json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "files": out })) - } - - pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { - let settings = self.ocr_settings(a)?; - let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; - let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; - let list: Vec = found - .iter() - .map(|p| match &p.skipped { - Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), - }) - .collect(); - Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) - } - - pub(crate) fn ocr_status(&self) -> Result { - use pdfcraft_engine::ocr; - let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect(); - let langs: Vec = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect(); - Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs })) - } -} - -fn request_json(r: &pdfcraft_engine::js::Request) -> Value { - use pdfcraft_engine::js::Request as R; - match r { - R::Reset(n) => json!({ "reset": n }), - R::Print => json!({ "print": true }), - R::GoToPage(p) => json!({ "page": p + 1 }), - R::LaunchUrl(u) => json!({ "url": u }), - R::Submit(u) => json!({ "submit": u }), - R::Focus(f) => json!({ "focus": f }), - R::Beep => json!({ "beep": true }), - R::SaveAs => json!({ "save_as": true }), - } -} - -impl Automation { - pub(crate) fn js_run(&mut self, a: &Args) -> Result { - let id = self.doc(a)?.id; - let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; - let reqs: Vec = o.requests.iter().map(request_json).collect(); - Ok(json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "result": o.result })) - } - - pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { - let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect(); - Ok(json!({ "scripts": list })) - } - - pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result { - let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.js_document_scripts(a) - } - - pub(crate) fn form_set_script(&mut self, a: &Args) -> Result { - let edit = pdfcraft_engine::Edit::SetFieldScript { - name: a.str("field")?.into(), - event: a.str("event")?.into(), - script: a.opt_str("script")?.map(str::to_string), - }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - let out = self.session.take_js_output(id); - Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors })) - } - - pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { - let mut files = Vec::new(); - for p in a.strs("paths")? { - let path = self.resolve(p, false)?; - files.push((p.to_string(), std::fs::read(&path).map_err(|e| failed(format!("{p}: {e}")))?)); - } - let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?; - let target = self.resolve(a.str("path")?, true)?; - write_atomic(&target, csv.as_bytes())?; - let columns = csv.lines().next().map_or(0, |h| h.split(',').count()); - Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns })) - } - - pub(crate) fn doc_export_office(&self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, true)?; - let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); - let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) - .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; - let bytes = self.doc_to_copy(a)?.export_office(format); - write_atomic(&path, &bytes)?; - Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() })) - } - - fn pdfa_level(&self, a: &Args) -> Result { - let l = a.opt_str("level")?.unwrap_or("2b"); - pdfcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)"))) - } - - pub(crate) fn pdfa_verify(&self, a: &Args) -> Result { - let level = self.pdfa_level(a)?; - let doc = self.doc(a)?; - let issues: Vec = doc - .pdfa_verify(level) - .iter() - .map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable })) - .collect(); - let d = doc.standards(); - Ok(json!({ - "level": level.label(), - "compliant": issues.is_empty(), - "issues": issues, - "declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents }, - })) - } - - pub(crate) fn print_readiness(&self, a: &Args) -> Result { - let report = self.doc(a)?.print_readiness(); - let findings: Vec = - report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect(); - let pages: Vec = report - .pages - .iter() - .map(|p| { - json!({ - "page": p.page, - "media_mm": p.media_mm, - "crop_mm": p.crop_mm, - "trim_mm": p.trim_mm, - "bleed_mm": p.bleed_mm, - "trim_set": p.trim_set, - "bleed_set": p.bleed_set, - "bleed_margin_mm": p.bleed_margin_mm, - }) - }) - .collect(); - let images: Vec = report - .images - .iter() - .map(|image| { - json!({ - "page": image.page, - "name": image.name, - "ppi": image.ppi, - "pixels": image.pixels, - "size_mm": image.size_mm, - }) - }) - .collect(); - let pdfa: Vec = report - .pdfa_issues - .iter() - .map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) })) - .collect(); - Ok(json!({ - "text": report.text(), - "findings": findings, - "pages": pages, - "images": images, - "output_intents": report.output_intents, - "fonts_not_embedded": report.fonts_not_embedded, - "pdfa_issues": pdfa, - "resolution_unknown": report.resolution_unknown, - })) - } - - pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result { - let level = self.pdfa_level(a)?; - let id = self.doc(a)?.id; - self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?; - self.pdfa_verify(a) - } - - pub(crate) fn action_list(&self) -> Result { - use pdfcraft_engine::actions::{Step, builtin}; - let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() }); - let actions: Vec = builtin() - .iter() - .map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::>() })) - .collect(); - let steps: Vec = Step::all().iter().map(|s| json!({ "step": s.id(), "label": s.label(), "takes_arg": s.arg().is_some() })).collect(); - Ok(json!({ "actions": actions, "steps": steps })) - } - - pub(crate) fn action_run(&mut self, a: &Args) -> Result { - use pdfcraft_engine::actions::{Action, Step, builtin, run_on}; - let action = if let Some(name) = a.opt_str("action")? { - builtin() - .into_iter() - .find(|x| x.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| ToolError::InvalidArgs(format!("no built-in action {name:?}")))? - } else { - let items = a.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("give action or steps".into()))?; - let steps = items - .iter() - .map(|it| { - let id = it["step"].as_str().unwrap_or(""); - Step::from_id(id, it["arg"].as_str().unwrap_or("")).ok_or_else(|| ToolError::InvalidArgs(format!("unknown step {id:?}"))) - }) - .collect::>>()?; - Action { name: "Custom".into(), description: String::new(), steps, builtin: false } - }; - let folder = self.resolve(a.str("folder")?, true)?; - std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; - let mut out = Vec::new(); - for p in a.strs("paths")? { - let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); - let result = self - .resolve(p, false) - .map_err(|e| e.to_string()) - .and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())) - .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); - out.push(match result { - Ok(r) => { - let target = child(&folder, &name); - let target = if target.exists() { - let stem = - std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - unused(&folder, &stem) - } else { - target - }; - if target.exists() { - return Err(failed(format!("{}: no unused output name available", target.display()))); - } - write_atomic(&target, &r.bytes)?; - json!({ "path": p, "output": target.to_string_lossy(), "log": r.log }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "action": action.name, "files": out })) - } - - fn compare_ids(&self, a: &Args) -> Result<(pdfcraft_engine::DocId, pdfcraft_engine::DocId)> { - let new = self.doc(a)?.id; - let other = a.int("other")?; - let old = u64::try_from(other) - .ok() - .and_then(|o| self.session.get(pdfcraft_engine::DocId(o))) - .map(|d| d.id) - .ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?; - Ok((old, new)) - } - - pub(crate) fn doc_compare(&self, a: &Args) -> Result { - use pdfcraft_engine::compare::Kind; - let (old, new) = self.compare_ids(a)?; - let c = self.session.compare(old, new).map_err(failed)?; - let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize; - let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0); - let side = - |s: &pdfcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::>() }); - let list: Vec = c - .changes - .iter() - .take(limit) - .map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) })) - .collect(); - let visual: Vec = if a.opt_bool("visual")?.unwrap_or(false) { - self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect() - } else { - Vec::new() - }; - Ok(json!({ - "visual": visual, - "identical": c.identical(), - "replaced": c.count(Kind::Replaced), - "inserted": c.count(Kind::Inserted), - "deleted": c.count(Kind::Deleted), - "old_words": c.old_words, - "new_words": c.new_words, - "changes": list, - })) - } - - pub(crate) fn doc_compare_report(&self, a: &Args) -> Result { - let (old, new) = self.compare_ids(a)?; - let bytes = self.session.compare_report(old, new).map_err(failed)?; - let path = self.resolve(a.str("path")?, true)?; - write_atomic(&path, &bytes)?; - Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() })) - } - - pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result { - let (old, new) = self.compare_ids(a)?; - let n = self.session.mark_differences(old, new).map_err(failed)?; - Ok(json!({ "comments": n })) - } - - pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result { - let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; - let found = self.session.detect_fields(id, &pages); - let list: Vec = found - .iter() - .map(|(p, c)| { - let (kind, comb) = match c.kind { - pdfcraft_engine::detect::Kind::Text => ("text", None), - pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), - pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), - }; - let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); - if let Some(cells) = comb { - v["comb"] = json!(cells); - } - v - }) - .collect(); - if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { - self.session.auto_detect_fields(id, &pages).map_err(failed)?; - } - Ok(json!({ "fields": list })) - } - - pub(crate) fn form_actions(&self, a: &Args) -> Result { - use pdfcraft_engine::FieldAction as A; - let list: Vec = self - .doc(a)? - .field_actions(a.str("field")?) - .iter() - .map(|(t, act)| { - let mut v = match act { - A::JavaScript(j) => json!({ "javascript": j }), - A::Uri(u) => json!({ "url": u }), - A::Reset(f) => json!({ "reset": f }), - A::Named(n) => json!({ "menu": n }), - A::GoTo(p) => json!({ "page": p + 1 }), - A::ShowHide { fields, hide: true } => json!({ "hide": fields }), - A::ShowHide { fields, hide: false } => json!({ "show": fields }), - A::Submit(u) => json!({ "submit": u }), - A::Other(s) => json!({ "other": s }), - }; - v["trigger"] = json!(t.id()); - v - }) - .collect(); - Ok(json!({ "field": a.str("field")?, "actions": list })) - } - - pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{FieldAction as A, FieldTrigger as T}; - let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?; - let bad = |m: String| ToolError::InvalidArgs(m); - let names = |v: &Value| -> Vec { - v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default() - }; - let mut acts = Vec::new(); - for it in items { - let tid = it["trigger"].as_str().unwrap_or(""); - let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?; - let act = if let Some(j) = it["javascript"].as_str() { - A::JavaScript(j.into()) - } else if let Some(u) = it["url"].as_str() { - A::Uri(u.into()) - } else if it.get("reset").is_some() { - A::Reset(names(&it["reset"])) - } else if let Some(m) = it["menu"].as_str() { - A::Named(m.into()) - } else if let Some(p) = it["page"].as_u64() { - A::GoTo((p.max(1) - 1) as usize) - } else if it.get("show").is_some() { - A::ShowHide { fields: names(&it["show"]), hide: false } - } else if it.get("hide").is_some() { - A::ShowHide { fields: names(&it["hide"]), hide: true } - } else if let Some(u) = it["submit"].as_str() { - A::Submit(u.into()) - } else { - return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)"))); - }; - acts.push((t, act)); - } - let name = a.str("field")?.to_string(); - let edit = - pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) }; - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.form_actions(a) - } - - pub(crate) fn js_enabled(&mut self, a: &Args) -> Result { - if let Some(on) = a.opt_bool("enabled")? { - self.session.set_javascript(on); - } - Ok(json!({ "enabled": self.session.javascript() })) - } -} - #[cfg(test)] mod review_html_tests { use super::*; diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs new file mode 100644 index 0000000000..e52768d535 --- /dev/null +++ b/crates/automation/src/a11y/documents.rs @@ -0,0 +1,219 @@ +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic}; + +impl Automation { + pub(crate) fn doc_export_office(&self, a: &Args) -> Result { + let path = self.resolve(a.str("path")?, true)?; + let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); + let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) + .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html, rtf or md)")))?; + let export = self.doc_to_copy(a)?.export_office_checked(format); + write_atomic(&path, &export.bytes)?; + // Text left out to keep a hostile page bounded is said, not silent (#3120). + let truncated: Vec = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect(); + let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(), + "truncated": !truncated.is_empty(), "truncated_pages": truncated }); + if !export.truncated_pages.is_empty() { + use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; + out["warnings"] = json!([format!( + "forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them" + )]); + } + Ok(out) + } + + fn pdfa_level(&self, a: &Args) -> Result { + let l = a.opt_str("level")?.unwrap_or("2b"); + pdfcraft_engine::pdfa::Level::from_id(l).ok_or_else(|| ToolError::InvalidArgs(format!("unknown PDF/A level {l:?} (2b or 3b)"))) + } + + pub(crate) fn pdfa_verify(&self, a: &Args) -> Result { + let level = self.pdfa_level(a)?; + let doc = self.doc(a)?; + let issues: Vec = doc + .pdfa_verify(level) + .iter() + .map(|i| json!({ "clause": i.clause, "message": i.message, "page": i.page.map(|p| p + 1), "fixable": i.fixable })) + .collect(); + let d = doc.standards(); + Ok(json!({ + "level": level.label(), + "compliant": issues.is_empty(), + "issues": issues, + "declared": { "pdfa": d.pdfa.map(|(p, c)| format!("PDF/A-{p}{}", c.to_lowercase())), "pdfua": d.pdfua, "output_intents": d.output_intents }, + })) + } + + pub(crate) fn print_readiness(&self, a: &Args) -> Result { + let report = self.doc(a)?.print_readiness(); + let findings: Vec = + report.findings.iter().map(|f| json!({ "id": f.id, "severity": f.severity, "message": f.message, "page": f.page })).collect(); + let pages: Vec = report + .pages + .iter() + .map(|p| { + json!({ + "page": p.page, + "media_mm": p.media_mm, + "crop_mm": p.crop_mm, + "trim_mm": p.trim_mm, + "bleed_mm": p.bleed_mm, + "trim_set": p.trim_set, + "bleed_set": p.bleed_set, + "bleed_margin_mm": p.bleed_margin_mm, + }) + }) + .collect(); + let images: Vec = report + .images + .iter() + .map(|image| { + json!({ + "page": image.page, + "name": image.name, + "ppi": image.ppi, + "pixels": image.pixels, + "size_mm": image.size_mm, + }) + }) + .collect(); + let pdfa: Vec = report + .pdfa_issues + .iter() + .map(|issue| json!({ "clause": issue.clause, "message": issue.message, "page": issue.page.map(|p| p + 1) })) + .collect(); + Ok(json!({ + "text": report.text(), + "findings": findings, + "pages": pages, + "images": images, + "output_intents": report.output_intents, + "fonts_not_embedded": report.fonts_not_embedded, + "pdfa_issues": pdfa, + "resolution_unknown": report.resolution_unknown, + })) + } + + pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result { + let level = self.pdfa_level(a)?; + let id = self.doc(a)?.id; + self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?; + self.pdfa_verify(a) + } + + pub(crate) fn action_list(&self) -> Result { + use pdfcraft_engine::actions::{Step, builtin}; + let step_json = |s: &Step| json!({ "step": s.id(), "arg": s.arg() }); + let actions: Vec = builtin() + .iter() + .map(|a| json!({ "name": a.name, "description": a.description, "steps": a.steps.iter().map(step_json).collect::>() })) + .collect(); + let steps: Vec = Step::all().iter().map(|s| json!({ "step": s.id(), "label": s.label(), "takes_arg": s.arg().is_some() })).collect(); + Ok(json!({ "actions": actions, "steps": steps })) + } + + pub(crate) fn action_run(&mut self, a: &Args) -> Result { + use pdfcraft_engine::actions::{Action, Step, builtin, run_on}; + let action = if let Some(name) = a.opt_str("action")? { + builtin() + .into_iter() + .find(|x| x.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| ToolError::InvalidArgs(format!("no built-in action {name:?}")))? + } else { + let items = a.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("give action or steps".into()))?; + let steps = items + .iter() + .map(|it| { + let id = it["step"].as_str().unwrap_or(""); + Step::from_id(id, it["arg"].as_str().unwrap_or("")).ok_or_else(|| ToolError::InvalidArgs(format!("unknown step {id:?}"))) + }) + .collect::>>()?; + Action { name: "Custom".into(), description: String::new(), steps, builtin: false } + }; + let folder = self.resolve(a.str("folder")?, true)?; + std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; + let mut out = Vec::new(); + for p in a.strs("paths")? { + let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); + let result = self + .resolve(p, false) + .map_err(|e| e.to_string()) + .and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())) + .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); + out.push(match result { + Ok(r) => { + let target = child(&folder, &name); + let target = if target.exists() { + let stem = + std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); + unused(&folder, &stem) + } else { + target + }; + if target.exists() { + return Err(failed(format!("{}: no unused output name available", target.display()))); + } + write_atomic(&target, &r.bytes)?; + json!({ "path": p, "output": target.to_string_lossy(), "log": r.log }) + } + Err(e) => json!({ "path": p, "error": e }), + }); + } + Ok(json!({ "action": action.name, "files": out })) + } + + fn compare_ids(&self, a: &Args) -> Result<(pdfcraft_engine::DocId, pdfcraft_engine::DocId)> { + let new = self.doc(a)?.id; + let other = a.int("other")?; + let old = u64::try_from(other) + .ok() + .and_then(|o| self.session.get(pdfcraft_engine::DocId(o))) + .map(|d| d.id) + .ok_or_else(|| ToolError::InvalidArgs(format!("no open document {other}")))?; + Ok((old, new)) + } + + pub(crate) fn doc_compare(&self, a: &Args) -> Result { + use pdfcraft_engine::compare::Kind; + let (old, new) = self.compare_ids(a)?; + let c = self.session.compare(old, new).map_err(failed)?; + let limit = a.opt_int("limit")?.unwrap_or(500).max(1) as usize; + let r2 = |r: &[f64; 4]| r.map(|v| (v * 100.0).round() / 100.0); + let side = + |s: &pdfcraft_engine::compare::Side| json!({ "text": s.text, "page": s.page + 1, "rects": s.rects.iter().map(r2).collect::>() }); + let list: Vec = c + .changes + .iter() + .take(limit) + .map(|ch| json!({ "kind": ch.kind.label().to_lowercase(), "old": side(&ch.old), "new": side(&ch.new) })) + .collect(); + let visual: Vec = if a.opt_bool("visual")?.unwrap_or(false) { + self.session.compare_visual(old, new, 72.0).map_err(failed)?.iter().map(|(p, r)| json!({ "page": p + 1, "rect": r2(r) })).collect() + } else { + Vec::new() + }; + Ok(json!({ + "visual": visual, + "identical": c.identical(), + "replaced": c.count(Kind::Replaced), + "inserted": c.count(Kind::Inserted), + "deleted": c.count(Kind::Deleted), + "old_words": c.old_words, + "new_words": c.new_words, + "changes": list, + })) + } + + pub(crate) fn doc_compare_report(&self, a: &Args) -> Result { + let (old, new) = self.compare_ids(a)?; + let bytes = self.session.compare_report(old, new).map_err(failed)?; + let path = self.resolve(a.str("path")?, true)?; + write_atomic(&path, &bytes)?; + Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len() })) + } + + pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result { + let (old, new) = self.compare_ids(a)?; + let n = self.session.mark_differences(old, new).map_err(failed)?; + Ok(json!({ "comments": n })) + } +} diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs new file mode 100644 index 0000000000..2420a44372 --- /dev/null +++ b/crates/automation/src/a11y/form_tools.rs @@ -0,0 +1,170 @@ +use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic}; + +/// A script's request to the viewer as the JSON that running a script reports; a side panel +/// goes by its fixed id. +fn request_json(r: &pdfcraft_engine::js::Request) -> Value { + use pdfcraft_engine::js::Request as R; + match r { + R::Reset(n) => json!({ "reset": n }), + R::Print => json!({ "print": true }), + R::GoToPage(p) => json!({ "page": p + 1 }), + R::LaunchUrl(u) => json!({ "url": u }), + R::Submit(u) => json!({ "submit": u }), + R::Focus(f) => json!({ "focus": f }), + R::Beep => json!({ "beep": true }), + R::SaveAs => json!({ "save_as": true }), + R::ShowPanel(p) => json!({ "panel": p.id() }), + } +} + +/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none), +/// icon and buttons. +fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { + json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() }) +} + +impl Automation { + /// `js_run`: run `script` in the document, or as button `field`'s script, and report its + /// messages, console lines, requests, error, the failing document scripts' errors and result. + pub(crate) fn js_run(&mut self, a: &Args) -> Result { + let id = self.doc(a)?.id; + let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; + let reqs: Vec = o.requests.iter().map(request_json).collect(); + let alerts: Vec = o.alerts.iter().map(alert_json).collect(); + Ok(json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result })) + } + + pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { + let list: Vec = self.doc(a)?.document_scripts().into_iter().map(|(n, s)| json!({ "name": n, "script": s })).collect(); + Ok(json!({ "scripts": list })) + } + + pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result { + let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) }; + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + self.js_document_scripts(a) + } + + pub(crate) fn form_set_script(&mut self, a: &Args) -> Result { + let edit = pdfcraft_engine::Edit::SetFieldScript { + name: a.str("field")?.into(), + event: a.str("event")?.into(), + script: a.opt_str("script")?.map(str::to_string), + }; + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + let out = self.session.take_js_output(id); + Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors })) + } + + pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result { + let mut files = Vec::new(); + for p in a.strs("paths")? { + let path = self.resolve(p, false)?; + files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?)); + } + let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?; + let target = self.resolve(a.str("path")?, true)?; + write_atomic(&target, csv.as_bytes())?; + let columns = csv.lines().next().map_or(0, |h| h.split(',').count()); + Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns })) + } + + pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result { + let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; + let id = self.doc(a)?.id; + let found = self.session.detect_fields(id, &pages); + let list: Vec = found + .iter() + .map(|(p, c)| { + let (kind, comb) = match c.kind { + pdfcraft_engine::detect::Kind::Text => ("text", None), + pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), + pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), + }; + let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); + if let Some(cells) = comb { + v["comb"] = json!(cells); + } + v + }) + .collect(); + if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { + self.session.auto_detect_fields(id, &pages).map_err(failed)?; + } + Ok(json!({ "fields": list })) + } + + pub(crate) fn form_actions(&self, a: &Args) -> Result { + use pdfcraft_engine::FieldAction as A; + let list: Vec = self + .doc(a)? + .field_actions(a.str("field")?) + .iter() + .map(|(t, act)| { + let mut v = match act { + A::JavaScript(j) => json!({ "javascript": j }), + A::Uri(u) => json!({ "url": u }), + A::Reset(f) => json!({ "reset": f }), + A::Named(n) => json!({ "menu": n }), + A::GoTo(p) => json!({ "page": p + 1 }), + A::ShowHide { fields, hide: true } => json!({ "hide": fields }), + A::ShowHide { fields, hide: false } => json!({ "show": fields }), + A::Submit(u) => json!({ "submit": u }), + A::Other(s) => json!({ "other": s }), + }; + v["trigger"] = json!(t.id()); + v + }) + .collect(); + Ok(json!({ "field": a.str("field")?, "actions": list })) + } + + pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{FieldAction as A, FieldTrigger as T}; + let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?; + let bad = |m: String| ToolError::InvalidArgs(m); + let names = |v: &Value| -> Vec { + v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default() + }; + let mut acts = Vec::new(); + for it in items { + let tid = it["trigger"].as_str().unwrap_or(""); + let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?; + let act = if let Some(j) = it["javascript"].as_str() { + A::JavaScript(j.into()) + } else if let Some(u) = it["url"].as_str() { + A::Uri(u.into()) + } else if it.get("reset").is_some() { + A::Reset(names(&it["reset"])) + } else if let Some(m) = it["menu"].as_str() { + A::Named(m.into()) + } else if let Some(p) = it["page"].as_u64() { + A::GoTo((p.max(1) - 1) as usize) + } else if it.get("show").is_some() { + A::ShowHide { fields: names(&it["show"]), hide: false } + } else if it.get("hide").is_some() { + A::ShowHide { fields: names(&it["hide"]), hide: true } + } else if let Some(u) = it["submit"].as_str() { + A::Submit(u.into()) + } else { + return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)"))); + }; + acts.push((t, act)); + } + let name = a.str("field")?.to_string(); + let edit = + pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) }; + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + self.form_actions(a) + } + + pub(crate) fn js_enabled(&mut self, a: &Args) -> Result { + if let Some(on) = a.opt_bool("enabled")? { + self.session.set_javascript(on); + } + Ok(json!({ "enabled": self.session.javascript() })) + } +} diff --git a/crates/automation/src/a11y/ocr.rs b/crates/automation/src/a11y/ocr.rs new file mode 100644 index 0000000000..82e521ddd5 --- /dev/null +++ b/crates/automation/src/a11y/ocr.rs @@ -0,0 +1,146 @@ +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic}; + +impl Automation { + /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused + /// unless the models read it) and `skip_text_pages`, each left at its default when absent. + pub(crate) fn ocr_settings(&self, a: &Args) -> Result { + let mut settings = pdfcraft_engine::ocr::OcrSettings::default(); + if let Some(d) = a.opt_num("dpi")? { + settings.dpi = d.clamp(72.0, 600.0) as f32; + } + if let Some(l) = a.opt_str("language")? { + if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) { + return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}"))); + } + settings.language = l.into(); + } + if let Some(s) = a.opt_bool("skip_text_pages")? { + settings.skip_text_pages = s; + } + Ok(settings) + } + + /// Recognizes text in each of `paths` and writes the result under the same file name in + /// `folder`, reporting per file its output, word count and skipped pages, or its error. A + /// path outside the root refuses the whole call before any work starts. + pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { + let settings = self.ocr_settings(a)?; + let folder = self.resolve(a.str("folder")?, true)?; + // A path that leaves the root refuses the whole call before any work starts, like the + // other tools (the write check passes missing files, which stay per-file errors below). + let paths = a.strs("paths")?; + for p in &paths { + self.resolve_for(p, crate::file_access::Access::Read, true)?; + } + // Two inputs that would write the same output file overwrite each other while both report + // success, so the whole batch is refused here: before the folder is created, anything is + // read, or the OCR engines load. + let targets: Vec = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect(); + let shared = shared_file_names(&targets); + if !shared.is_empty() { + return Err(ToolError::InvalidArgs(format!( + "more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.", + shared.join(", ") + ))); + } + std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; + let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; + let mut out = Vec::new(); + for p in paths { + let name = input_file_name(p); + let result = + self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then( + |bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true), + ); + out.push(match result { + Ok(r) => { + let target = child(&folder, &name); + write_atomic(&target, &r.bytes)?; + let skipped: Vec = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect(); + json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped }) + } + Err(e) => json!({ "path": p, "error": e }), + }); + } + Ok(json!({ "files": out })) + } + + /// Recognizes text on the open document's `pages` (every page when absent) and adds it, + /// reporting the total word count and, per page, why it was skipped or what it found. + pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { + let settings = self.ocr_settings(a)?; + let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; + let doc = self.doc(a)?; + // Recognizing needs only permission to change the document; the words it found are + // returned only when copying is allowed too. + let (id, copy) = (doc.id, doc.allows_copying()); + let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; + let list: Vec = found + .iter() + .map(|p| match &p.skipped { + Some(why) => json!({ "page": p.page + 1, "skipped": why }), + None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }), + }) + .collect(); + Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) + } + + /// Whether text recognition's models are installed, the directories searched for them (in + /// order), and the languages it reads, each as a code and a name. + pub(crate) fn ocr_status(&self) -> Result { + use pdfcraft_engine::ocr; + let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect(); + let langs: Vec = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect(); + Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs })) + } +} + +/// The file name an input is written under: its own file name, or `document.pdf` when it has none. +fn input_file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()) +} + +/// The output file names that two or more of `targets` share, each spelling listed once, in input +/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on +/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode +/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are +/// not caught. The folder is the same for every target, so only the names are compared. +fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec { + let mut groups: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); + for target in targets { + let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let key = if cfg!(windows) { + name.trim_end_matches(['.', ' ']).to_lowercase() + } else if cfg!(target_os = "macos") { + // Case only; NFC and NFD spellings of one name stay distinct here (a known limit). + name.to_lowercase() + } else { + name.clone() + }; + let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new())); + *count += 1; + if !spellings.contains(&name) { + spellings.push(name); + } + } + groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect() +} + +#[cfg(test)] +mod shared_output_name_tests { + use super::*; + + #[test] + fn shared_output_names_follow_the_default_filesystem() { + let targets = |names: &[&str]| -> Vec { names.iter().map(|n| std::path::Path::new("out").join(n)).collect() }; + // A spelling that repeats is one shared name, listed once. + assert_eq!(shared_file_names(&targets(&["a.pdf", "b.pdf", "a.pdf"])), vec!["a.pdf".to_string()]); + assert!(shared_file_names(&targets(&["a.pdf", "b.pdf"])).is_empty()); + // Case is ignored on Windows and macOS only; a trailing dot is ignored on Windows only. + let case = shared_file_names(&targets(&["scan.pdf", "Scan.PDF"])); + let dot = shared_file_names(&targets(&["scan.pdf", "scan.pdf."])); + assert_eq!(case.len(), if cfg!(any(windows, target_os = "macos")) { 2 } else { 0 }, "{case:?}"); + assert_eq!(dot.len(), if cfg!(windows) { 2 } else { 0 }, "{dot:?}"); + } +} diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs new file mode 100644 index 0000000000..deb2efa18a --- /dev/null +++ b/crates/automation/src/assemble.rs @@ -0,0 +1,424 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, one_based, summary}; +use super::paths::{child, read_input, read_input_bytes, unused, write_atomic}; +use super::{Arc, Automation, CombineSource, Edit, Path, PathBuf, Value, json}; + +impl Automation { + pub(crate) fn doc_create(&mut self, a: &Args) -> Result { + let (name, bytes) = match a.str("from")? { + "blank" => { + let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize; + let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0)); + ("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?) + } + "images" => { + let resolution = a.opt_num("dpi")?.map_or(pdfcraft_engine::ImageResolution::Embedded, pdfcraft_engine::ImageResolution::Dpi); + let mut images = Vec::new(); + for p in a.strs("paths")? { + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); + } + let name = if images.len() == 1 { + format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s)) + } else { + "Images.pdf".into() + }; + (name, self.session.create_from_images_with_resolution(&images, resolution).map_err(failed)?) + } + "text" => { + let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) { + (Some(t), _) => ("Text".to_string(), t.to_string()), + (None, Some(p)) => { + let path = self.resolve(p, false)?; + let t = read_input(&path)?; + (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) + } + (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), + }; + // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with + // its line) by `pdfcraft_create::from_text`, as for every other caller. + let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { + pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), + e => failed(e), + })?; + (format!("{title}.pdf"), bytes) + } + other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), + }; + let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name); + let id = self.session.open_new(name, bytes).map_err(failed)?; + Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) + } + + pub(crate) fn page_transition_set(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{Transition, TransitionSpec}; + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let transition = match a.opt_str("style")? { + None | Some("none") => None, + Some(style) => { + let mut t = Transition::new(style); + if let Some(d) = a.opt_num("duration")? { + t.duration = d as f32; + } + if let Some(dm) = a.opt_str("dimension")? { + t.horizontal = match dm { + "horizontal" => true, + "vertical" => false, + other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))), + }; + } + if let Some(m) = a.opt_str("motion")? { + t.inward = match m { + "inward" => true, + "outward" => false, + other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))), + }; + } + match a.get("direction") { + None => {} + Some(Value::String(s)) if s == "none" => t.direction = None, + Some(v) => { + t.direction = Some( + v.as_u64() + .and_then(|d| u16::try_from(d).ok()) + .ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?, + ); + } + } + if let Some(s) = a.opt_num("scale")? { + t.scale = s as f32; + } + if let Some(b) = a.opt_bool("opaque")? { + t.opaque = b; + } + Some(t) + } + }; + let display = a.opt_num("display")?.map(|s| s as f32); + self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } }) + } + + pub(crate) fn page_set_box(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{BoxSpec, PageBox}; + let doc = self.doc(a)?; + let n = doc.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let which = match a.opt_str("box")? { + None => PageBox::Crop, + Some(b) => PageBox::from_name(b).ok_or_else(|| ToolError::InvalidArgs(format!("unknown box {b:?}")))?, + }; + let nums = |key: &str| -> Result> { + a.get(key) + .map(|v| { + let v: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + <[f64; 4]>::try_from(v).map_err(|_| ToolError::InvalidArgs(format!("{key} must be 4 numbers"))) + }) + .transpose() + }; + let spec = match (nums("margins")?, nums("rect")?) { + (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass margins or rect, not both".into())), + (Some(m), None) => BoxSpec::Margins(m), + (None, Some(r)) => { + // Top-left-origin points on the displayed page → user space (one page at a time). + if pages.len() != 1 { + return Err(ToolError::InvalidArgs("rect applies to a single page; use margins for several".into())); + } + let p = &doc.info.pages[pages[0]]; + let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); + BoxSpec::Rect([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + (None, None) => BoxSpec::Remove, + }; + let mut out = self.apply(a, Edit::SetPageBox { pages, which, spec })?; + out["page_sizes"] = json!(self.doc(a)?.info.pages.iter().map(|p| [p.width, p.height]).collect::>()); + Ok(out) + } + + pub(crate) fn insert_blank(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let at = self.position(a, "at")?; + // Default to the size of the neighbouring page, as Acrobat does; Letter for empty files. + let near = doc.info.pages.get(at.saturating_sub(1)).or(doc.info.pages.first()); + let (w, h) = near.map(|p| (f64::from(p.width), f64::from(p.height))).unwrap_or((612.0, 792.0)); + let width = a.opt_num("width")?.unwrap_or(w); + let height = a.opt_num("height")?.unwrap_or(h); + if !(1.0..=14400.0).contains(&width) || !(1.0..=14400.0).contains(&height) { + return Err(ToolError::InvalidArgs("width and height must be between 1 and 14400 points".into())); + } + self.apply(a, Edit::InsertBlankPage { at, width, height }) + } + + pub(crate) fn insert_file(&mut self, a: &Args) -> Result { + let at = self.position(a, "at")?; + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) + } + + /// Write freshly created bytes to `out` and/or open them as a new document. + pub(crate) fn deliver(&mut self, a: &Args, name: &str, bytes: Arc>) -> Result { + let mut result = json!({ "bytes": bytes.len() }); + let out = a.opt_str("out")?; + if let Some(out) = out { + let path = self.resolve(out, true)?; + write_atomic(&path, &bytes)?; + result["path"] = json!(path.to_string_lossy()); + } + if a.opt_bool("open")?.unwrap_or(out.is_none()) { + let id = self.session.open_new(name, bytes).map_err(failed)?; + result["document"] = summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?); + } + Ok(result) + } + + pub(crate) fn doc_create_multiple(&mut self, a: &Args) -> Result { + let paths = a.strs("paths")?; + if paths.is_empty() { + return Err(ToolError::InvalidArgs("paths must list at least one file".into())); + } + let separate = match a.opt_str("mode")? { + None | Some("combine") => false, + Some("separate") => true, + Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))), + }; + if separate { + return self.create_separate(a, &paths); + } + let ranges: Vec> = match a.get("pages") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), + Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), + }; + // Each file is read, converted and copied in turn, so a long list holds one file at a time. + let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; + let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + Ok((title, pdf, range)) + }); + let bytes = self.session.combine_each(sources)?.ok_or_else(|| ToolError::InvalidArgs("paths must list at least one file".into()))?; + self.deliver(a, "Combined", bytes) + } + + /// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others. + fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result { + let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut out = Vec::new(); + for &p in paths { + let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { + let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?; + let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); + let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; + Ok((kind, stem, pdf)) + }); + out.push(match converted { + Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }), + Ok((_, stem, pdf)) => { + let target = unused(&dir, &stem); + write_atomic(&target, &pdf)?; + json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() }) + } + Err(e) => json!({ "path": p, "error": e }), + }); + } + Ok(json!({ "files": out })) + } + + pub(crate) fn page_extract(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let (id, name) = (doc.id, format!("{} (extract)", doc.name)); + let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "page".into()); + let pages = self.pages(a, "pages")?; + let mut out = if a.opt_bool("separate")?.unwrap_or(false) { + // Each page as its own file. + let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut files = Vec::new(); + for &p in &pages { + let bytes = self.session.extract(id, &[p]).map_err(failed)?; + let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); + write_atomic(&path, &bytes)?; + files.push(path.to_string_lossy().into_owned()); + } + json!({ "files": files }) + } else { + let bytes = self.session.extract(id, &pages).map_err(failed)?; + self.deliver(a, &name, bytes)? + }; + if a.opt_bool("delete")?.unwrap_or(false) { + let deleted = self.apply(a, Edit::DeletePages { pages })?; + out["original"] = deleted; + } + Ok(out) + } + + pub(crate) fn doc_combine(&mut self, a: &Args) -> Result { + let paths = a.strs("paths")?; + if paths.len() < 2 { + return Err(ToolError::InvalidArgs("combine needs at least two files".into())); + } + let ranges: Vec> = match a.get("pages") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() => v + .iter() + .enumerate() + .map(|(index, value)| match value { + Value::Null => Ok(None), + Value::String(range) => Ok(Some(range.clone())), + _ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))), + }) + .collect::>()?, + Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), + }; + let passwords: Vec> = match a.get("passwords") { + None | Some(Value::Null) => vec![None; paths.len()], + Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => { + v.iter().map(|x| x.as_str().map(str::to_owned)).collect() + } + Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())), + }; + // Entries sharing a group number are one file split into parts placed apart. + let groups: Option>> = match a.get("groups") { + None | Some(Value::Null) => None, + Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => { + Some(v.iter().map(Value::as_u64).collect()) + } + Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())), + }; + let field_conflicts = match a.opt_str("field_conflicts")? { + None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename, + Some("merge") => pdfcraft_organize::FieldConflicts::Merge, + Some(other) => { + return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}"))); + } + }; + let mut sources: Vec = Vec::new(); + let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new(); + for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() { + let path = self.resolve(p, false)?; + let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten()); + let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g)); + let bytes = match first { + Some((g, first_path, at)) => { + if *first_path != path { + return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display()))); + } + sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))? + } + None => { + if let Some(g) = group { + firsts.push((g, path.clone(), sources.len())); + } + let bytes = Arc::new(read_input(&path)?); + // Images and text files are made into PDFs first (#569), as in the Combine files tab. + let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + self.session.as_pdf(&file_name, &bytes).map_err(failed)? + } + }; + let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + sources.push((name, bytes, range)); + } + let passwords: Vec> = passwords.iter().map(Option::as_deref).collect(); + let combined = match groups { + None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts), + Some(groups) => { + // A path without a group is a file of its own: give it a key no group uses. + let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1)); + let mut keys = Vec::with_capacity(groups.len()); + for g in &groups { + keys.push(match g { + Some(g) => *g, + None => { + let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?; + next = own.checked_add(1); + own + } + }); + } + self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts) + } + } + .map_err(failed)?; + let mut result = self.deliver(a, "Combined", combined.bytes)?; + if !combined.renamed_fields.is_empty() { + result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::>()); + } + Ok(result) + } + + pub(crate) fn doc_split(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "part".into()); + let bookmarks = a.opt_bool("bookmarks")?.unwrap_or(false); + let max_mb = a.opt_num("max_mb")?; + let chosen = [a.get("every").is_some(), a.get("before").is_some(), bookmarks, max_mb.is_some()].iter().filter(|x| **x).count(); + if chosen != 1 { + return Err(ToolError::InvalidArgs( + "pass exactly one of every (pages per file), before (page numbers), bookmarks: true, or max_mb".into(), + )); + } + let mut titles: Vec<(usize, String)> = Vec::new(); + let parts = if let Some(mb) = max_mb { + if !mb.is_finite() || mb <= 0.0 { + return Err(ToolError::InvalidArgs("max_mb must be positive".into())); + } + self.session.split_by_size(id, (mb * 1_048_576.0) as usize).map_err(failed)? + } else { + let by = match (a.opt_int("every")?, a.opt_ints("before")?) { + (Some(n), None) if n > 0 => pdfcraft_organize::SplitBy::PageCount(n as usize), + (None, Some(b)) => pdfcraft_organize::SplitBy::Before(one_based(&b)?), + _ if bookmarks => { + titles = self.session.bookmark_splits(id); + if titles.is_empty() { + return Err(failed("the document has no top-level bookmarks")); + } + pdfcraft_organize::SplitBy::Before(titles.iter().map(|t| t.0).collect()) + } + _ => return Err(ToolError::InvalidArgs("every must be a positive page count".into())), + }; + self.session.split(id, &by).map_err(failed)? + }; + let dir = self.resolve(a.str("out_dir")?, true)?; + std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + let mut files = Vec::new(); + let mut used = std::collections::HashSet::new(); + for (i, (first, last, bytes)) in parts.iter().enumerate() { + let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); + let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { + Some((_, t)) => format!("{stem}-{}", safe(t)), + None => format!("{stem}-part{}", i + 1), + }; + // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. + if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { + base.truncate(end); + } + let file = format!("{base}.pdf"); + // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. + let mut unique = file; + let mut n = 1; + while !used.insert(unique.to_lowercase()) { + n += 1; + unique = format!("{base}-{n}.pdf"); + } + let path = child(&dir, &unique); + write_atomic(&path, bytes)?; + files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); + } + Ok(json!({ "files": files })) + } +} diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs index 99310832fb..decb18d2b7 100644 --- a/crates/automation/src/batch.rs +++ b/crates/automation/src/batch.rs @@ -17,6 +17,7 @@ const MAX_INPUT: u64 = 512 * 1024 * 1024; struct Recipe<'a> { steps: Vec<&'a str>, images: optimize::ImageSettings, + mono: optimize::MonoSettings, ocr: ocr::OcrSettings, } @@ -38,12 +39,17 @@ impl<'a> Recipe<'a> { } } let mut images = optimize::ImageSettings::REDUCE; + let mut mono = optimize::MonoSettings::REDUCE; if let Some(ppi) = args.opt_num("ppi")? { if !(9.0..=1600.0).contains(&ppi) { return Err(Args::wrong("ppi", "9 to 1600")); } images.target_ppi = ppi; images.above_ppi = ppi * 1.5; + // Black-and-white images keep Reduce File Size's higher resolution, at which text + // stays legible, and are never taken below a higher `ppi` asked for. + mono.target_ppi = mono.target_ppi.max(ppi); + mono.above_ppi = mono.target_ppi * 1.5; } if let Some(q) = args.opt_int("quality")? { if !(1..=100).contains(&q) { @@ -54,11 +60,11 @@ impl<'a> Recipe<'a> { if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) { return Err(Args::wrong("dpi", "72 to 600")); } - Ok(Self { steps, images, ocr: auto.ocr_settings(args)? }) + Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)? }) } fn accepts(&self, path: &Path) -> bool { - let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("").to_ascii_lowercase(); + let extension = path.extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase(); extension == "pdf" || (self.steps.contains(&"convert") && pdfcraft_engine::CONVERTIBLE.contains(&extension.as_str())) } @@ -70,7 +76,7 @@ impl<'a> Recipe<'a> { if input.len() as u64 > MAX_INPUT { return Err(failed("input exceeds 512 MiB")); } - let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| failed("input name is not UTF-8"))?; + let name = path.file_name().and_then(std::ffi::OsStr::to_str).ok_or_else(|| failed("input name is not UTF-8"))?; let mut bytes = Arc::new(input); let mut log = Vec::new(); for step in &self.steps { @@ -88,7 +94,7 @@ impl<'a> Recipe<'a> { } "optimize" => { let id = session.open(name, None, bytes.clone(), None).map_err(failed)?; - let settings = optimize::Settings { color: self.images, gray: self.images, ..Default::default() }; + let settings = optimize::Settings { color: self.images, gray: self.images, mono: self.mono, ..Default::default() }; let (candidate, _) = session.optimized_bytes(id, &settings, &[])?; session.close(id); if candidate.len() < bytes.len() { diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 302688f729..f87fd0b58e 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -3,9 +3,9 @@ //! Geometry follows the automation convention: points from the top-left of the displayed page, //! y down. It is converted to PDF user space (crop box, `/Rotate`) here. -use pdfcraft_engine::{ - Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort, -}; +use std::collections::HashMap; + +use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; use pdfcraft_render::{Annotation, PageInfo}; use serde_json::{Value, json}; @@ -13,6 +13,8 @@ use crate::{Args, Automation, Content, DEFAULT_DPI, MAX_DPI, Result, ToolError, /// Author used when a tool call names none. pub(crate) const DEFAULT_AUTHOR: &str = "PdfCraft"; +/// The largest side of a stamp's box, in points: the largest page PDF allows (ISO 32000-2 Annex C). +const MAX_STAMP_SIDE: f64 = 14_400.0; pub(crate) fn parse_color(s: &str) -> Result { let named = match s.to_ascii_lowercase().as_str() { @@ -79,13 +81,11 @@ fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { } /// The engine's `at` for a note or attachment icon whose top-left corner *as displayed* is the -/// view point (x, y): the top-left (`[x0, y1]`) of the icon's square in user space. Converting -/// the point alone is only right on unrotated pages — under `/Rotate` that corner of the -/// displayed square is another corner of the user-space one, and the icon lands one icon-width -/// away from where it was asked for. +/// view point (x, y): the upper-left corner (`[x0, y1]`) of its `/Rect`. Notes and attachments +/// are NoRotate, so on a turned page the icon is drawn upright with its top-left at that corner +/// (ISO 32000-2 §12.5.3), and `comment_list` reports that square (`Annotation::shown_rect`). fn icon_anchor(p: &PageInfo, x: f64, y: f64) -> [f64; 2] { - let r = rect_to_user(p, [x, y, x + NOTE_SIZE, y + NOTE_SIZE]); - [r[0], r[3]] + to_user(p, x, y) } /// A user-space rectangle as the displayed-page rectangle every tool reports and accepts: @@ -154,8 +154,13 @@ impl Automation { fn comment_target(&self, a: &Args) -> Result<(usize, usize)> { let all = self.comments(a)?; if let Some(id) = a.opt_str("id")? { - let c = - all.iter().find(|c| c.name.as_deref() == Some(id)).ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?; + // `/NM` need not be unique (PyMuPDF names the first comment on every page `fitz-A0`): + // the first match could be another page's comment, so a shared id is refused. + let mut named = all.iter().filter(|c| c.name.as_deref() == Some(id)); + let c = named.next().ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?; + if named.next().is_some() { + return Err(failed(format!("several comments have the id {id:?}: pass page and index instead (see comment_list)"))); + } return Ok((c.page, c.index)); } let (Some(page), Some(index)) = (a.opt_int("page")?, a.opt_int("index")?) else { @@ -173,8 +178,7 @@ impl Automation { let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize); let all = self.comments(a)?; let view = |c: &Annotation| -> Value { - let p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))]; - json!({ + let mut v = json!({ "id": c.name, "page": c.page + 1, "index": c.index + 1, @@ -182,37 +186,58 @@ impl Automation { "author": c.author, "contents": c.contents, "modified": c.modified, - "rect": rect_to_view(p, c.rect), + "rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))), "color": c.color.map(hex), - }) + }); + // A line's ends, or the corners of connected lines, a polygon or a cloud, as + // comment_edit's `points` takes them. + // Unchanged on a page the viewer could not read, as `rect` is. + if !c.points.is_empty() { + let round = |v: f32| (v * 100.0).round() / 100.0; + let page = doc.info.pages.get(c.page); + v["points"] = + json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::>()); + } + v + }; + // Every reply under the comment its thread starts from, at any depth, in document order: + // a reply to a reply keeps its `/IRT` on the direct parent, so following one level would + // drop it. Parents are found by reference, as `/NM` need not be unique. A status or + // checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the + // Comments panel: one set on a reply is not the comment's. + let tops = pdfcraft_render::thread_tops(&all); + let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); + for r in &all { + let Some(&top) = tops.get(&(r.page, r.index)) else { continue }; + let key = match r.parent { + Some(parent) if r.state.is_some() => parent, + _ => top, + }; + threads.entry(key).or_default().push(r); + } + // An annotation's review status and checkmark: the latest of each set on it. + let states = |c: &Annotation, v: &mut Value| { + let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); + v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone())); + v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); }; let roots: Vec = all .iter() - .filter(|c| c.in_reply_to.is_none() && only.is_none_or(|p| c.page == p)) + .filter(|c| c.is_comment() && only.is_none_or(|p| c.page == p)) .map(|c| { let mut v = view(c); - // Every reply under this comment, at any depth, in document order. A reply to a - // reply keeps its `/IRT` on the direct parent, so following one level would drop it. - let descends_from = |r: &Annotation, root: &str| -> bool { - let mut parent = r.in_reply_to.as_deref(); - for _ in 0..64 { - let Some(nm) = parent else { return false }; - if nm == root { - return true; - } - parent = all.iter().find(|x| x.name.as_deref() == Some(nm)).and_then(|x| x.in_reply_to.as_deref()); - } - false - }; - let thread: Vec<&Annotation> = match c.name.as_deref() { - Some(nm) => all.iter().filter(|r| r.in_reply_to.is_some() && descends_from(r, nm)).collect(), - None => Vec::new(), - }; - let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()); - v["status"] = json!(status); - v["marked"] = json!(thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); + states(c, &mut v); v["locked"] = json!(c.locked); - v["replies"] = thread.iter().filter(|r| r.state.is_none()).map(|r| view(r)).collect(); + let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); + v["replies"] = thread + .iter() + .filter(|r| r.state.is_none()) + .map(|r| { + let mut reply = view(r); + states(r, &mut reply); + reply + }) + .collect(); v }) .collect(); @@ -375,7 +400,7 @@ impl Automation { "attachment" => { let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?; let path = self.resolve(a.str("path")?, false)?; - let data = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let data = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let icon = match a.opt_str("icon")? { Some(n) => pdfcraft_engine::AttachIcon::from_name(n) @@ -477,6 +502,10 @@ impl Automation { if sensitivity.is_some() && !scan { return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into())); } + let requested_color = a.color("color")?; + if requested_color.is_some() && !matches!(kind, "text" | "date") { + return Err(ToolError::InvalidArgs("color is only for type text or date".into())); + } let requested_size = a.opt_num("font_size")?; if requested_size.is_some() && !matches!(kind, "text" | "date") { return Err(ToolError::InvalidArgs("font_size is only for type text or date".into())); @@ -551,11 +580,13 @@ impl Automation { "line" => FillMark::Line, other => return Err(ToolError::InvalidArgs(format!("unknown type {other:?}"))), }; - let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) }; - (Shape::Mark { rect: [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0], mark }, String::new()) + (Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new()) } }; - let style = Style::default_for(&shape); + let mut style = Style::default_for(&shape); + if let Some(color) = requested_color { + style.color = color; + } self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author })) } @@ -589,7 +620,10 @@ impl Automation { pub(crate) fn comments_hide(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; let hidden = a.opt_bool("hidden")?.unwrap_or(true); - self.session.set_hide_comments(id, hidden); + if self.session.set_hide_comments(id, hidden) { + // The cached renderer drew with the old setting; page_render must read it again. + self.renderers.remove(&id); + } Ok(json!({ "hidden": hidden })) } @@ -626,14 +660,34 @@ impl Automation { edits.push(Edit::FontSizeAnnotation { page, index, size }); } if let Some(r) = a.nums::<4>("rect")? { - edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) }); + // The rectangle as comment_list reports it: where the comment is drawn. + let doc = self.doc(a)?; + let rect = rect_to_user(&info, r); + let rect = + doc.info.annotations.iter().find(|c| c.page == page && c.index == index).map_or(rect, |c| c.rect_for_shown(rect, info.rotation)); + edits.push(Edit::ResizeAnnotation { page, index, rect }); } if let Some([dx, dy]) = a.nums::<2>("move")? { let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy)); edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] }); } + if let Some(pts) = a.get("points") { + let wrong = || ToolError::InvalidArgs("points must be an array of [x, y] points (a line's two ends, or every corner)".into()); + let points = pts + .as_array() + .ok_or_else(wrong)? + .iter() + .map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::>()).as_deref() { + Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)), + _ => Err(wrong()), + }) + .collect::>>()?; + edits.push(Edit::ReshapeAnnotation { page, index, points }); + } if edits.is_empty() { - return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into())); + return Err(ToolError::InvalidArgs( + "nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(), + )); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; self.apply(a, edit) @@ -647,19 +701,34 @@ impl Automation { impl Automation { /// A custom stamp: a picture file (PDF page or image) centred at `at`, at its natural size - /// (at most 200 pt) or `width` points wide. + /// (at most 200 pt), or fitted inside `rect`, keeping its shape and centred (#4077). pub(crate) fn stamp_custom(&mut self, a: &Args) -> Result { let page = self.page(a)?; let info = self.doc(a)?.info.pages[page].clone(); + let rect = match (a.nums::<4>("rect")?, a.get("at")) { + (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass at (a point) or rect (a box), not both".into())), + (Some(r), None) => { + let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs()); + if !((1.0..=MAX_STAMP_SIDE).contains(&w) && (1.0..=MAX_STAMP_SIDE).contains(&h)) { + return Err(ToolError::InvalidArgs(format!( + "rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_STAMP_SIDE} pt" + ))); + } + rect_to_user(&info, r) + } + (None, _) => { + let [x, y] = a.need::<2>("at", "a stamp (its centre, or pass rect)")?; + let [ux, uy] = to_user(&info, x, y); + [ux, uy, ux, uy] + } + }; let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned())); - let [x, y] = a.need::<2>("at", "a stamp (its centre)")?; - let [ux, uy] = to_user(&info, x, y); let edit = Edit::AddCustomStamp { page, - rect: [ux, uy, ux, uy], + rect, name, file: pdfcraft_engine::MarkFile { name: file, diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index faefc5fa7c..7518072d1c 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -2,7 +2,7 @@ //! move/resize/retype/restyle it, delete it. Geometry is in points from the top-left of the //! displayed page (y down), like every other tool. -use pdfcraft_engine::{AddedContent, AddedText, Edit, FontFamily, TextAlign}; +use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign}; use serde_json::{Value, json}; use crate::comments::parse_color; @@ -53,17 +53,54 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> { t.color = parse_color(c)?; } if let Some(al) = a.opt_str("align")? { - t.align = match al { - "left" => TextAlign::Left, - "center" | "centre" => TextAlign::Center, - "right" => TextAlign::Right, - "justify" => TextAlign::Justify, - other => return Err(bad(format!("unknown align {other:?}"))), - }; + t.align = parse_align(al)?; + // The whole box: no paragraph keeps an alignment of its own unless `aligns` gives it one. + t.aligns.clear(); + } + if let Some(aligns) = opt_aligns(a)? { + let count = pdfcraft_engine::paragraph_align::count(&t.text); + t.aligns = pdfcraft_engine::paragraph_align::tidy(aligns.into_iter().take(count).collect(), t.align); + } + if let Some(c) = a.opt_num("char_spacing")? { + if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) { + return Err(bad(format!("char_spacing must be between -{ADDED_CHAR_SPACING_LIMIT} and {ADDED_CHAR_SPACING_LIMIT} points"))); + } + t.char_spacing = c; } Ok(()) } +pub(crate) fn parse_align(al: &str) -> Result { + Ok(match al { + "left" => TextAlign::Left, + "center" | "centre" => TextAlign::Center, + "right" => TextAlign::Right, + "justify" => TextAlign::Justify, + other => return Err(bad(format!("unknown align {other:?}"))), + }) +} + +/// Most entries `aligns` takes: one per paragraph, and no text has this many. +const MAX_ALIGNS: usize = 4096; + +/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`. +pub(crate) fn opt_aligns(a: &Args) -> Result>>> { + let Some(v) = a.get("aligns") else { return Ok(None) }; + let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?; + if items.len() > MAX_ALIGNS { + return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries"))); + } + items + .iter() + .map(|v| match v { + Value::Null => Ok(None), + Value::String(s) => parse_align(s).map(Some), + _ => Err(bad("each entry of aligns is left, center, right, justify or null")), + }) + .collect::>>() + .map(Some) +} + impl Automation { pub(crate) fn content_list(&self, a: &Args) -> Result { let doc = self.doc(a)?; @@ -73,9 +110,9 @@ impl Automation { .iter() .map(|it| { let index = per_page.entry(it.page).or_default(); - let ph = doc.info.pages[it.page].height as f64; - let mut v = - json!({ "page": it.page + 1, "index": *index + 1, "rect": to_view(it.content.rect(), ph).map(|x| (x * 100.0).round() / 100.0) }); + // Unchanged on a page the viewer could not read (#2660), like `crate::view_rect`. + let rect = doc.info.pages.get(it.page).map_or(it.content.rect(), |p| to_view(it.content.rect(), p.height as f64)); + let mut v = json!({ "page": it.page + 1, "index": *index + 1, "rect": rect.map(|x| (x * 100.0).round() / 100.0) }); *index += 1; match &it.content { AddedContent::Text(t) => { @@ -85,6 +122,7 @@ impl Automation { v["size"] = json!(t.size); v["bold"] = json!(t.bold); v["italic"] = json!(t.italic); + v["char_spacing"] = json!(t.char_spacing); } AddedContent::Image(_) => v["type"] = json!("image"), } @@ -118,7 +156,7 @@ impl Automation { let page = self.page(a)?; let ph = self.doc(a)?.info.pages[page].height as f64; let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let rect = rect_arg(a, "rect")?.map(|r| to_display(r, ph)); let mut out = self.apply(a, Edit::AddImage { page, rect, name, bytes: std::sync::Arc::new(bytes) })?; @@ -153,6 +191,8 @@ impl Automation { match &mut content { AddedContent::Text(t) => { if let Some(s) = a.opt_str("text")? { + // Each paragraph keeps its own alignment through the retyping. + t.aligns = pdfcraft_engine::paragraph_align::remap(&t.text, s, &t.aligns); t.text = s.to_string(); } style(a, t)?; @@ -164,14 +204,15 @@ impl Automation { if a.get("text").is_some() { return Err(bad("images have no text")); } - if ["font", "size", "bold", "italic", "color", "align"].iter().any(|k| a.get(k).is_some()) { - return Err(bad("font, size, bold, italic, color and align apply to text")); + if ["font", "size", "bold", "italic", "color", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) { + return Err(bad("font, size, bold, italic, color, align, aligns and char_spacing apply to text")); } if let Some(deg) = a.opt_int("rotate")? { if deg.rem_euclid(90) != 0 { return Err(bad("rotate must be a multiple of 90")); } - i.rotation = ((i64::from(i.rotation) * 90 + deg).rem_euclid(360) / 90) as u8; + // Reduce untrusted degrees before adding the current quarter turns. + i.rotation = (i64::from(i.rotation) + deg.rem_euclid(360) / 90).rem_euclid(4) as u8; } if a.opt_bool("flip_h")? == Some(true) { i.flip_h = !i.flip_h; @@ -180,14 +221,16 @@ impl Automation { i.flip_v = !i.flip_v; } if let Some(c) = rect_arg(a, "crop")? { - if !c.iter().all(|v| (0.0..0.5).contains(v)) { - return Err(bad("crop takes fractions from 0 to under 0.5 (left, bottom, right, top)")); + if !pdfcraft_engine::AddedImage::crop_valid(c) { + return Err(bad( + "crop takes fractions from 0 to under 1 (left, bottom, right, top); left and right together, and bottom and top together, trim at most 0.95", + )); } i.crop = c; } if let Some(p) = a.opt_str("image")? { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); // Box and transforms first, then the new picture. self.apply(a, Edit::UpdateContent { page, index, content: content.clone() })?; @@ -202,6 +245,63 @@ impl Automation { let (page, index, _, _) = self.item(a)?; self.apply(a, Edit::DeleteContent { page, index }) } + + /// Edit ▸ Copy, then Paste: an added item onto `to_page` of `to_doc` (its own page and + /// document by default), as it is, at `at` (its top-left) or where it was, stepped down and to + /// the right past copies of it already on that page. + pub(crate) fn content_copy(&mut self, a: &Args) -> Result { + let (page, index, content, _) = self.item(a)?; + let source = self.doc(a)?; + source.check_copying()?; + let from = source.id; + let file = match &content { + AddedContent::Text(_) => None, + AddedContent::Image(_) => { + let (ext, bytes) = source.added_image_file(page, index).map_err(failed)?; + Some((format!("Pasted image.{ext}"), std::sync::Arc::new(bytes))) + } + }; + let to_doc = match a.opt_int("to_doc")? { + Some(d) => { + let d = u64::try_from(d).map_err(|_| bad("to_doc must be positive"))?; + self.session.get(pdfcraft_engine::DocId(d)).ok_or_else(|| failed(format!("no open document with id {d} (see doc_list)")))?.id + } + None => from, + }; + let doc = self.session.get(to_doc).ok_or_else(|| failed("the document is no longer open"))?; + let n = doc.info.pages.len(); + let to_page = match a.opt_int("to_page")? { + Some(p) => usize::try_from(p) + .ok() + .and_then(|p| p.checked_sub(1)) + .filter(|p| *p < n) + .ok_or_else(|| bad(format!("to_page {p} is out of range: the document has {n} pages")))?, + None if page < n => page, + None => return Err(bad(format!("page {} is out of range in the target: it has {n} pages; pass to_page", page + 1))), + }; + let info = doc.info.pages.get(to_page).ok_or_else(|| bad("the page no longer exists"))?; + let (pw, ph) = (f64::from(info.width), f64::from(info.height)); + let at = match a.get("at") { + Some(v) => { + let p: Option> = v.as_array().and_then(|x| x.iter().map(Value::as_f64).collect()); + let [x, y]: [f64; 2] = p.and_then(|p| p.try_into().ok()).ok_or_else(|| bad("at must be [x, y]"))?; + Some([x, ph - y]) + } + None => None, + }; + let on_page = || doc.added().iter().filter(|x| x.page == to_page); + let rect = match at { + Some(at) => pdfcraft_engine::added_paste_rect(content.rect(), [pw, ph], 0, Some(at)), + None => pdfcraft_engine::added_paste_place(on_page().map(|x| &x.content), &content, [pw, ph]), + }; + let before = on_page().count(); + let target = json!({ "doc": to_doc.0 }); + let mut out = self.apply(&Args(&target), Edit::PasteContent { page: to_page, content: content.with_rect(rect), file })?; + out["page"] = json!(to_page + 1); + out["index"] = json!(before + 1); + out["rect"] = json!(to_view(rect, ph).map(|x| (x * 100.0).round() / 100.0)); + Ok(out) + } } impl Automation { diff --git a/crates/automation/src/conventions.rs b/crates/automation/src/conventions.rs index ac670109fb..a84c6488a4 100644 --- a/crates/automation/src/conventions.rs +++ b/crates/automation/src/conventions.rs @@ -1,7 +1,7 @@ //! Shared command/inspection/preview entry points, using the existing confined tool path. use serde_json::{Value, json}; -use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools}; +use crate::{Args, Automation, Content, Result, ToolError, failed, info, rendered_content, tools}; use pdfcraft_engine::commands; use pdfcraft_render::{RenderRequest, RequestKind}; @@ -108,7 +108,8 @@ impl Automation { Ok(json!({"documents":self.session.docs().iter().map(info).collect::>()})) } - pub(crate) fn render_preview(&mut self, args: &Args) -> Result { + /// A bounded PNG of the page, with its render warnings after it as `page_render` gives them. + pub(crate) fn render_preview(&mut self, args: &Args) -> Result> { let id = if args.opt_int("doc")?.is_some() { self.doc(args)?.id } else { @@ -134,7 +135,6 @@ impl Automation { if let Some(error) = out.error { return Err(failed(error)); } - let data = encode_png(out.width, out.height, &out.rgba)?; - Ok(Content::Png { data, width: out.width, height: out.height }) + rendered_content(&out, page) } } diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs new file mode 100644 index 0000000000..36e0d00454 --- /dev/null +++ b/crates/automation/src/dispatch.rs @@ -0,0 +1,818 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary, text_style_levels}; +use super::paths::{read_input, write_atomic}; +use super::{Arc, Automation, Edit, Value, block_overlapping, comments, content, json, tools}; + +/// One piece of a tool's result. +#[derive(Clone, Debug, PartialEq)] +pub enum Content { + /// Structured data (MCP: a text block with the JSON plus `structuredContent`). + Json(Value), + /// A PNG image. + Png { data: Vec, width: u32, height: u32 }, +} + +impl Automation { + /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). + pub fn call(&mut self, name: &str, args: &Value) -> Result> { + let empty = json!({}); + let args = if args.is_null() { &empty } else { args }; + if !args.is_object() { + return Err(ToolError::InvalidArgs("arguments must be a JSON object".into())); + } + let def = tools::find(name).ok_or_else(|| ToolError::UnknownTool(name.into()))?; + tools::check_args(def, args)?; + let a = Args(args); + let out = match name { + "doc_open" => self.doc_open(&a)?, + "doc_batch" => self.doc_batch(&a)?, + "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), + "doc_info" => { + let id = self.doc(&a)?.id; + self.session.refresh_fonts(id); + info(self.doc(&a)?) + } + "doc_close" => self.doc_close(&a)?, + "doc_save" => self.doc_save(&a)?, + "doc_set_info" => { + let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; + self.apply(&a, edit)? + } + "page_render" => return self.page_render(&a), + "comment_image_preview" => return self.comment_image_preview(&a), + "text_extract" => self.text_extract(&a)?, + "text_word_count" => self.text_word_count(&a)?, + "text_find" => self.text_find(&a)?, + "page_rotate" => { + let degrees = a.int("degrees")?; + if degrees % 90 != 0 { + return Err(ToolError::InvalidArgs("degrees must be a multiple of 90".into())); + } + let doc = self.doc(&a)?; + let base = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let parity = match a.opt_str("subset")?.unwrap_or("all") { + "all" => pdfcraft_engine::PageParity::Both, + "even" => pdfcraft_engine::PageParity::Even, + "odd" => pdfcraft_engine::PageParity::Odd, + s => return Err(ToolError::InvalidArgs(format!("unknown subset {s:?} (all, even, odd)"))), + }; + let orientation = match a.opt_str("orientation")?.unwrap_or("all") { + "all" => pdfcraft_engine::PageOrientation::Both, + "landscape" => pdfcraft_engine::PageOrientation::Landscape, + "portrait" => pdfcraft_engine::PageOrientation::Portrait, + o => return Err(ToolError::InvalidArgs(format!("unknown orientation {o:?} (all, landscape, portrait)"))), + }; + let pages = pdfcraft_engine::filter_pages(&self.doc(&a)?.info, &base, parity, orientation); + if pages.is_empty() { + return Err(failed("no pages match the filters")); + } + let n = pages.len(); + let mut out = self.apply(&a, Edit::RotatePages { pages, degrees })?; + out["rotated"] = json!(n); + out + } + "page_enhance_camera" => { + let page = self.page(&a)?; + let given = a.nums::<8>("corners")?; + let doc = self.doc(&a)?; + let (id, info) = (doc.id, doc.info.pages.get(page).cloned().ok_or_else(|| failed("no such page"))?); + let corners = match given { + Some(c) => [0, 1, 2, 3].map(|i| { + let [x, y] = info.view_to_user(c[2 * i] as f32, c[2 * i + 1] as f32); + [x as f64, y as f64] + }), + None => self.session.detect_camera_corners(id, page).ok_or_else(|| { + failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]") + })?, + }; + let level = match a.opt_int("level")? { + None => 0, + Some(l @ 0..=100) => l as u8, + Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), + }; + let look = match a.opt_str("mode")? { + None => pdfcraft_engine::camera_clean::Look::Colour, + Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?, + }; + self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?; + let r = |v: f32| (v * 100.0).round() / 100.0; + let used: Vec = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect(); + let mut out = summary(self.doc(&a)?); + out["corners"] = json!(used); + out["detected"] = json!(given.is_none()); + out + } + "page_delete" => { + let pages = self.pages(&a, "pages")?; + self.apply(&a, Edit::DeletePages { pages })? + } + "page_move" => { + let pages = self.pages(&a, "pages")?; + let to = self.position(&a, "to")?; + self.apply(&a, Edit::MovePages { pages, to })? + } + "page_reverse" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..self.doc(&a)?.info.pages.len()).collect(), + }; + self.apply(&a, Edit::ReversePages { pages })? + } + "page_insert_blank" => self.insert_blank(&a)?, + "page_insert_file" => self.insert_file(&a)?, + "page_extract" => self.page_extract(&a)?, + "doc_combine" => self.doc_combine(&a)?, + "doc_create_multiple" => self.doc_create_multiple(&a)?, + "doc_split" => self.doc_split(&a)?, + "edit_undo" => { + let id = self.doc(&a)?.id; + let label = self.session.undo(id).map_err(failed)?.to_string(); + json!({ "undone": label, "document": summary(self.doc(&a)?) }) + } + "edit_redo" => { + let id = self.doc(&a)?.id; + let label = self.session.redo(id).map_err(failed)?.to_string(); + json!({ "redone": label, "document": summary(self.doc(&a)?) }) + } + "command_list" => self.command_list(&a)?, + "command_run" => return self.command_run(&a), + "command_batch" => self.command_batch(&a)?, + "doc_inspect" => self.doc_inspect(&a)?, + "render_preview" => return self.render_preview(&a), + "page_number" => { + use pdfcraft_organize::LabelStyle as L; + let n = self.doc(&a)?.info.pages.len(); + let (from, to) = (a.int("from")?, a.int("to")?); + if from < 1 || to < from || to as usize > n { + return Err(ToolError::InvalidArgs(format!("from and to must satisfy 1 ≤ from ≤ to ≤ {n}"))); + } + let style = match a.opt_str("style")?.unwrap_or("decimal") { + "decimal" => L::Decimal, + "upper-roman" => L::UpperRoman, + "lower-roman" => L::LowerRoman, + "upper-alpha" => L::UpperAlpha, + "lower-alpha" => L::LowerAlpha, + "none" => L::None, + other => return Err(ToolError::InvalidArgs(format!("unknown style {other:?}"))), + }; + let prefix = a.opt_str("prefix")?.unwrap_or_default().to_string(); + let first = a.opt_int("start")?.unwrap_or(1).clamp(1, u32::MAX as i64) as u32; + let mut out = self.apply(&a, Edit::NumberPages { from: from as usize - 1, to: to as usize - 1, style, prefix, first })?; + out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::>()); + out + } + "bookmark_list" => self.bookmark_listing(&a)?, + "bookmark_add" => { + let page = self.page(&a)?; + let parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); + let title = a.str("title")?.to_string(); + let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?); + let [left, top] = match self.doc(&a)?.info.pages.get(page) { + Some(p) => p.xyz_for_view(left, top), + None => [None, None], + }; + let (left, top) = (left.map(f64::from), top.map(f64::from)); + self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })? + } + "bookmark_rename" => { + let (path, title) = (a.path("path")?, a.str("title")?.to_string()); + self.apply(&a, Edit::RenameBookmark { path, title })? + } + "bookmark_delete" => { + let path = a.path("path")?; + self.apply(&a, Edit::DeleteBookmark { path })? + } + "bookmark_move" => { + let from = a.path("path")?; + let to_parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); + self.apply(&a, Edit::MoveBookmark { from, to_parent, index })? + } + "bookmark_set_page" => { + let (path, page) = (a.path("path")?, self.page(&a)?); + self.apply(&a, Edit::SetBookmarkPage { path, page })? + } + "bookmark_set_style" => { + let path = a.path("path")?; + // Left out keeps the colour; [0, 0, 0] is the default black. + let color = match a.get("color") { + None => None, + Some(v) => { + let rgb: Option> = v.as_array().and_then(|c| c.iter().map(Value::as_f64).collect()); + match rgb.as_deref() { + Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]), + _ => return Err(ToolError::InvalidArgs("color must be [r, g, b], each from 0 to 1".into())), + } + } + }; + let (bold, italic) = (a.opt_bool("bold")?, a.opt_bool("italic")?); + if color.is_none() && bold.is_none() && italic.is_none() { + return Err(ToolError::InvalidArgs("give color, bold or italic".into())); + } + let mut edits = Vec::new(); + if let Some(color) = color { + edits.push(Edit::SetBookmarkColor { path: path.clone(), color: Some(color) }); + } + if bold.is_some() || italic.is_some() { + edits.push(Edit::SetBookmarkStyle { path, bold, italic }); + } + // A colour and a style given together are one step to undo, applied all or nothing. + match edits.len() { + 1 => self.apply(&a, edits.remove(0))?, + _ => self.apply(&a, Edit::Batch { label: "Style".into(), edits })?, + } + } + "bookmark_from_structure" => { + let mut out = self.apply(&a, Edit::BookmarksFromStructure)?; + let listing = self.bookmark_listing(&a)?; + out["bookmarks"] = listing["bookmarks"].clone(); + out["next"] = listing["next"].clone(); + out + } + "text_styles" => { + // The samples are the document's text, as text_lines is. + let doc = self.doc_to_copy(&a)?; + let styles: Vec = doc + .text_styles() + .into_iter() + .map(|u| { + json!({ + "font": u.style.font, + "size": u.style.size, + "bold": u.style.bold, + "italic": u.style.italic, + "lines": u.lines, + "pages": u.pages, + "sample": u.sample, + }) + }) + .collect(); + json!({ "styles": styles }) + } + "bookmark_from_text_styles" => { + let levels = text_style_levels(&a)?; + let mut out = self.apply(&a, Edit::BookmarksFromTextStyles { levels })?; + let listing = self.bookmark_listing(&a)?; + out["bookmarks"] = listing["bookmarks"].clone(); + out["next"] = listing["next"].clone(); + out + } + "doc_protect" => self.doc_protect(&a)?, + "page_replace" => { + let pages = self.pages(&a, "pages")?; + let path = self.resolve(a.str("path")?, false)?; + let bytes = Arc::new(read_input(&path)?); + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let src_pages = match a.opt_ints("from_pages")? { + Some(p) => one_based(&p)?, + None => (0..pages.len()).collect(), + }; + self.apply(&a, Edit::ReplacePages { pages, name, bytes, src_pages })? + } + "page_duplicate" => { + let pages = self.pages(&a, "pages")?; + let at = self.opt_position(&a, "at")?; + self.apply(&a, Edit::DuplicatePages { pages, at })? + } + "page_set_box" => self.page_set_box(&a)?, + "page_transition_set" => self.page_transition_set(&a)?, + "doc_create" => self.doc_create(&a)?, + "xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?, + "doc_flatten" => { + let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); + if !comments && !fields { + return Err(ToolError::InvalidArgs("nothing to flatten".into())); + } + self.apply(&a, Edit::Flatten { comments, fields })? + } + "doc_reduce" => { + let id = self.doc(&a)?.id; + let before = self.doc(&a)?.bytes.len(); + let path = self.resolve(a.str("path")?, true)?; + let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; + // As in the app, a copy that isn't smaller is not written (#490). + let written = bytes.len() < before; + if written { + write_atomic(&path, &bytes)?; + } + json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) + } + "doc_optimize" => self.doc_optimize(&a)?, + "doc_initial_view" => self.doc_initial_view(&a)?, + "doc_revisions" => self.doc_revisions(&a)?, + "text_lines" => { + let page = self.page(&a)?; + let doc = self.doc_to_copy(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let lines: Vec = doc + .text_lines(page) + .iter() + .enumerate() + .map(|(i, l)| { + let (u, v) = (info.user_to_view(l.rect[0] as f32, l.rect[1] as f32), info.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); + json!({ + "line": i + 1, + "text": l.text, + "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], + "font": l.base_font, + "size": (l.size * 100.0).round() / 100.0, + }) + }) + .collect(); + json!({ "page": page + 1, "count": lines.len(), "lines": lines }) + } + "image_hit" => { + let page = self.page(&a)?; + let point = a + .get("point") + .and_then(Value::as_array) + .filter(|p| p.len() == 2) + .and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) + .filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9)) + .ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?; + let doc = self.doc(&a)?; + let p = &doc.info.pages[page]; + let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from); + let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect(); + json!({"page":page+1,"images":images}) + } + "page_images" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let list: Vec = doc + .page_images(page) + .iter() + .enumerate() + .map(|(i, im)| { + let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32)); + let visible = im.visible_rect.map(|rect| { + let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); + [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] + }); + json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) + }) + .collect(); + json!({ "page": page + 1, "count": list.len(), "images": list }) + } + "image_edit" | "image_save" => { + let page = self.page(&a)?; + let n = self.doc(&a)?.page_images(page).len(); + let k = a.int("image")?; + if k < 1 || k as usize > n { + return Err(ToolError::InvalidArgs(format!("image {k} is out of range: page {} has {n} images", page + 1))); + } + let index = k as usize - 1; + if name == "image_save" { + let (ext, bytes) = self.doc(&a)?.page_image_file(page, index).map_err(failed)?; + let mut path = self.resolve(a.str("path")?, true)?; + if path.is_dir() { + // Adding the extension to a folder's name would write beside it: "." is the + // root, so that would land outside it. + return Err(failed(format!("{}: is a folder, not a file", path.display()))); + } + if path.extension().is_none() { + path.set_extension(ext); + } + write_atomic(&path, &bytes)?; + json!({ "path": path.to_string_lossy(), "format": ext, "bytes": bytes.len() }) + } else { + use pdfcraft_engine::ImageEdit; + let change = match a.str("action")? { + "move" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("move needs rect: 4 numbers".into()))?; + // Top-left-origin points → user space. + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + "rotate_by" => ImageEdit::RotateBy( + a.get("degrees") + .and_then(Value::as_f64) + .ok_or_else(|| ToolError::InvalidArgs("rotate_by needs degrees: a number (clockwise)".into()))?, + ), + "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), + "crop" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = + <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } + "flip_horizontal" => ImageEdit::Flip { horizontal: true }, + "flip_vertical" => ImageEdit::Flip { horizontal: false }, + "delete" => ImageEdit::Delete, + "replace" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + ImageEdit::Replace { + name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + bytes: Arc::new(bytes), + } + } + other => return Err(ToolError::InvalidArgs(format!("unknown action {other:?}"))), + }; + self.apply(&a, Edit::EditPageImage { page, index, change })? + } + } + "text_paragraphs" => { + let page = self.page(&a)?; + let doc = self.doc_to_copy(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let current = doc.text_blocks(page); + let id = doc.id; + let blocks: Vec = current + .iter() + .enumerate() + .map(|(i, b)| { + let (u, v) = (info.user_to_view(b.rect[0] as f32, b.rect[1] as f32), info.user_to_view(b.rect[2] as f32, b.rect[3] as f32)); + json!({ + "paragraph": i + 1, + "text": b.text, + "editor_text": b.editor_text, + "rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::>()).collect::>(), + "lines": b.lines.iter().map(|l| l + 1).collect::>(), + "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], + "font": b.base_font, + "size": (b.size * 100.0).round() / 100.0, + }) + }) + .collect(); + self.record_listing(id, page, ¤t); + json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) + } + "text_font_files" => { + let roots = if let Some(paths) = a.get("directories") { + let paths = paths + .as_array() + .filter(|p| p.len() <= 16) + .ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?; + paths + .iter() + .map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false)) + .collect::>>()? + } else if let Some(roots) = &self.roots { + // The installed fonts are outside the roots: look in the read root instead. + let read = roots + .read_root() + .ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?; + vec![self.resolve(&read.to_string_lossy(), false)?] + } else if let Some(root) = &self.root { + vec![root.clone()] + } else { + pdfcraft_engine::text_fonts::installed_directories() + }; + json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::>()}) + } + "text_font_import" => { + let page = self.page(&a)?; + let path = self.resolve(a.str("path")?, false)?; + let file = std::fs::File::open(&path).map_err(failed)?; + use std::io::Read; + let mut bytes = Vec::new(); + file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?; + if bytes.len() > 16 << 20 { + return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into())); + } + let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect(); + let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?; + let added = self + .doc(&a)? + .text_editor_fonts(page) + .into_iter() + .find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved")); + if let Some(f) = added { + out["resource"] = json!(f.resource); + out["font"] = json!(f.face); + } + out + } + "text_fonts" => { + let page = self.page(&a)?; + json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::>()}) + } + "text_edit_layout" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let k = a.int("paragraph")?; + let blocks = doc.text_blocks(page); + let index = self.paragraph_index(doc.id, page, k, &blocks)?; + let source = blocks.get(index).ok_or_else(|| ToolError::InvalidArgs("paragraph is out of range".into()))?; + let text = a.opt_str("text")?.unwrap_or(&source.editor_text); + let style = pdfcraft_engine::BlockStyle { + size: a.opt_num("size")?, + width: a.opt_num("width")?, + resource: a.opt_str("font_resource")?.map(str::to_owned), + ranges: parse_style_ranges(a.get("ranges"))?, + ..Default::default() + }; + let layout = doc.text_edit_layout(page, index, text, &style).map_err(failed)?; + let info = &doc.info.pages[page]; + let view_rect = |r: [f64; 4]| { + let a = info.user_to_view(r[0] as f32, r[1] as f32); + let b = info.user_to_view(r[2] as f32, r[3] as f32); + [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] + }; + let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect(); + json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect), + "draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts }) + } + "text_edit" if a.get("paragraph").is_some() => { + let page = self.page(&a)?; + let k = a.int("paragraph")?; + let (id, blocks) = { + let doc = self.doc(&a)?; + (doc.id, doc.text_blocks(page)) + }; + let index = self.paragraph_index(id, page, k, &blocks)?; + let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; + let width = a.opt_num("width")?; + // Explicit row alignments keep the editor's row boundaries; a width-only + // reflow rejoins soft wraps while retaining typed newlines and spaces. + let text = match a.opt_str("text")? { + Some(text) => text.to_owned(), + None if a.get("aligns").is_some() => block.editor_text, + None if width.is_some() => block.reflow_text.clone(), + None => block.text, + }; + let mut style = pdfcraft_engine::BlockStyle { + ranges: parse_style_ranges(a.get("ranges"))?, + resource: a.opt_str("font_resource")?.map(str::to_owned), + bold: a.opt_bool("bold")?, + size: a.opt_num("size")?, + underline: a.opt_bool("underline")?, + line_spacing: a.opt_num("line_spacing")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + width, + ..Default::default() + }; + let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); + if dx.is_some() || dy.is_some() { + style.offset = Some([dx.unwrap_or(0.0), dy.unwrap_or(0.0)]); + } + if let Some(f) = a.opt_str("font")? { + let family = match f { + "helvetica" => pdfcraft_engine::FontFamily::Helvetica, + "times" => pdfcraft_engine::FontFamily::Times, + "courier" => pdfcraft_engine::FontFamily::Courier, + other => return Err(ToolError::InvalidArgs(format!("unknown font {other:?} (helvetica, times, courier)"))), + }; + style.family = Some((family, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))); + } + if let Some(c) = a.opt_str("color")? { + style.color = Some(comments::parse_color(c)?); + } + if let Some(al) = a.opt_str("align")? { + style.align = Some(content::parse_align(al)?); + } + if let Some(aligns) = content::opt_aligns(&a)? { + style.aligns = aligns; + } + // Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy). + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; + let wanted: String = text.split_whitespace().collect(); + let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; + let doc = self.doc(&a)?; + let after = doc.text_blocks(page); + // Without `text` the paragraph keeps the document's own words: not echoed when + // the document doesn't allow copying them. + let copy = doc.allows_copying(); + // Only a paragraph that now shows exactly the requested text is the edited one. A + // neighbour that overlaps the old box (overlaid text, a text layer) must not take + // its number, and neither must a paragraph the edit merged it into: rewriting that + // one by this number would drop the neighbour's text. + let edited = block_overlapping(&after, moved) + .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); + if let Some(b) = edited.and_then(|i| after.get(i)) { + out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); + if copy { + out["paragraph"]["text"] = json!(b.text); + } + } + let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); + if !stale.is_empty() { + out["stale_paragraphs"] = json!(stale); + } + out + } + "text_edit" => { + let page = self.page(&a)?; + let n = self.doc(&a)?.text_lines(page).len(); + let line = a.int("line")?; + if line < 1 || line as usize > n { + return Err(ToolError::InvalidArgs(format!("line {line} is out of range: page {} has {n} lines", page + 1))); + } + let text = a.str("text")?.to_owned(); + let mut out = self.apply(&a, Edit::EditTextLine { page, line: line as usize - 1, text })?; + let after = self.doc(&a)?.text_lines(page); + if let Some(l) = after.get(line as usize - 1) { + out["line"] = json!({ "text": l.text, "font": l.base_font }); + } + out + } + "doc_audit_space" => { + let rows: Vec = self + .doc(&a)? + .audit_space() + .iter() + .map(|u| json!({ "category": u.category.label(), "bytes": u.bytes, "percent": (u.percent * 100.0).round() / 100.0 })) + .collect(); + json!({ "categories": rows }) + } + "doc_open_revision" => { + let id = self.doc(&a)?.id; + let n = usize::try_from(a.opt_int("revision")?.ok_or_else(|| ToolError::InvalidArgs("revision is required".into()))?).unwrap_or(0); + let new = self.session.open_revision(id, n).map_err(failed)?; + summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) + } + "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, + "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_backdrops" => self.backdrops(&a)?, + "doc_unprotect" => { + let mut out = self.apply(&a, Edit::RemoveProtection)?; + out["security"] = security(self.doc(&a)?); + out + } + "form_fields" => self.form_fields(&a)?, + "form_fill" => self.form_fill(&a)?, + "form_set_image" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? + } + "form_reset" => self.form_reset(&a)?, + "form_add_field" => self.form_add_field(&a)?, + "form_add_field_table" => self.form_add_field_table(&a)?, + "form_set_props" => self.form_set_props(&a)?, + "form_delete_field" => self.form_delete_field(&a)?, + "form_tab_order" => self.form_tab_order(&a)?, + "form_calculation_order" => self.form_calculation_order(&a)?, + "doc_export_data" => self.doc_export_data(&a)?, + "doc_import_data" => self.doc_import_data(&a)?, + "redact_mark" => self.redact_mark(&a)?, + "redact_apply" => self.redact_apply(&a)?, + "redact_clear" => self.redact_clear(&a)?, + "doc_hidden_info" => self.doc_hidden_info(&a)?, + "printers" => self.printers()?, + "printer_options" => self.printer_options(&a)?, + "link_list" => self.link_list(&a)?, + "link_open" => self.link_open(&a)?, + "link_add" => self.link_add(&a)?, + "link_edit" => self.link_edit(&a)?, + "link_delete" => self.link_delete(&a)?, + "links_from_urls" => self.links_from_urls(&a)?, + "links_remove" => self.links_remove(&a)?, + "object_list" => self.object_list(&a)?, + "object_move" => self.object_move(&a)?, + "content_list" => self.content_list(&a)?, + "page_add_text" => self.page_add_text(&a)?, + "page_add_image" => self.page_add_image(&a)?, + "content_update" => self.content_update(&a)?, + "content_delete" => self.content_delete(&a)?, + "content_copy" => self.content_copy(&a)?, + "doc_print" => self.doc_print(&a)?, + "attachment_add" => { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let name = match a.opt_str("name")? { + Some(n) => n.to_string(), + None => path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + }; + let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); + let description = a.opt_str("description")?.map(str::to_string); + // Only the document-level keys: an annotation attachment of the same name is not the new one. + let before = document_attachment_keys(self.doc(&a)?); + let out = self.apply(&a, Edit::AddAttachment { name, bytes, description, mime })?; + let key = document_attachment_keys(self.doc(&a)?).into_iter().find(|n| !before.contains(n)).unwrap_or_default(); + json!({ "name": key, "document": out }) + } + "attachment_delete" => { + let edit = Edit::DeleteAttachment { key: a.str("name")?.into() }; + self.apply(&a, edit)? + } + "doc_remove_hidden" => self.doc_remove_hidden(&a)?, + "doc_audit_redactions" => self.doc_audit_redactions(&a)?, + "fill_sign_add" => self.fill_sign_add(&a)?, + "fill_sign_date_format" => self.fill_sign_date_format(&a)?, + "measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?, + "measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?, + "measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?, + "measure_info" => self.measurement_info(&a)?, + "measure_list" => self.measurement_list(&a)?, + "measure_scale" => self.measurement_scale(&a)?, + "measure_snap" => self.measurement_snap(&a)?, + "measure_export" => self.measurement_export(&a)?, + "comment_list" => self.comment_list(&a)?, + "comment_add" => self.comment_add(&a)?, + "stamp_custom" => self.stamp_custom(&a)?, + "comment_reply" => self.comment_reply(&a)?, + "comment_set_status" => self.comment_set_status(&a)?, + "sign_list" => self.sign_list(&a)?, + "accessibility_check" => self.a11y_check(&a)?, + "accessibility_find_untagged" => self.a11y_find_untagged(&a)?, + "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, + "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, + "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, + "ocr_recognize" => self.ocr_recognize(&a)?, + "js_run" => self.js_run(&a)?, + "js_document_scripts" => self.js_document_scripts(&a)?, + "js_set_document_script" => self.js_set_document_script(&a)?, + "js_enabled" => self.js_enabled(&a)?, + "form_set_script" => self.form_set_script(&a)?, + "form_merge_data" => self.form_merge_data(&a)?, + "form_actions" => self.form_actions(&a)?, + "form_detect_fields" => self.form_detect_fields(&a)?, + "doc_compare" => self.doc_compare(&a)?, + "action_list" => self.action_list()?, + "pdfa_verify" => self.pdfa_verify(&a)?, + "print_readiness" => self.print_readiness(&a)?, + "doc_export_office" => self.doc_export_office(&a)?, + "pdfa_convert" => self.pdfa_convert(&a)?, + "standards_remove_id" => { + let standard = match a.str("standard")? { + "pdfa" => pdfcraft_engine::Standard::PdfA, + "pdfx" => pdfcraft_engine::Standard::PdfX, + other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))), + }; + self.apply(&a, Edit::RemoveStandardId { standard })? + } + "action_run" => self.action_run(&a)?, + "doc_compare_report" => self.doc_compare_report(&a)?, + "doc_compare_mark" => self.doc_compare_mark(&a)?, + "form_set_actions" => self.form_set_actions(&a)?, + "ocr_status" => self.ocr_status()?, + "ocr_recognize_files" => self.ocr_recognize_files(&a)?, + "accessibility_report" => self.a11y_report(&a)?, + "accessibility_review_record" => self.a11y_review_record(&a)?, + "accessibility_review_save" => self.a11y_review_save(&a)?, + "accessibility_review_load" => self.a11y_review_load(&a)?, + "accessibility_review_html" => self.a11y_review_html(&a)?, + "accessibility_fix" => self.a11y_fix(&a)?, + "accessibility_figures" => self.accessibility_figures(&a)?, + "accessibility_set_alt" => self.accessibility_set_alt(&a)?, + "accessibility_tags" => self.accessibility_tags(&a)?, + "accessibility_tag_content" => self.accessibility_tag_content(&a)?, + "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, + "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, + "accessibility_move_tag" => self.accessibility_move_tag(&a)?, + "sign_id_create" => self.sign_id_create(&a)?, + "sign_document" => self.sign_document(&a)?, + "sign_keychain_ids" => { + #[cfg(target_os = "macos")] + let ids: Vec = pdfcraft_engine::sign::keychain::identities(None) + .map_err(failed)? + .iter() + .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": super::signing::cert_json(&id.certificate) })) + .collect(); + #[cfg(not(target_os = "macos"))] + let ids: Vec = Vec::new(); + json!({ "count": ids.len(), "ids": ids }) + } + "sign_windows_ids" => { + #[cfg(target_os = "windows")] + let (ids, unusable): (Vec, Vec) = { + let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?; + let ids = listing + .ids + .iter() + .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": super::signing::cert_json(&id.certificate) })) + .collect(); + let unusable = listing + .unusable + .iter() + .map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key })) + .collect(); + (ids, unusable) + }; + #[cfg(not(target_os = "windows"))] + let (ids, unusable): (Vec, Vec) = (Vec::new(), Vec::new()); + json!({ "count": ids.len(), "ids": ids, "unusable": unusable }) + } + "sign_trust" => self.sign_trust(&a)?, + "comment_mark" => self.comment_mark(&a)?, + "comment_lock" => self.comment_lock(&a)?, + "comments_hide" => self.comments_hide(&a)?, + "layer_set" => self.layer_set(&a)?, + "view_reading_colors" => self.view_reading_colors(&a)?, + "comments_summarize" => self.comments_summarize(&a)?, + "comment_edit" => self.comment_edit(&a)?, + "comment_delete" => self.comment_delete(&a)?, + other => return Err(ToolError::UnknownTool(other.into())), + }; + Ok(vec![Content::Json(out)]) + } +} diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs new file mode 100644 index 0000000000..859c92da48 --- /dev/null +++ b/crates/automation/src/documents.rs @@ -0,0 +1,676 @@ +use super::error::{Result, ToolError, failed}; +use super::json::{Args, nest_rows, security, summary}; +use super::paths::{child, read_input, write_atomic}; +use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json}; + +/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). +const WATERMARK_MAX_OFFSET: f64 = 20_000.0; + +/// Bookmarks in one page of the bookmark list, by default and at most. +const BOOKMARK_PAGE: usize = 100; + +const MAX_BOOKMARK_PAGE: usize = 1000; + +impl Automation { + // ---- documents --------------------------------------------------------------------------- + + /// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`). + pub(crate) fn bookmark_listing(&self, a: &Args) -> Result { + let id = a.int("doc")?; + let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?); + let offset = a.opt_int("offset")?.unwrap_or(0); + let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; + let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64); + let limit = usize::try_from(limit) + .ok() + .filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l)) + .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?; + let page = self + .session + .bookmark_page(id, offset, limit) + .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; + let pages = &self.doc(a)?.info.pages; + Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated })) + } + + pub(crate) fn doc(&self, a: &Args) -> Result<&Document> { + let id = a.int("doc")?; + let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?; + self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)"))) + } + + /// Most pages whose paragraph listing is remembered. + const MAX_LISTINGS: usize = 256; + + /// Remember the paragraphs `text_paragraphs` just listed for `page`, so their numbers can be + /// resolved against that listing. + pub(crate) fn record_listing(&mut self, doc: DocId, page: usize, blocks: &[TextBlock]) { + if self.listed.len() >= Self::MAX_LISTINGS && !self.listed.contains_key(&(doc, page)) { + self.evicted.extend(self.listed.keys().map(|(d, _)| *d)); + self.listed.clear(); + } + self.listed.insert((doc, page), blocks.iter().map(BlockKey::of).collect()); + } + + /// The index in `blocks` (the page's paragraphs now) of paragraph number `k` (1-based). + /// + /// After `text_paragraphs` a number names the paragraph that listing showed, wherever it is + /// now. Without a listing it is a position in `blocks`. A paragraph an edit merged into a + /// neighbour (or changed) is refused: the number would otherwise rewrite another paragraph + /// and lose its text (#2516). So is any number on a page of a document whose listings were + /// dropped at the cap, until the page is listed again. + pub(crate) fn paragraph_index(&self, doc: DocId, page: usize, k: i64, blocks: &[TextBlock]) -> Result { + let keys = self.listed.get(&(doc, page)); + if keys.is_none() && self.evicted.contains(&doc) { + return Err(ToolError::InvalidArgs(format!( + "page {} has no paragraph listing any more: too many pages were listed and the oldest listings were dropped. List the page again with text_paragraphs", + page + 1 + ))); + } + let n = keys.map_or(blocks.len(), Vec::len); + let i = usize::try_from(k) + .ok() + .and_then(|k| k.checked_sub(1)) + .filter(|i| *i < n) + .ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)))?; + let Some(keys) = keys else { return Ok(i) }; + keys.get(i).and_then(|key| key.find(blocks, i)).ok_or_else(|| { + ToolError::InvalidArgs(format!( + "paragraph {k} of page {} is no longer a paragraph of its own: an edit changed it or how the page is grouped. List the page again with text_paragraphs", + page + 1 + )) + }) + } + + /// Update the listing after paragraph `k` was edited into `edited`, one of the page's + /// paragraphs `after`. Returns the numbers of the listed paragraphs that are gone. + pub(crate) fn follow_edit(&mut self, doc: DocId, page: usize, k: i64, edited: Option<&TextBlock>, after: &[TextBlock]) -> Vec { + let Some(keys) = self.listed.get_mut(&(doc, page)) else { return Vec::new() }; + if let (Some(edited), Some(key)) = (edited, usize::try_from(k).ok().and_then(|k| k.checked_sub(1)).and_then(|i| keys.get_mut(i))) { + *key = BlockKey::of(edited); + } + keys.iter().enumerate().filter(|(i, key)| key.find(after, *i).is_none()).map(|(i, _)| i + 1).collect() + } + + /// The document, if its security allows copying text and images out of it (Table 22, bit 5). + pub(crate) fn doc_to_copy(&self, a: &Args) -> Result<&Document> { + let doc = self.doc(a)?; + doc.check_copying().map_err(failed)?; + Ok(doc) + } + + pub(crate) fn doc_open(&mut self, a: &Args) -> Result { + let path = self.resolve(a.str("path")?, false)?; + let bytes = read_input(&path)?; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; + let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; + Ok(summary(doc)) + } + + pub(crate) fn doc_close(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let discard_changes = a.opt_bool("discard_changes")?.unwrap_or(false); + if doc.dirty && !discard_changes { + return Err(failed("the document has unsaved changes: save it with doc_save, or pass discard_changes: true")); + } + self.session.close(id); + self.renderers.remove(&id); + self.texts.remove(&id); + self.listed.retain(|(d, _), _| *d != id); + self.evicted.remove(&id); + Ok(json!({ "closed": id.0 })) + } + + pub(crate) fn doc_save(&mut self, a: &Args) -> Result { + self.doc_save_with(a, write_atomic) + } + + pub(crate) fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let target = match a.opt_str("path")? { + Some(p) => self.resolve(p, true)?, + None => { + // Saving in place writes where the file was opened from, which the read root + // allows and the write root may not. + let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; + let own_path = PathBuf::from(&own); + self.check_roots(&own, &own_path, file_access::Access::Write)?; + own_path + } + }; + let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); + // Saving to a new file is a full rewrite unless asked otherwise, like Save As. + let full = a.opt_bool("full")?.unwrap_or(!same_file); + let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); + if flatten_fill_sign { + self.apply(a, Edit::FlattenFillSign)?; + } + let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; + // A save that adds or removes encryption, or must not keep the old revision (Sanitize, + // redaction), is rewritten in full even when an incremental save was asked for. + let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); + self.publish_save(id, &target, bytes, incremental, write) + } + + /// Validate the prepared bytes before publishing them or marking the document saved. + pub(crate) fn publish_save( + &mut self, + id: DocId, + target: &Path, + bytes: Arc>, + incremental: bool, + write: impl FnOnce(&Path, &[u8]) -> Result<()>, + ) -> Result { + self.session.check_save(id, &bytes).map_err(failed)?; + write(target, &bytes)?; + let path = target.to_string_lossy().into_owned(); + self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; + let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?; + Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) })) + } + + pub(crate) fn doc_revisions(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let ends = doc.revision_ends(); + let sigs = doc.signatures.clone(); + let list: Vec = ends + .iter() + .enumerate() + .map(|(i, end)| { + let start = if i == 0 { 0 } else { ends[i - 1] }; + // A signature signs the revision its byte range ends in. + let signed: Vec<&str> = + sigs.iter().filter(|s| s.signed && s.signed_len > start && s.signed_len <= *end).map(|s| s.field.as_str()).collect(); + json!({ "revision": i + 1, "end": end, "bytes": end - start, "signed_by": signed }) + }) + .collect(); + Ok(json!({ "revisions": list, "unsaved_changes": doc.dirty })) + } + + pub(crate) fn doc_initial_view(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; + let bad = |m: String| ToolError::InvalidArgs(m); + let mut v = self.doc(a)?.initial_view(); + let before = v.clone(); + if let Some(n) = a.opt_str("navigation")? { + v.navigation = match n { + "page" => N::PageOnly, + "bookmarks" => N::Bookmarks, + "pages" => N::Pages, + "attachments" => N::Attachments, + "layers" => N::Layers, + o => return Err(bad(format!("unknown navigation {o:?}"))), + }; + } + if let Some(l) = a.opt_str("layout")? { + v.layout = match l { + "default" => L::Default, + "single" => L::SinglePage, + "continuous" => L::SinglePageContinuous, + "two_up" => L::TwoUp, + "two_up_continuous" => L::TwoUpContinuous, + "two_up_cover" => L::TwoUpCoverPage, + "two_up_continuous_cover" => L::TwoUpContinuousCoverPage, + o => return Err(bad(format!("unknown layout {o:?}"))), + }; + } + match a.get("magnification") { + None => {} + Some(Value::Number(p)) => v.magnification = M::Percent(p.as_f64().unwrap_or(100.0).clamp(1.0, 6400.0)), + Some(Value::String(m)) => { + v.magnification = match m.as_str() { + "default" => M::Default, + "actual" => M::ActualSize, + "fit_page" => M::FitPage, + "fit_width" => M::FitWidth, + "fit_height" => M::FitHeight, + "fit_visible" => M::FitVisible, + o => return Err(bad(format!("unknown magnification {o:?}"))), + } + } + Some(_) => return Err(bad("magnification is a name or a percentage".into())), + } + if let Some(p) = a.opt_int("page")? { + v.page = (p.max(1) - 1) as usize; + } + for (k, f) in [ + ("fit_window", &mut v.fit_window), + ("center_window", &mut v.center_window), + ("full_screen", &mut v.full_screen), + ("display_title", &mut v.display_title), + ("hide_menubar", &mut v.hide_menubar), + ("hide_toolbar", &mut v.hide_toolbar), + ("hide_window_ui", &mut v.hide_window_ui), + ] { + if let Some(b) = a.opt_bool(k)? { + *f = b; + } + } + if let Some(l) = a.opt_str("language")? { + v.language = (!l.trim().is_empty()).then(|| l.trim().to_string()); + } + if let Some(b) = a.opt_str("binding")? { + v.right_to_left = match b { + "left" => false, + "right" => true, + o => return Err(bad(format!("binding is left or right, not {o:?}"))), + }; + } + let mut out = if v != before { self.apply(a, Edit::SetInitialView(Box::new(v.clone())))? } else { json!({}) }; + out["initial_view"] = json!({ + "navigation": format!("{:?}", v.navigation), + "layout": format!("{:?}", v.layout), + "magnification": format!("{:?}", v.magnification), + "page": v.page + 1, + "fit_window": v.fit_window, + "center_window": v.center_window, + "full_screen": v.full_screen, + "display_title": v.display_title, + "hide_menubar": v.hide_menubar, + "hide_toolbar": v.hide_toolbar, + "hide_window_ui": v.hide_window_ui, + "language": v.language, + "binding": if v.right_to_left { "right" } else { "left" }, + }); + Ok(out) + } + + pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result { + use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, MonoCompression, Preset}; + const MONO_KEYS: [&str; 4] = ["downsample", "ppi", "above_ppi", "compression"]; + let id = self.doc(a)?.id; + let before = self.doc(a)?.bytes.len(); + let path = self.resolve(a.str("path")?, true)?; + let mut settings = match a.opt_str("preset")? { + None => Preset::Standard.settings(), + Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), + }; + if let Some(c) = a.opt_str("compatibility")? { + settings.compatibility = Compatibility::from_name(c) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown compatibility {c:?} (retain, 1.4, 1.5, 1.6, 1.7, 2.0)")))?; + } + let images = |key: &str, base: ImageSettings| -> Result { + let Some(v) = a.get(key) else { return Ok(base) }; + let mut s = base; + if let Some(d) = v.get("downsample").and_then(Value::as_bool) { + s.downsample = d; + } + if let Some(p) = v.get("ppi").and_then(Value::as_f64) { + s.target_ppi = p.clamp(9.0, 2400.0); + } + if let Some(p) = v.get("above_ppi").and_then(Value::as_f64) { + s.above_ppi = p.clamp(9.0, 2400.0); + } + s.above_ppi = s.above_ppi.max(s.target_ppi); + if let Some(c) = v.get("compression").and_then(Value::as_str) { + let q = v.get("quality").and_then(Value::as_u64).unwrap_or(60).clamp(1, 100) as u8; + s.compression = match c { + "jpeg" => Compression::Jpeg(q), + "zip" | "flate" => Compression::Flate, + "retain" => Compression::Retain, + other => return Err(ToolError::InvalidArgs(format!("unknown compression {other:?} (jpeg, zip, retain)"))), + }; + } + Ok(s) + }; + settings.color = images("color", settings.color)?; + settings.gray = images("gray", settings.gray)?; + if let Some(mono) = a.get("mono") { + let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, jbig2, retain)")); + let mono = mono.as_object().ok_or_else(|| { + ToolError::InvalidArgs("mono must be an object: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain }".into()) + })?; + // A misspelled key would otherwise leave the preset's setting in place unnoticed. + if let Some(key) = mono.keys().find(|k| !MONO_KEYS.contains(&k.as_str())) { + return Err(ToolError::InvalidArgs(format!("unknown mono key {key:?} ({})", MONO_KEYS.join(", ")))); + } + let m = &mut settings.mono; + if let Some(d) = mono.get("downsample") { + m.downsample = d.as_bool().ok_or_else(|| ToolError::InvalidArgs(format!("mono downsample must be true or false, not {d}")))?; + } + for (key, ppi) in [("ppi", &mut m.target_ppi), ("above_ppi", &mut m.above_ppi)] { + if let Some(p) = mono.get(key) { + *ppi = p + .as_f64() + .filter(|p| p.is_finite()) + .ok_or_else(|| ToolError::InvalidArgs(format!("mono {key} must be a number, not {p}")))? + .clamp(9.0, 2400.0); + } + } + m.above_ppi = m.above_ppi.max(m.target_ppi); + if let Some(c) = mono.get("compression") { + m.compression = match c.as_str() { + Some("ccitt_g4" | "ccitt" | "g4") => MonoCompression::CcittG4, + Some("jbig2") => MonoCompression::Jbig2, + Some("retain") => MonoCompression::Retain, + _ => return Err(unknown(c)), + }; + } + } + for (key, flag) in [ + ("merge_fragments", &mut settings.merge_fragments), + ("discard_thumbnails", &mut settings.discard_thumbnails), + ("discard_alternate_images", &mut settings.discard_alternate_images), + ("discard_tags", &mut settings.discard_tags), + ("discard_print_settings", &mut settings.discard_print_settings), + ("flate_unencoded", &mut settings.flate_unencoded), + ("object_streams", &mut settings.object_streams), + ("lzw_to_flate", &mut settings.lzw_to_flate), + ("remove_invalid_links", &mut settings.remove_invalid_links), + ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), + ] { + if let Some(b) = a.opt_bool(key)? { + *flag = b; + } + } + let discard: Vec = match a.get("discard") { + None => Vec::new(), + Some(v) => v + .as_array() + .ok_or_else(|| ToolError::InvalidArgs("discard must be an array".into()))? + .iter() + .map(|x| { + x.as_str() + .and_then(pdfcraft_engine::Hidden::from_id) + .ok_or_else(|| ToolError::InvalidArgs(format!("unknown discard category {x}"))) + }) + .collect::>()?, + }; + let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; + write_atomic(&path, &bytes)?; + fn valid_version(v: &[u8]) -> Option<&str> { + if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None } + } + let catalog_version = (settings.compatibility == Compatibility::Retain) + .then(|| { + let pw = self.doc(a).ok().and_then(|d| d.password.as_deref()); + let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?; + let root = doc.root()?; + // Through a reference too, as the optimizer reads it. + let version = doc.get(root).as_dict()?.get(b"Version")?.clone(); + doc.resolve(&version).as_name().and_then(valid_version).map(str::to_owned) + }) + .flatten(); + let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); + // Viewers read the later of the two (object streams raise the header past a lower catalog + // entry). Both are "d.d", so text order is version order. + let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max(); + let o = &r.optimize; + Ok(json!({ + "path": path.to_string_lossy(), + "pdf_version": pdf_version, + "object_streams": settings.effective_object_streams(), + "bytes_before": before, + "bytes_after": bytes.len(), + "merged_images": o.merged_images, + "fragments_merged": o.fragments_merged, + "images": o.images, + "images_resampled": o.images_resampled, + "images_recompressed": o.images_recompressed, + "image_bytes_before": o.image_bytes_before, + "image_bytes_after": o.image_bytes_after, + "thumbnails": o.thumbnails, + "alternate_images": o.alternate_images, + "tags_removed": o.tags_removed, + "streams_compressed": o.streams_compressed, + "lzw_streams": o.lzw_streams, + "invalid_links": o.invalid_links, + "invalid_bookmarks": o.invalid_bookmarks, + "unreferenced_dests": o.unreferenced_dests, + "unused_xobjects": o.unused_xobjects, + "merged_objects": r.merged, + "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), + })) + } + + /// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based). + fn mark_file(&self, a: &Args) -> Result> { + let Some(p) = a.opt_str("file")? else { return Ok(None) }; + let path = self.resolve(p, false)?; + let bytes = read_input(&path)?; + let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; + Ok(Some(pdfcraft_engine::MarkFile { + name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + bytes: Arc::new(bytes), + page, + })) + } + + pub(crate) fn apply(&mut self, a: &Args, edit: Edit) -> Result { + let id = self.doc(a)?.id; + self.session.apply(id, edit).map_err(failed)?; + let doc = self.doc(a)?; + let mut out = summary(doc); + // Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent. + if !doc.edit_notices.is_empty() { + out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::>()); + } + Ok(out) + } + + /// Pages whose own backdrop hides a background (see `pdfcraft_edit::backdrop`). + pub(crate) fn backdrops(&mut self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let doc = self.doc(a)?; + let found: Vec = pages + .iter() + .zip(doc.page_backdrops(&pages)) + .filter_map(|(p, found)| { + let kind = match found? { + pdfcraft_engine::Backdrop::Fill => "fill", + pdfcraft_engine::Backdrop::Image => "image", + }; + Some(json!({ "page": p + 1, "backdrop": kind })) + }) + .collect(); + Ok(json!({ "pages": found })) + } + + pub(crate) fn marks(&mut self, tool: &str, a: &Args) -> Result { + use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let color = + |key: &str, default: [f64; 3]| -> Result<[f64; 3]> { Ok(a.opt_str(key)?.map(comments::parse_color).transpose()?.unwrap_or(default)) }; + let replace = a.opt_bool("replace")?.unwrap_or(false); + let edit = match tool { + "doc_header_footer" => { + let mut hf = HeaderFooter::default(); + for (k, key) in ["header_left", "header_center", "header_right", "footer_left", "footer_center", "footer_right"].iter().enumerate() { + hf.text[k] = a.opt_str(key)?.unwrap_or_default().to_string(); + } + if let Some(s) = a.opt_num("font_size")? { + hf.font_size = s; + } + hf.font = match a.opt_str("font")? { + None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica, + Some("Times") => pdfcraft_engine::FontFamily::Times, + Some("Courier") => pdfcraft_engine::FontFamily::Courier, + Some(other) => { + return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}"))); + } + }; + hf.color = color("color", hf.color)?; + if let Some(m) = a.get("margins").and_then(Value::as_array) { + let m: Vec = m.iter().filter_map(Value::as_f64).collect(); + hf.margins = <[f64; 4]>::try_from(m).map_err(|_| ToolError::InvalidArgs("margins must be 4 numbers".into()))?; + } + if let Some(s) = a.opt_int("start_number")? { + hf.start_number = s.clamp(1, u32::MAX as i64) as u32; + } + hf.shrink = a.opt_bool("shrink")?.unwrap_or(false); + Edit::AddHeaderFooter { pages, settings: hf, replace } + } + "doc_watermark" => { + let d = Watermark::default(); + let file = self.mark_file(a)?; + let text = match &file { + Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(), + None => a.str("text")?.to_string(), + }; + let wm = Watermark { + text, + source: None, + scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0), + font_size: a.opt_num("font_size")?.unwrap_or(0.0), + color: color("color", d.color)?, + opacity: a.opt_num("opacity")?.unwrap_or(d.opacity), + rotation: a.opt_num("rotation")?.unwrap_or(d.rotation), + behind: a.opt_bool("behind")?.unwrap_or(false), + offset: [ + a.opt_num("offset_x")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), + a.opt_num("offset_y")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), + ], + }; + Edit::AddWatermark { pages, settings: wm, replace, file } + } + "doc_background" => Edit::AddBackground { + pages, + settings: Background { + color: color("color", [1.0; 3])?, + opacity: a.opt_num("opacity")?.unwrap_or(1.0), + scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), + replace_backdrop: a.opt_bool("replace_backdrop")?.unwrap_or(true), + ..Background::default() + }, + replace, + file: self.mark_file(a)?, + }, + _ => { + let kind = match a.str("kind")? { + "header_footer" => MarkKind::HeaderFooter, + "watermark" => MarkKind::Watermark, + "background" => MarkKind::Background, + other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), + }; + Edit::RemoveMarks { kind } + } + }; + self.apply(a, edit) + } + + pub(crate) fn export(&mut self, tool: &str, a: &Args) -> Result { + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); + let mut ex = pdfcraft_engine::export::Exporter::new(doc); + if tool == "doc_export_text" { + let path = self.resolve(a.str("path")?, true)?; + let text = ex.text_of(&pages).map_err(failed)?; + write_atomic(&path, text.as_bytes())?; + return Ok(json!({ "path": path.to_string_lossy(), "pages": pages.len(), "bytes": text.len() })); + } + let folder = self.resolve(a.str("folder")?, true)?; + std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?; + if tool == "doc_export_all_images" { + let min = u32::try_from(a.opt_int("min_size")?.unwrap_or(0).max(0)).unwrap_or(u32::MAX); + let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; + let mut files = Vec::new(); + for (k, img) in out.images.iter().enumerate() { + let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len())); + write_atomic(&path, &img.data)?; + files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); + } + let skipped: Vec = out.skipped.iter().map(|(p, r, why)| json!({ "page": p + 1, "object": r.num, "reason": why })).collect(); + return Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })); + } + let dpi = a.opt_num("dpi")?.unwrap_or(150.0); + let quality = a.opt_int("quality")?.unwrap_or(85).clamp(1, 100) as u8; + let format = match a.opt_str("format")?.unwrap_or("png") { + "png" => pdfcraft_engine::export::ImageFormat::Png, + "jpeg" | "jpg" => pdfcraft_engine::export::ImageFormat::Jpeg { quality }, + "tiff" | "tif" => pdfcraft_engine::export::ImageFormat::Tiff, + f => return Err(ToolError::InvalidArgs(format!("unknown format {f:?} (png, jpeg, tiff)"))), + }; + let mut files = Vec::new(); + let mut lowered = Vec::new(); + // A write error stops the export as it is, not as a page error. + let mut write_error = None; + let exported = ex.images(&pages, dpi, format, |p, img| { + let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); + write_atomic(&path, &img).map_err(|e| { + let message = e.to_string(); + write_error = Some(e); + message + })?; + files.push(path.to_string_lossy().into_owned()); + Ok(()) + }); + if let Some(e) = write_error { + return Err(e); + } + exported.map_err(failed)?; + for &p in &pages { + // A page too large for the renderer at `dpi` is drawn at the most it allows. + let used = ex.dpi_used(p, dpi); + if used < dpi.clamp(18.0, 1200.0) - 0.5 { + lowered.push(json!({ "page": p + 1, "dpi": used.floor() })); + } + } + if lowered.is_empty() { + Ok(json!({ "count": files.len(), "files": files })) + } else { + Ok(json!({ "count": files.len(), "files": files, "lower_dpi": lowered })) + } + } + + pub(crate) fn doc_protect(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{Algorithm, Changes, Printing, Protection}; + let d = Protection::default(); + // Restrictions only exist behind a permissions password (ISO 32000-2 §7.6.4.4: /P is + // enforced against the owner password; without one everything stays allowed). Refuse a + // restriction that could not take effect instead of writing an unrestricted file (#134). + let restriction = ["printing", "changes", "copy", "accessibility"].into_iter().find(|k| a.get(k).is_some()); + if let (None, Some(key)) = (a.opt_str("permissions_password")?, restriction) { + return Err(ToolError::InvalidArgs(format!( + "`{key}` needs `permissions_password`: with open_password alone the document is encrypted but nothing is restricted" + ))); + } + let p = Protection { + open_password: a.opt_str("open_password")?.map(str::to_owned), + permissions_password: a.opt_str("permissions_password")?.map(str::to_owned), + printing: match a.opt_str("printing")? { + None => d.printing, + Some("none") => Printing::None, + Some("low") => Printing::Low, + Some("high") => Printing::High, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))), + }, + changes: match a.opt_str("changes")? { + None => d.changes, + Some("none") => Changes::None, + Some("pages") => Changes::Pages, + Some("fill-sign") => Changes::FillSign, + Some("comment-fill-sign") => Changes::CommentFillSign, + Some("any-except-extract") => Changes::AnyExceptExtract, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))), + }, + copy: a.opt_bool("copy")?.unwrap_or(d.copy), + accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility), + algorithm: match a.opt_str("compatibility")? { + None | Some("aes-256") => Algorithm::Aes256, + Some("aes-128") => Algorithm::Aes128, + Some("rc4-128") => Algorithm::Rc4_128, + Some("rc4-40") => Algorithm::Rc4_40, + Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))), + }, + encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata), + }; + let mut out = self.apply(a, Edit::Protect(p))?; + out["security"] = security(self.doc(a)?); + Ok(out) + } +} diff --git a/crates/automation/src/error.rs b/crates/automation/src/error.rs new file mode 100644 index 0000000000..18e58939ed --- /dev/null +++ b/crates/automation/src/error.rs @@ -0,0 +1,33 @@ +/// Why a tool call failed. +#[derive(Clone, Debug, PartialEq)] +pub enum ToolError { + /// No tool has this name. + UnknownTool(String), + /// The arguments don't match the tool's schema. + InvalidArgs(String), + /// The tool ran and failed (the message is for the agent to read). + Failed(String), +} + +impl std::fmt::Display for ToolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ToolError::UnknownTool(t) => write!(f, "unknown tool {t:?}"), + ToolError::InvalidArgs(m) | ToolError::Failed(m) => f.write_str(m), + } + } +} + +impl std::error::Error for ToolError {} + +pub(crate) type Result = std::result::Result; + +pub(crate) fn failed(e: impl std::fmt::Display) -> ToolError { + ToolError::Failed(e.to_string()) +} + +impl From for ToolError { + fn from(e: pdfcraft_engine::EditError) -> Self { + failed(e) + } +} diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index 55b10662f0..067a593888 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -9,6 +9,9 @@ //! links are followed: for a file that doesn't exist yet, the links of its nearest existing //! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so //! is a link that leads nowhere). +//! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`) +//! than the root's is refused by name, before anything is resolved: resolving it would contact +//! the host. //! - The result must lie inside the root, compared by whole components (`/work2` isn't inside //! `/work`), without regard to case on Windows. //! - Read and write access are separate: a root given alone grants only its own kind of access. @@ -97,7 +100,10 @@ impl AutomationRoots { Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")), }; }; - if inside(&resolve(shown, target)?, folder) { + let absolute = absolute(shown, target)?; + // Another network share or a device is refused by name, as `--root` refuses it: even + // resolving the path would contact the host (#4003). + if !foreign_share(&absolute, folder) && inside(&resolve(shown, &absolute)?, folder) { return Ok(()); } let root = root_shown.display(); @@ -123,9 +129,8 @@ fn plain(path: PathBuf) -> PathBuf { } } -/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest -/// existing folder's links followed and the rest appended. -fn resolve(shown: &str, path: &Path) -> Result { +/// `path` made absolute against the working directory, by name: nothing on disk is read. +fn absolute(shown: &str, path: &Path) -> Result { if path.as_os_str().is_empty() { return Err("automation path rejected: path is empty".into()); } @@ -133,10 +138,15 @@ fn resolve(shown: &str, path: &Path) -> Result { if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { return Err(format!("automation path rejected: reserved device names are not allowed: {shown}")); } - let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?; + std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}")) +} + +/// `absolute` with its links followed; for a path that doesn't exist (yet), its nearest existing +/// folder's links followed and the rest appended. +fn resolve(shown: &str, absolute: &Path) -> Result { // The names below the nearest existing folder, last first. let mut missing: Vec = vec![]; - let mut at = absolute.as_path(); + let mut at = absolute; loop { match std::fs::canonicalize(at) { Ok(mut found) => { @@ -189,6 +199,24 @@ fn same_component(a: Component, b: Component) -> bool { } } +/// Whether `path` names another network share or device namespace than `root` (Windows +/// `\\host\share`, `\\?\UNC\…`, `\\.\…`, `\\?\…`). Those are refused by name: even checking +/// that one exists would contact the host. Drive letters are left to the normal check. +pub(crate) fn foreign_share(path: &Path, root: &Path) -> bool { + use std::path::Prefix; + fn share(p: &Path) -> Option { + let Some(Component::Prefix(prefix)) = p.components().next() else { return None }; + let (kind, a, b) = match prefix.kind() { + Prefix::Disk(_) | Prefix::VerbatimDisk(_) => return None, + Prefix::UNC(host, share) | Prefix::VerbatimUNC(host, share) => ("unc", host, share), + Prefix::DeviceNS(name) => ("device", name, std::ffi::OsStr::new("")), + Prefix::Verbatim(name) => ("verbatim", name, std::ffi::OsStr::new("")), + }; + Some(format!("{kind}\\{}\\{}", a.to_string_lossy(), b.to_string_lossy()).to_lowercase()) + } + share(path).is_some_and(|s| share(root).as_ref() != Some(&s)) +} + /// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. fn is_device_name(component: &str) -> bool { let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); @@ -233,6 +261,27 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + /// #4003: another share or a device namespace is refused by name, with the same words as any + /// other path outside the root, before anything is resolved (which would contact the host). + #[cfg(windows)] + #[test] + fn other_shares_and_devices_are_refused_before_they_are_resolved() { + let (base, roots) = folders("shares"); + for p in [ + r"\\.\pipe\pdfcraft-4003.pdf", + r"\\?\GLOBALROOT\Device\Null", + r"\\pdfcraft-no-such-host.invalid\share\a.pdf", + "//pdfcraft-no-such-host.invalid/share/a.pdf", + r"\\?\UNC\pdfcraft-no-such-host.invalid\share\a.pdf", + ] { + let e = roots.check(p, Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{p}: {e}"); + let e = roots.check(p, Access::Write).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the write root"), "{p}: {e}"); + } + let _ = std::fs::remove_dir_all(base); + } + #[test] fn a_sibling_with_the_same_prefix_is_outside() { let (base, roots) = folders("prefix"); @@ -345,27 +394,48 @@ mod tests { // Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its // inputs and out_dir, walks only inside them (skipping links) and resolves every // discovered file again before `process` reads it or `publish` writes it. - const RESOLVED_BY_CALLER: &[&str] = &["write_atomic", "write_atomic_with", "read_review_sidecar", "process", "linked", "walk", "publish"]; - let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + const RESOLVED_BY_CALLER: &[&str] = + &["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"]; + let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; + let mut files = vec![]; + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + if path.file_name().is_some_and(|n| !n.to_string_lossy().starts_with("tests")) { + dirs.push(path); + } + } else { + files.push(path); + } + } + } let mut offenders = vec![]; - for entry in std::fs::read_dir(&dir).unwrap() { - let path = entry.unwrap().path(); + for path in files { if path.extension().is_none_or(|e| e != "rs") || path.file_name().is_some_and(|n| n == "file_access.rs" || n.to_string_lossy().starts_with("tests")) { continue; } let text = std::fs::read_to_string(&path).unwrap(); - // Test modules (which build their own fixtures) come last in each file. - let code = text.split("\n#[cfg(test)]").next().unwrap_or(""); + // Inline test modules (which build their own fixtures) come last in each file. A + // `#[cfg(test)]` on a `use` line or a `mod tests;` declaration doesn't start one. + let code = text + .match_indices("\n#[cfg(test)]\nmod ") + .find(|(at, m)| text[at + m.len()..].lines().next().is_some_and(|l| l.trim_end().ends_with('{'))) + .map_or(text.as_str(), |(at, _)| &text[..at]); let lines: Vec<&str> = code.lines().collect(); for (n, line) in lines.iter().enumerate() { let trimmed = line.trim_start(); + // A `use` line only names a helper; the call is checked where it is made. if trimmed.starts_with("//") + || trimmed.starts_with("use ") + || (trimmed.starts_with("pub") && trimmed.contains(" use ")) || !(trimmed.contains("std::fs") || trimmed.contains("File::open") || trimmed.contains("File::create") - || trimmed.contains("OpenOptions")) + || trimmed.contains("OpenOptions") + || trimmed.contains("read_input")) { continue; } diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index f2eab5ddc0..adf0bd0884 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -1,8 +1,8 @@ //! Form tools: list fields, fill them (several at once, one undo step), clear the form, and -//! prepare a form (add, change and delete fields). +//! prepare a form (add fields one at a time or as a table, change and delete them). -use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate}; -use pdfcraft_engine::{Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; +use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, NOTATION_NOT_STORABLE, Validate, notation_storable}; +use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; use serde_json::{Value, json}; use crate::{Args, Automation, Result, ToolError, failed}; @@ -94,13 +94,20 @@ fn look_arg(v: &Value) -> Result { }) } -/// `{"min": 0, "max": 100}` or `"none"`. +/// `{"min": 0, "max": 100}` or `"none"`. A null bound is no bound, as `form_fields` shows it. fn validate_arg(v: &Value) -> Result { if v.as_str() == Some("none") { return Ok(Validate::None); } let o = v.as_object().ok_or_else(|| bad("validate must be {\"min\": …, \"max\": …} or \"none\""))?; - let (min, max) = (o.get("min").and_then(Value::as_f64), o.get("max").and_then(Value::as_f64)); + if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "min" | "max")) { + return Err(bad(format!("unknown key {k:?} in validate (min, max)"))); + } + let bound = |k: &str| match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(x) => x.as_f64().map(Some).ok_or_else(|| bad(format!("validate {k} must be a number"))), + }; + let (min, max) = (bound("min")?, bound("max")?); if min.is_none() && max.is_none() { return Err(bad("validate needs min, max or both")); } @@ -113,25 +120,67 @@ fn calculate_arg(v: &Value) -> Result { return Ok(Calculate::None); } let o = v.as_object().ok_or_else(|| bad("calculate must be {\"op\": …, \"fields\": […]}, {\"notation\": …} or \"none\""))?; - if let Some(n) = o.get("notation").and_then(Value::as_str) { + // Before anything else is read, so a script given beside a notation isn't dropped unnoticed. + if o.contains_key("script") { + return Err(bad("a custom calculate script is set with form_set_script (event: calculate)")); + } + if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "op" | "fields" | "notation")) { + return Err(bad(format!("unknown key {k:?} in calculate (op and fields, or notation)"))); + } + // A null is no value, as for the tool's own arguments. + let get = |k: &str| o.get(k).filter(|v| !v.is_null()); + if let Some(n) = get("notation") { + if get("op").is_some() || get("fields").is_some() { + return Err(bad("calculate takes a notation, or op and fields, not both")); + } + let n = n.as_str().ok_or_else(|| bad("notation must be a string such as \"Price * Qty\""))?; + // Refused rather than stored as a different formula. + if !notation_storable(n) { + return Err(bad(NOTATION_NOT_STORABLE)); + } return Ok(Calculate::Notation(n.to_string())); } - let op = match o.get("op").and_then(Value::as_str).unwrap_or("") { - "sum" => CalcOp::Sum, - "product" => CalcOp::Product, - "average" => CalcOp::Average, - "min" | "minimum" => CalcOp::Minimum, - "max" | "maximum" => CalcOp::Maximum, - x => return Err(bad(format!("unknown op {x:?} (sum, product, average, min, max)"))), + let op = get("op").ok_or_else(|| bad("calculate needs op and fields, or a notation"))?; + let op = match op.as_str().ok_or_else(|| bad("op must be one of sum, product, average, min, max"))? { + "minimum" => CalcOp::Minimum, + "maximum" => CalcOp::Maximum, + x => { + CalcOp::ALL.into_iter().find(|&op| op_name(op) == x).ok_or_else(|| bad(format!("unknown op {x:?} (sum, product, average, min, max)")))? + } }; - let fields: Vec = - o.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default(); + let fields = get("fields").and_then(Value::as_array).ok_or_else(|| bad("calculate fields must be a list of field names"))?; + let fields: Vec = fields + .iter() + .map(|x| x.as_str().map(str::to_owned).ok_or_else(|| bad("calculate fields must be field names (strings)"))) + .collect::>()?; if fields.is_empty() { return Err(bad("calculate needs fields")); } Ok(Calculate::Simple { op, fields }) } +/// An operation's name in `calculate`, as `form_set_props` takes it and the listings show it. +fn op_name(op: CalcOp) -> &'static str { + match op { + CalcOp::Sum => "sum", + CalcOp::Product => "product", + CalcOp::Average => "average", + CalcOp::Minimum => "min", + CalcOp::Maximum => "max", + } +} + +/// A field's calculation as `form_fields` and `form_calculation_order` show it, in +/// `form_set_props`'s shape, or `{"script": …}` for a custom Calculate script; `None` without one. +fn calculate_json(f: &FormField) -> Option { + match (&f.actions.calculate, &f.actions.scripts.calculate) { + (Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })), + (Calculate::Notation(n), _) => Some(json!({ "notation": n })), + (Calculate::None, Some(js)) => Some(json!({ "script": js })), + (Calculate::None, None) => None, + } +} + fn format_json(f: &Format) -> Value { match f { Format::None => Value::Null, @@ -220,18 +269,15 @@ impl Automation { if let Validate::Range { min, max } = &f.actions.validate { o.insert("validate".into(), json!({ "min": min, "max": max })); } - match &f.actions.calculate { - Calculate::Simple { op, fields } => { - o.insert("calculate".into(), json!({ "op": op.code().to_lowercase(), "fields": fields })); - } - Calculate::Notation(n) => { - o.insert("calculate".into(), json!({ "notation": n })); - } - Calculate::None => {} + if let Some(c) = calculate_json(f) { + o.insert("calculate".into(), c); } if !f.actions.unsupported.is_empty() { o.insert("unsupported_scripts".into(), json!(f.actions.unsupported)); } + if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) { + o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::>())); + } match f.kind { FormFieldKind::PushButton => { o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default())); @@ -288,6 +334,23 @@ impl Automation { let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Fill in form".into(), edits } }; let mut out = self.apply(a, edit)?; out["filled"] = json!(values.len()); + // A value the box cuts off at a fixed font size is saved whole but shows only in part: + // said, not silent (#4018). + let doc = self.doc(a)?; + let cut: Vec = values + .keys() + .filter_map(|name| doc.field_overflow(name).map(|(page, o)| (name, page, o))) + .map(|(name, page, o)| { + let page = page.map(|p| format!(" (page {})", p.saturating_add(1))).unwrap_or_default(); + json!(format!("form field {name:?}{page}: {o}, so the box shows only part of it (the whole value is saved)")) + }) + .collect(); + if !cut.is_empty() { + match out.get_mut("warnings").and_then(Value::as_array_mut) { + Some(w) => w.extend(cut), + None => out["warnings"] = Value::Array(cut), + } + } Ok(out) } @@ -339,9 +402,70 @@ impl Automation { Ok(out) } + /// A table of text fields: `rect` (points from the top-left of the displayed page) divided + /// into rows and columns, one field per cell, as one undo step. + pub(crate) fn form_add_field_table(&mut self, a: &Args) -> Result { + use pdfcraft_engine::field_table::{self, FieldTable, MAX_COLUMNS, TableColumn, TableError}; + let page = self.page(a)?; + let rect = a + .get("rect") + .and_then(Value::as_array) + .and_then(|r| r.iter().map(Value::as_f64).collect::>>()) + .and_then(|r| <[f64; 4]>::try_from(r).ok()) + .ok_or_else(|| bad("rect must be 4 numbers"))?; + let names = a.get("names").map(|_| a.strs("names")).transpose()?; + let widths = match a.get("widths") { + None => None, + Some(v) => Some( + v.as_array() + .and_then(|w| w.iter().map(Value::as_f64).collect::>>()) + .ok_or_else(|| bad("widths must be an array of numbers"))?, + ), + }; + // A negative count is no count: the table refuses it with its own words. + let counts = [a.opt_int("columns")?.map(|n| usize::try_from(n).unwrap_or(0)), names.as_ref().map(Vec::len), widths.as_ref().map(Vec::len)]; + let mut given = counts.into_iter().flatten(); + let columns = given.next().unwrap_or(1); + if given.any(|n| n != columns) { + return Err(bad("columns, names and widths must agree on the number of columns")); + } + // Before anything is sized by it. + if !(1..=MAX_COLUMNS).contains(&columns) { + return Err(bad(TableError::Columns.to_string())); + } + let (edit, fields) = { + let doc = self.doc(a)?; + let base = field_table::free_base(doc.form.iter().map(|f| f.name.as_str())); + let mut table = FieldTable::new(rect, &base); + table.rows = usize::try_from(a.int("rows")?).unwrap_or(0); + table.columns = (0..columns) + .map(|c| TableColumn { + pattern: names.as_ref().and_then(|n| n.get(c)).map_or_else(|| field_table::column_pattern(&base, c + 1), |p| (*p).to_owned()), + width: widths.as_ref().and_then(|w| w.get(c)).copied().unwrap_or(1.0), + }) + .collect(); + table.row_gap = a.opt_num("row_gap")?.unwrap_or(0.0); + table.column_gap = a.opt_num("column_gap")?.unwrap_or(0.0); + table.first_row = a.opt_int("first_row")?.map_or(1, |n| usize::try_from(n).unwrap_or(usize::MAX)); + let info = doc.info.pages.get(page).ok_or_else(|| bad(format!("page {} does not exist", page + 1)))?; + let edit = field_table::edit(&table, page, info, &doc.form).map_err(|e| bad(e.to_string()))?; + // The names the batch adds, in the order it adds them. + let fields: Vec = match &edit { + Edit::Batch { edits, .. } => { + edits.iter().filter_map(|e| if let Edit::AddField { name, .. } = e { name.clone() } else { None }).collect() + } + _ => Vec::new(), + }; + (edit, fields) + }; + let mut out = self.apply(a, edit)?; + out["fields"] = json!(fields); + Ok(out) + } + pub(crate) fn form_set_props(&mut self, a: &Args) -> Result { - if a.get("widget").is_some() && a.get("caption").is_none() { - return Err(bad("widget requires caption")); + if a.get("widget").is_some() && a.get("caption").is_none() && a.get("export_value").is_none() { + return Err(bad("widget requires caption or export_value")); } let single = a.opt_str("field")?; let bulk = a.get("fields").is_some(); @@ -360,7 +484,7 @@ impl Automation { return Err(bad(format!("field {name:?} is listed more than once"))); } } - for key in ["name", "rect", "rotation", "caption", "widget"] { + for key in ["name", "rect", "rotation", "caption", "export_value", "widget"] { if a.get(key).is_some() { return Err(bad(format!("{key} applies to one field; use field instead of fields"))); } @@ -386,12 +510,24 @@ impl Automation { } else { edits.pop().ok_or_else(|| bad("give a field name"))? }; + // A rename also renames the field in the formulas that read it. + let before = if a.get("name").is_some() { Some(self.doc(a)?.form.clone()) } else { None }; let mut out = self.apply(a, edit)?; if bulk { out["fields"] = json!(names); } else { out["field"] = json!(new_name); } + if let Some(before) = before { + let after = &self.doc(a)?.form; + // The renamed field itself, found by object: the name stored can differ from the + // argument (trimmed, kept under its parent's prefix). + let own = + names.first().and_then(|n| before.iter().find(|f| &f.name == n)).and_then(|f| after.iter().find(|g| g.obj == f.obj)).map(|g| &g.name); + let mut updated = pdfcraft_engine::form_calculations_changed(&before, after); + updated.retain(|f| Some(f) != own); + out["formulas_updated"] = json!(updated); + } Ok(out) } @@ -405,13 +541,22 @@ impl Automation { let r: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("rect must be 4 numbers".into()))?; let page = f.widgets.first().and_then(|w| w.page).ok_or_else(|| failed(format!("{name} is not on a page")))?; - let p = &doc.info.pages[page]; + let p = doc.info.pages.get(page).ok_or_else(|| failed(format!("{name} is on page {}, which could not be read", page + 1)))?; let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); Some((0, [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])) } }; Ok(FieldProps { rect, + export_value: a + .opt_str("export_value")? + .map(|value| -> Result<_> { + let index = a.opt_int("widget")?.unwrap_or(1); + let index = + usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?; + Ok((index, value.to_owned())) + }) + .transpose()?, name: a.opt_str("name")?.map(str::to_owned), tooltip: a.opt_str("tooltip")?.map(str::to_owned), caption: a @@ -510,17 +655,84 @@ impl Automation { }; let mut out = self.apply(a, Edit::MoveInTabOrder { name: field.to_owned(), earlier })?; out["tab_order"] = self.tab_sequence(a)?; + out["page_tabs"] = self.page_tabs(a)?; return Ok(out); } - let order = match a.str("order")? { - "row" | "rows" => pdfcraft_engine::TabOrder::Row, - "column" | "columns" => pdfcraft_engine::TabOrder::Column, - "structure" => pdfcraft_engine::TabOrder::Structure, - "annotations" | "unspecified" => pdfcraft_engine::TabOrder::Annotations, - o => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))), + let order = match a.opt_str("order")? { + None => None, + Some("row" | "rows") => Some(pdfcraft_engine::TabOrder::Row), + Some("column" | "columns") => Some(pdfcraft_engine::TabOrder::Column), + Some("structure") => Some(pdfcraft_engine::TabOrder::Structure), + Some("annotations" | "unspecified") => Some(pdfcraft_engine::TabOrder::Annotations), + Some(o) => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))), + }; + let mut out = match order { + Some(order) => self.apply(a, Edit::SetTabOrder { pages, order })?, + None => json!({}), }; - let mut out = self.apply(a, Edit::SetTabOrder { pages, order })?; out["tab_order"] = self.tab_sequence(a)?; + out["page_tabs"] = self.page_tabs(a)?; + Ok(out) + } + + pub(crate) fn form_calculation_order(&mut self, a: &Args) -> Result { + let current = self.doc(a)?.calculation_order(); + let fix = a.opt_bool("fix")?.unwrap_or(false); + let field = a.opt_str("field")?; + let order = a.get("order").map(|_| a.strs("order")).transpose()?; + if usize::from(fix) + usize::from(field.is_some()) + usize::from(order.is_some()) > 1 { + return Err(bad("give one change at a time: order, field with move, or fix")); + } + let names: Option> = if let Some(order) = order { + Some(order.into_iter().map(str::to_owned).collect()) + } else if let Some(field) = field { + let earlier = match a.opt_str("move")?.unwrap_or("earlier") { + "earlier" => true, + "later" => false, + m => return Err(bad(format!("move must be earlier or later, not {m:?}"))), + }; + let mut names: Vec = current.fields.iter().map(|f| f.name.clone()).collect(); + let i = names.iter().position(|n| n == field).ok_or_else(|| bad(format!("{field:?} is not a calculated field")))?; + // Already first or last: nothing moves. + let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; + j.map(|j| { + names.swap(i, j); + names + }) + } else { + (fix && current.fixable()).then(|| current.fixed()) + }; + let mut out = match names { + Some(names) => self.apply(a, Edit::SetCalculationOrder { names })?, + None => json!({}), + }; + let order = self.doc(a)?.calculation_order(); + let issue = |i: &CalculationIssue| match i { + CalculationIssue::MissingField { field } => json!({ "issue": "missing_field", "field": field }), + CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }), + CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }), + CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }), + CalculationIssue::BuiltNames => json!({ "issue": "built_names" }), + }; + // By field object, not name: two calculated fields can share a name in a malformed form. + let doc = self.doc(a)?; + let by_obj: std::collections::HashMap<_, &FormField> = doc.form.iter().map(|f| (f.obj, f)).collect(); + out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::>()); + out["fields"] = order + .fields + .iter() + .map(|f| { + json!({ + "name": f.name, + "formula": by_obj.get(&f.obj).and_then(|x| calculate_json(x)), + "reads": f.reads, + "listed": f.listed, + "issues": f.issues.iter().map(issue).collect::>(), + }) + }) + .collect(); + out["extra"] = json!(order.extra); + out["fixable"] = json!(order.fixable()); Ok(out) } @@ -533,12 +745,35 @@ impl Automation { Ok(json!(seq.iter().map(|x| x.1).collect::>())) } + /// Each page's tab order (Page properties ▸ Tab order), in page order. + fn page_tabs(&self, a: &Args) -> Result { + let names: Vec<&str> = self + .doc(a)? + .tab_orders() + .into_iter() + .map(|o| match o { + pdfcraft_engine::TabOrder::Row => "row", + pdfcraft_engine::TabOrder::Column => "column", + pdfcraft_engine::TabOrder::Structure => "structure", + pdfcraft_engine::TabOrder::Annotations => "annotations", + }) + .collect(); + Ok(json!(names)) + } + pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result { let name = a.str("field")?.to_owned(); if !self.doc(a)?.form.iter().any(|f| f.name == name) { return Err(failed(format!("there is no field named {name:?} (see form_fields)"))); } - self.apply(a, Edit::DeleteField { name }) + let Some(widget) = a.opt_int("widget")? else { return self.apply(a, Edit::DeleteField { name }) }; + let count = self.doc(a)?.form.iter().find(|f| f.name == name).map_or(0, |f| f.widgets.len()); + let widget = usize::try_from(widget) + .ok() + .and_then(|w| w.checked_sub(1)) + .filter(|w| *w < count) + .ok_or_else(|| bad(format!("widget must be 1 to {count}: {name} has {count} widgets")))?; + self.apply(a, Edit::DeleteWidget { name, widget }) } pub(crate) fn doc_export_data(&mut self, a: &Args) -> Result { @@ -563,7 +798,7 @@ impl Automation { pub(crate) fn doc_import_data(&mut self, a: &Args) -> Result { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let before = (self.doc(a)?.info.annotations.len(), self.doc(a)?.form.iter().filter(|f| !f.value.is_empty()).count()); let mut out = self.apply(a, Edit::ImportData { name, bytes: std::sync::Arc::new(bytes) })?; diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs new file mode 100644 index 0000000000..9e93d4c347 --- /dev/null +++ b/crates/automation/src/json.rs @@ -0,0 +1,391 @@ +use super::error::{Result, ToolError}; +use super::{Document, Value, comments, json}; + +/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at +/// most 14 400 across). +const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0; + +// ---- JSON helpers ---------------------------------------------------------------------------- + +/// The most bookmark levels `bookmark_from_text_styles` takes. +const MAX_STYLE_LEVELS: usize = 9; + +/// bookmark_from_text_styles' `levels`: one style per bookmark level, the first level 1, each +/// `{ font, size, bold, italic }` as text_styles lists them. +pub(crate) fn text_style_levels(a: &Args) -> Result> { + let levels = + a.get("levels").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("levels must list a style for each bookmark level".into()))?; + if levels.is_empty() || levels.len() > MAX_STYLE_LEVELS { + return Err(ToolError::InvalidArgs(format!("levels must list 1 to {MAX_STYLE_LEVELS} styles"))); + } + levels + .iter() + .enumerate() + .map(|(i, v)| { + let bad = |what: &str| ToolError::InvalidArgs(format!("levels[{i}]: {what}")); + let font = + v.get("font").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad("font must be a font name from text_styles"))?; + let size = + v.get("size").and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| bad("size must be a positive number"))?; + let flag = |key: &str| match v.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(b)) => Ok(*b), + Some(_) => Err(bad(&format!("{key} must be true or false"))), + }; + Ok(pdfcraft_engine::text_style_bookmarks::TextStyle { font: font.to_string(), size, bold: flag("bold")?, italic: flag("italic")? }) + }) + .collect() +} + +pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result> { + let Some(value) = value else { return Ok(Vec::new()) }; + let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; + values + .iter() + .map(|v| { + let a = Args(v); + let bounds = a + .get("range") + .and_then(Value::as_array) + .filter(|v| v.len() == 2) + .ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?; + let start = bounds + .first() + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let end = bounds + .get(1) + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; + let family = a + .opt_str("font")? + .map(|f| match f { + "helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica), + "times" => Ok(pdfcraft_engine::FontFamily::Times), + "courier" => Ok(pdfcraft_engine::FontFamily::Courier), + _ => Err(Args::wrong("font", "helvetica, times or courier")), + }) + .transpose()? + .map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) }) + .transpose()?; + Ok(pdfcraft_engine::RangeStyle { + range: start..end, + family, + resource: a.opt_str("font_resource")?.map(str::to_owned), + size: a.opt_num("size")?, + color: a.opt_str("color")?.map(comments::parse_color).transpose()?, + underline: a.opt_bool("underline")?, + char_spacing: a.opt_num("char_spacing")?, + scale: a.opt_num("scale")?, + }) + }) + .collect() +} + +/// Typed access to validated arguments. +pub(crate) struct Args<'a>(pub(crate) &'a Value); + +impl Args<'_> { + pub(crate) fn get(&self, key: &str) -> Option<&Value> { + self.0.get(key).filter(|v| !v.is_null()) + } + pub(crate) fn missing(key: &str) -> ToolError { + ToolError::InvalidArgs(format!("missing argument {key}")) + } + pub(crate) fn wrong(key: &str, what: &str) -> ToolError { + ToolError::InvalidArgs(format!("{key} must be {what}")) + } + pub(crate) fn str(&self, key: &str) -> Result<&str> { + self.opt_str(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_str(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose() + } + pub(crate) fn int(&self, key: &str) -> Result { + self.opt_int(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_int(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose() + } + pub(crate) fn opt_num(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose() + } + pub(crate) fn opt_bool(&self, key: &str) -> Result> { + self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose() + } + pub(crate) fn ints(&self, key: &str) -> Result> { + self.opt_ints(key)?.ok_or_else(|| Self::missing(key)) + } + pub(crate) fn opt_ints(&self, key: &str) -> Result>> { + let Some(v) = self.get(key) else { return Ok(None) }; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; + arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) + } + /// A 1-based bookmark path → 0-based indices. + pub(crate) fn path(&self, key: &str) -> Result> { + let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?; + if p.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must not be empty"))); + } + Ok(p) + } + pub(crate) fn opt_path(&self, key: &str) -> Result>> { + let Some(v) = self.opt_ints(key)? else { return Ok(None) }; + v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some) + } + /// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error + /// when it is not a number or lies absurdly far off the page. + pub(crate) fn bookmark_coordinate(&self, key: &str) -> Result> { + match self.opt_num(key)? { + None => Ok(None), + Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)), + Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))), + } + } + pub(crate) fn strs(&self, key: &str) -> Result> { + let v = self.get(key).ok_or_else(|| Self::missing(key))?; + let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; + arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect() + } +} + +/// The name-tree keys of a document's own attachments, leaving out the files attached to pages as comments. +pub(crate) fn document_attachment_keys(doc: &Document) -> Vec { + doc.info + .attachments + .iter() + .filter_map(|x| match &x.source { + pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()), + pdfcraft_render::AttachmentSource::Annotation { .. } => None, + }) + .collect() +} + +/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the +/// next level down in `children`, and paths and pages are 1-based. +pub(crate) fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { + // The open rows, outermost first, with their depth and the children gathered so far. + let mut open: Vec<(usize, Value, Vec)> = Vec::new(); + let mut roots = Vec::new(); + for row in rows { + let depth = row.path.len().saturating_sub(1); + while open.last().is_some_and(|(d, _, _)| *d >= depth) { + close_row(&mut open, &mut roots); + } + let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); + let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + // Only what differs from the defaults (black, regular), as the PDF itself stores it. + if let Some(color) = row.color { + fields["color"] = json!(color); + } + if row.bold { + fields["bold"] = json!(true); + } + if row.italic { + fields["italic"] = json!(true); + } + if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { + fields["destination"] = destination; + } + open.push((depth, fields, Vec::new())); + } + while !open.is_empty() { + close_row(&mut open, &mut roots); + } + roots +} + +/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page +/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its +/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the +/// reader's position and zoom). `None` for a destination without a type. +pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { + use pdfcraft_render::DestView; + let points = |left: Option, top: Option| { + let [x, y] = page.dest_fraction(left, top, 0); + let round = |v: f32| f64::from((v * 100.0).round() / 100.0); + (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) + }; + Some(match view { + DestView::Top => return None, + DestView::Xyz { left, top, zoom } => { + let (x, y) = points(*left, *top); + json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) + } + DestView::Fit => json!({ "fit": "Fit" }), + DestView::FitH { top } => { + let (x, y) = points(None, *top); + json!({ "fit": "FitH", "left": x, "top": y }) + } + DestView::FitV { left } => { + let (x, y) = points(*left, None); + json!({ "fit": "FitV", "left": x, "top": y }) + } + DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), + }) +} + +/// Closes the innermost open row: it joins the row above it as a child, or the top level. +fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { + if let Some((_, mut fields, children)) = open.pop() { + fields["children"] = Value::Array(children); + match open.last_mut() { + Some((_, _, siblings)) => siblings.push(fields), + None => roots.push(fields), + } + } +} + +pub(crate) fn one_based(pages: &[i64]) -> Result> { + pages + .iter() + .map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) }) + .collect() +} + +/// The document's security as the next save writes it (never includes passwords). +pub(crate) fn security(d: &Document) -> Value { + match d.security_summary() { + None => json!({ "protected": false }), + Some(s) => { + let p = s.permissions; + json!({ + "protected": true, + "method": s.method, + "pending": s.pending, + "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, + "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), + "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), + }) + } + } +} + +/// What every tool that changes a document returns. +pub(crate) fn summary(d: &Document) -> Value { + json!({ + "doc": d.id.0, + "name": d.name, + "path": d.path, + "pages": d.info.pages.len(), + "dirty": d.dirty, + "editable": d.read_only_reason.is_none(), + "read_only_reason": d.read_only_reason, + "encrypted": d.info.encrypted, + "undo": d.can_undo(), + "redo": d.can_redo(), + }) +} + +pub(crate) fn info(d: &Document) -> Value { + let i = &d.info; + let page1 = |p: usize| p + 1; + let mut security = security(d); + if let Some(s) = d.security_summary() { + security["opened_as_owner"] = json!(s.owner); + } + json!({ + "document": summary(d), + "pdf_version": i.pdf_version, + "file_size": i.file_size, + "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the + // ones the spec names, set and cleared with doc_set_info. + "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), + "creator": i.creator, "producer": i.producer, + "tagged": i.tagged, + "has_javascript": i.has_javascript, + // Where the document opens (/OpenAction, named destinations resolved): 1-based page and + // destination, null without one. + "open_destination": i.open_dest.as_ref().map(|(p, view)| json!({ "page": page1(*p), "destination": i.pages.get(*p).and_then(|pg| destination(view, pg)) })), + // XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" + // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). + "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), + "xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })), + // A script of the form ran too long and was abandoned: its scripts no longer run in this + // document (calculations, buttons) until it is opened again. + "xfa_scripts_off": d.xfa_scripts_off(), + // What was rewritten from, or could not be written to, the XFA data. + "xfa_warnings": d.xfa_warnings, + "security": security, + "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ + "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, + })).collect::>(), + "outline": outline(&i.outline), + "outline_more": i.outline_more, + // Rectangles use the tools' convention (top-left of the displayed page, like + // comment_list and link_list), not raw PDF user space, so they can be fed back to + // geometry-taking tools (#129). + "annotations": i.annotations.iter().map(|a| json!({ + "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, + "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, + "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), + })).collect::>(), + "fields": i.fields.iter().map(|f| json!({ + "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), + "tooltip": f.tooltip, "has_actions": f.has_actions, + })).collect::>(), + "links": i.links.iter().map(|l| json!({ + "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), + "target": match &l.target { + pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), + pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), + pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ + "layers": changes.iter().map(|(op, ocg)| json!({ + "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), + "state": match op { + pdfcraft_render::LayerOp::On => "on", + pdfcraft_render::LayerOp::Off => "off", + pdfcraft_render::LayerOp::Toggle => "toggle", + }, + })).collect::>(), + "preserve_rb": preserve_rb, + }), + pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({ + "action": if *launch { "Launch" } else { "GoToR" }, + "remote_document": file, + "destination": match destination { + pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), + pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), + pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), + }, + "new_window": new_window, + }), + pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), + }, + })).collect::>(), + "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), + // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with + // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. + "transitions": i.transitions.iter().map(|t| json!({ + "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, + "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, + "display": t.display, + })).collect::>(), + "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), + // `substitute` names the face standing in for a program the document does not embed, and + // `substitution` says why. Both are null for an embedded font: nothing stands in. A null + // `substitute` with a non-null `substitution` means no face was available at all, so + // nothing is drawn for that font -- which is the answer to "why is my text missing?". + "fonts": i.fonts.iter().map(|f| json!({ + "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, + "substitute": f.substitute, "substitution": f.substitution, + })).collect::>(), + "warnings": i.warnings, + "repairs": d.repair_log(), + }) +} + +/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the +/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports +/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). +/// Public so `pdfcraft-cli` reports the same rectangles as the tools. +pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { + i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) +} + +fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { + Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) +} diff --git a/crates/automation/src/layers.rs b/crates/automation/src/layers.rs new file mode 100644 index 0000000000..7cb0817a47 --- /dev/null +++ b/crates/automation/src/layers.rs @@ -0,0 +1,47 @@ +//! Layers (optional content groups) as a tool: the Layers panel's eye icon (#2986, AGENTS.md §3). +//! +//! A per-document view setting, like hidden comments: `page_render` draws with it, and the file +//! itself never changes. + +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError}; + +impl Automation { + /// Show or hide a layer, named by `layer`: its name as `doc_info` lists it, or its 1-based + /// position in that list. A name picks every layer that has it, since CAD exporters often + /// write one group per page or per drawing under the same name. + pub(crate) fn layer_set(&mut self, a: &Args) -> Result { + let doc = self.doc(a)?; + let id = doc.id; + let visible = a.opt_bool("visible")?.ok_or_else(|| ToolError::InvalidArgs("missing argument visible".into()))?; + let layers = &doc.info.layers; + let targets: Vec = match a.get("layer") { + Some(Value::String(name)) => layers.iter().enumerate().filter(|(_, l)| l.name == *name).map(|(i, _)| i).collect(), + Some(v) => match v.as_u64() { + Some(n) if n >= 1 && usize::try_from(n - 1).is_ok_and(|i| i < layers.len()) => vec![(n - 1) as usize], + Some(_) => Vec::new(), + None => return Err(ToolError::InvalidArgs("layer must be a layer name or its 1-based position in doc_info's layers".into())), + }, + None => return Err(ToolError::InvalidArgs("missing argument layer".into())), + }; + if targets.is_empty() { + let names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect(); + return Err(ToolError::InvalidArgs(if names.is_empty() { + "the document has no layers".into() + } else { + format!("no such layer; the document's layers are: {}", names.join(", ")) + })); + } + let mut changed = false; + for i in targets { + changed |= self.session.set_layer_visible(id, i, visible); + } + if changed { + // The cached renderer drew with the old visibility; page_render must read it again. + self.renderers.remove(&id); + } + let layers = &self.doc(a)?.info.layers; + Ok(json!({ "layers": layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>() })) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 0e2e0dd698..f1910867c9 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -21,6 +21,7 @@ mod content; mod conventions; pub mod file_access; mod forms; +mod layers; mod links; #[cfg(feature = "mcp")] pub mod mcp; @@ -32,11 +33,11 @@ mod signing; mod text_cache; mod tools; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use pdfcraft_engine::{CombineSource, DocId, Document, Edit, Session, commands}; +use pdfcraft_engine::{BlockKey, CombineSource, DocId, Document, Edit, Session, TextBlock, block_overlapping, commands}; use pdfcraft_platform::staging::{StagingName, staging_suffixes}; use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind}; use serde_json::{Value, json}; @@ -44,69 +45,32 @@ use serde_json::{Value, json}; pub use pdfcraft_render::Pixels; pub use tools::{ToolDef, tools}; -/// One piece of a tool's result. -#[derive(Clone, Debug, PartialEq)] -pub enum Content { - /// Structured data (MCP: a text block with the JSON plus `structuredContent`). - Json(Value), - /// A PNG image. - Png { data: Vec, width: u32, height: u32 }, -} - -/// Why a tool call failed. -#[derive(Clone, Debug, PartialEq)] -pub enum ToolError { - /// No tool has this name. - UnknownTool(String), - /// The arguments don't match the tool's schema. - InvalidArgs(String), - /// The tool ran and failed (the message is for the agent to read). - Failed(String), -} - -impl std::fmt::Display for ToolError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ToolError::UnknownTool(t) => write!(f, "unknown tool {t:?}"), - ToolError::InvalidArgs(m) | ToolError::Failed(m) => f.write_str(m), - } - } -} - -impl std::error::Error for ToolError {} - -type Result = std::result::Result; - -fn failed(e: impl std::fmt::Display) -> ToolError { - ToolError::Failed(e.to_string()) -} - -impl From for ToolError { - fn from(e: pdfcraft_engine::EditError) -> Self { - failed(e) - } -} - -/// Default and maximum resolution for `page_render`. -const DEFAULT_DPI: f64 = 96.0; -const MAX_DPI: f64 = 600.0; -/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). -const WATERMARK_MAX_OFFSET: f64 = 20_000.0; - -/// Bookmarks in one page of the bookmark list, by default and at most. -const BOOKMARK_PAGE: usize = 100; -const MAX_BOOKMARK_PAGE: usize = 1000; - -/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a -/// match near the start returns without touching the rest of a long document (#971). -const FIND_FIRST_BATCH: usize = 8; -const FIND_MAX_BATCH: usize = 128; - +use file_access::foreign_share; use text_cache::TextCache; -type PageTexts = Vec<(usize, std::result::Result, String>)>; -const FIND_MAX_WARNINGS: usize = 100; -const FIND_MAX_WARNING_BYTES: usize = 1024; +mod assemble; +mod dispatch; +mod documents; +mod error; +mod json; +mod pages; +mod paths; + +pub use dispatch::Content; +pub use error::ToolError; +use error::{Result, failed}; +pub(crate) use json::destination; +pub use json::view_rect; +use json::{Args, info, summary}; +use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content}; +#[cfg(test)] +use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel}; +#[cfg(test)] +pub(crate) use paths::MAX_INPUT; +use paths::{child, lexical, unused, write_atomic}; +pub(crate) use paths::{read_input, read_input_bytes}; +#[cfg(test)] +use paths::{write_atomic_synced, write_atomic_with}; /// A headless PdfCraft session driven by tool calls. pub struct Automation { @@ -118,6 +82,12 @@ pub struct Automation { renderers: HashMap>, PageRenderer)>, /// Extracted page text, under one memory budget across all open documents. texts: TextCache, + /// The paragraphs of the last `text_paragraphs` listing of each page, so a paragraph number + /// keeps naming that paragraph after edits regroup the page (#2516). + listed: HashMap<(DocId, usize), Vec>, + /// Documents that lost a listing to the cap. A page of one with no listing is refused rather + /// than read as "a number is a position", which the page may no longer match. + evicted: HashSet, } impl Default for Automation { @@ -128,7 +98,15 @@ impl Default for Automation { impl Automation { pub fn new() -> Self { - Self { session: Session::new(), root: None, roots: None, renderers: HashMap::new(), texts: TextCache::default() } + Self { + session: Session::new(), + root: None, + roots: None, + renderers: HashMap::new(), + texts: TextCache::default(), + listed: HashMap::new(), + evicted: HashSet::new(), + } } /// Confine every path the tools read or write to `root` (relative paths resolve inside it). @@ -155,6 +133,12 @@ impl Automation { self } + /// Multiply the time XFA form scripts may take (tests). See [`Session::with_xfa_time_scale`]. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.session = std::mem::take(&mut self.session).with_xfa_time_scale(factor); + self + } + /// The clock and time zone of a host without its own. See [`Session::with_wall_clock`]. pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset); @@ -180,2965 +164,7 @@ impl Automation { } Ok(target) } - - /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). - pub fn call(&mut self, name: &str, args: &Value) -> Result> { - let empty = json!({}); - let args = if args.is_null() { &empty } else { args }; - if !args.is_object() { - return Err(ToolError::InvalidArgs("arguments must be a JSON object".into())); - } - let def = tools::find(name).ok_or_else(|| ToolError::UnknownTool(name.into()))?; - tools::check_args(def, args)?; - let a = Args(args); - let out = match name { - "doc_open" => self.doc_open(&a)?, - "doc_batch" => self.doc_batch(&a)?, - "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), - "doc_info" => { - let id = self.doc(&a)?.id; - self.session.refresh_fonts(id); - info(self.doc(&a)?) - } - "doc_close" => self.doc_close(&a)?, - "doc_save" => self.doc_save(&a)?, - "doc_set_info" => { - let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; - self.apply(&a, edit)? - } - "page_render" => return self.page_render(&a).map(|c| vec![c]), - "comment_image_preview" => return self.comment_image_preview(&a), - "text_extract" => self.text_extract(&a)?, - "text_word_count" => self.text_word_count(&a)?, - "text_find" => self.text_find(&a)?, - "page_rotate" => { - let degrees = a.int("degrees")?; - if degrees % 90 != 0 { - return Err(ToolError::InvalidArgs("degrees must be a multiple of 90".into())); - } - let doc = self.doc(&a)?; - let base = match a.opt_ints("pages")? { - Some(_) => self.pages(&a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let parity = match a.opt_str("subset")?.unwrap_or("all") { - "all" => pdfcraft_engine::PageParity::Both, - "even" => pdfcraft_engine::PageParity::Even, - "odd" => pdfcraft_engine::PageParity::Odd, - s => return Err(ToolError::InvalidArgs(format!("unknown subset {s:?} (all, even, odd)"))), - }; - let orientation = match a.opt_str("orientation")?.unwrap_or("all") { - "all" => pdfcraft_engine::PageOrientation::Both, - "landscape" => pdfcraft_engine::PageOrientation::Landscape, - "portrait" => pdfcraft_engine::PageOrientation::Portrait, - o => return Err(ToolError::InvalidArgs(format!("unknown orientation {o:?} (all, landscape, portrait)"))), - }; - let pages = pdfcraft_engine::filter_pages(&self.doc(&a)?.info, &base, parity, orientation); - if pages.is_empty() { - return Err(failed("no pages match the filters")); - } - let n = pages.len(); - let mut out = self.apply(&a, Edit::RotatePages { pages, degrees })?; - out["rotated"] = json!(n); - out - } - "page_enhance_camera" => { - let page = self.page(&a)?; - let given = a.nums::<8>("corners")?; - let doc = self.doc(&a)?; - let (id, info) = (doc.id, doc.info.pages.get(page).cloned().ok_or_else(|| failed("no such page"))?); - let corners = match given { - Some(c) => [0, 1, 2, 3].map(|i| { - let [x, y] = info.view_to_user(c[2 * i] as f32, c[2 * i + 1] as f32); - [x as f64, y as f64] - }), - None => self.session.detect_camera_corners(id, page).ok_or_else(|| { - failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]") - })?, - }; - let level = match a.opt_int("level")? { - None => 0, - Some(l @ 0..=100) => l as u8, - Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))), - }; - let look = match a.opt_str("mode")? { - None => pdfcraft_engine::camera_clean::Look::Colour, - Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?, - }; - self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?; - let r = |v: f32| (v * 100.0).round() / 100.0; - let used: Vec = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect(); - let mut out = summary(self.doc(&a)?); - out["corners"] = json!(used); - out["detected"] = json!(given.is_none()); - out - } - "page_delete" => { - let pages = self.pages(&a, "pages")?; - self.apply(&a, Edit::DeletePages { pages })? - } - "page_move" => { - let pages = self.pages(&a, "pages")?; - let to = self.position(&a, "to")?; - self.apply(&a, Edit::MovePages { pages, to })? - } - "page_reverse" => { - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(&a, "pages")?, - None => (0..self.doc(&a)?.info.pages.len()).collect(), - }; - self.apply(&a, Edit::ReversePages { pages })? - } - "page_insert_blank" => self.insert_blank(&a)?, - "page_insert_file" => self.insert_file(&a)?, - "page_extract" => self.page_extract(&a)?, - "doc_combine" => self.doc_combine(&a)?, - "doc_create_multiple" => self.doc_create_multiple(&a)?, - "doc_split" => self.doc_split(&a)?, - "edit_undo" => { - let id = self.doc(&a)?.id; - let label = self.session.undo(id).map_err(failed)?.to_string(); - json!({ "undone": label, "document": summary(self.doc(&a)?) }) - } - "edit_redo" => { - let id = self.doc(&a)?.id; - let label = self.session.redo(id).map_err(failed)?.to_string(); - json!({ "redone": label, "document": summary(self.doc(&a)?) }) - } - "command_list" => self.command_list(&a)?, - "command_run" => return self.command_run(&a), - "command_batch" => self.command_batch(&a)?, - "doc_inspect" => self.doc_inspect(&a)?, - "render_preview" => return self.render_preview(&a).map(|c| vec![c]), - "page_number" => { - use pdfcraft_organize::LabelStyle as L; - let n = self.doc(&a)?.info.pages.len(); - let (from, to) = (a.int("from")?, a.int("to")?); - if from < 1 || to < from || to as usize > n { - return Err(ToolError::InvalidArgs(format!("from and to must satisfy 1 ≤ from ≤ to ≤ {n}"))); - } - let style = match a.opt_str("style")?.unwrap_or("decimal") { - "decimal" => L::Decimal, - "upper-roman" => L::UpperRoman, - "lower-roman" => L::LowerRoman, - "upper-alpha" => L::UpperAlpha, - "lower-alpha" => L::LowerAlpha, - "none" => L::None, - other => return Err(ToolError::InvalidArgs(format!("unknown style {other:?}"))), - }; - let prefix = a.opt_str("prefix")?.unwrap_or_default().to_string(); - let first = a.opt_int("start")?.unwrap_or(1).clamp(1, u32::MAX as i64) as u32; - let mut out = self.apply(&a, Edit::NumberPages { from: from as usize - 1, to: to as usize - 1, style, prefix, first })?; - out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::>()); - out - } - "bookmark_list" => self.bookmark_listing(&a)?, - "bookmark_add" => { - let page = self.page(&a)?; - let parent = a.opt_path("parent")?.unwrap_or_default(); - let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); - let title = a.str("title")?.to_string(); - self.apply(&a, Edit::AddBookmark { parent, index, title, page })? - } - "bookmark_rename" => { - let (path, title) = (a.path("path")?, a.str("title")?.to_string()); - self.apply(&a, Edit::RenameBookmark { path, title })? - } - "bookmark_delete" => { - let path = a.path("path")?; - self.apply(&a, Edit::DeleteBookmark { path })? - } - "bookmark_move" => { - let from = a.path("path")?; - let to_parent = a.opt_path("parent")?.unwrap_or_default(); - let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); - self.apply(&a, Edit::MoveBookmark { from, to_parent, index })? - } - "bookmark_set_page" => { - let (path, page) = (a.path("path")?, self.page(&a)?); - self.apply(&a, Edit::SetBookmarkPage { path, page })? - } - "bookmark_from_structure" => { - let mut out = self.apply(&a, Edit::BookmarksFromStructure)?; - let listing = self.bookmark_listing(&a)?; - out["bookmarks"] = listing["bookmarks"].clone(); - out["next"] = listing["next"].clone(); - out - } - "doc_protect" => self.doc_protect(&a)?, - "page_replace" => { - let pages = self.pages(&a, "pages")?; - let path = self.resolve(a.str("path")?, false)?; - let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?); - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let src_pages = match a.opt_ints("from_pages")? { - Some(p) => one_based(&p)?, - None => (0..pages.len()).collect(), - }; - self.apply(&a, Edit::ReplacePages { pages, name, bytes, src_pages })? - } - "page_duplicate" => { - let pages = self.pages(&a, "pages")?; - let at = self.opt_position(&a, "at")?; - self.apply(&a, Edit::DuplicatePages { pages, at })? - } - "page_set_box" => self.page_set_box(&a)?, - "page_transition_set" => self.page_transition_set(&a)?, - "doc_create" => self.doc_create(&a)?, - "xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?, - "doc_flatten" => { - let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); - if !comments && !fields { - return Err(ToolError::InvalidArgs("nothing to flatten".into())); - } - self.apply(&a, Edit::Flatten { comments, fields })? - } - "doc_reduce" => { - let id = self.doc(&a)?.id; - let before = self.doc(&a)?.bytes.len(); - let path = self.resolve(a.str("path")?, true)?; - let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?; - // As in the app, a copy that isn't smaller is not written (#490). - let written = bytes.len() < before; - if written { - write_atomic(&path, &bytes)?; - } - json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged }) - } - "doc_optimize" => self.doc_optimize(&a)?, - "doc_initial_view" => self.doc_initial_view(&a)?, - "doc_revisions" => self.doc_revisions(&a)?, - "text_lines" => { - let page = self.page(&a)?; - let doc = self.doc(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let lines: Vec = doc - .text_lines(page) - .iter() - .enumerate() - .map(|(i, l)| { - let (u, v) = (info.user_to_view(l.rect[0] as f32, l.rect[1] as f32), info.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); - json!({ - "line": i + 1, - "text": l.text, - "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], - "font": l.base_font, - "size": (l.size * 100.0).round() / 100.0, - }) - }) - .collect(); - json!({ "page": page + 1, "count": lines.len(), "lines": lines }) - } - "image_hit" => { - let page = self.page(&a)?; - let point = a - .get("point") - .and_then(Value::as_array) - .filter(|p| p.len() == 2) - .and_then(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])) - .filter(|p| p.iter().all(|v| v.is_finite() && v.abs() <= 1e9)) - .ok_or_else(|| ToolError::InvalidArgs("point must contain two finite coordinates".into()))?; - let doc = self.doc(&a)?; - let p = &doc.info.pages[page]; - let user = p.view_to_user(point[0] as f32, point[1] as f32).map(f64::from); - let images: Vec<_> = doc.page_images(page).iter().enumerate().filter(|(_, im)| im.contains(user)).map(|(i, _)| i + 1).collect(); - json!({"page":page+1,"images":images}) - } - "page_images" => { - let page = self.page(&a)?; - let doc = self.doc(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let list: Vec = doc - .page_images(page) - .iter() - .enumerate() - .map(|(i, im)| { - let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32)); - let visible = im.visible_rect.map(|rect| { - let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32)); - [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))] - }); - json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } }) - }) - .collect(); - json!({ "page": page + 1, "count": list.len(), "images": list }) - } - "image_edit" | "image_save" => { - let page = self.page(&a)?; - let n = self.doc(&a)?.page_images(page).len(); - let k = a.int("image")?; - if k < 1 || k as usize > n { - return Err(ToolError::InvalidArgs(format!("image {k} is out of range: page {} has {n} images", page + 1))); - } - let index = k as usize - 1; - if name == "image_save" { - let (ext, bytes) = self.doc(&a)?.page_image_file(page, index).map_err(failed)?; - let mut path = self.resolve(a.str("path")?, true)?; - if path.is_dir() { - // Adding the extension to a folder's name would write beside it: "." is the - // root, so that would land outside it. - return Err(failed(format!("{}: is a folder, not a file", path.display()))); - } - if path.extension().is_none() { - path.set_extension(ext); - } - write_atomic(&path, &bytes)?; - json!({ "path": path.to_string_lossy(), "format": ext, "bytes": bytes.len() }) - } else { - use pdfcraft_engine::ImageEdit; - let change = match a.str("action")? { - "move" => { - let r: Vec = - a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("move needs rect: 4 numbers".into()))?; - // Top-left-origin points → user space. - let info = &self.doc(&a)?.info.pages[page]; - let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); - ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), - "crop" => { - let r: Vec = - a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - let r = - <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; - let info = &self.doc(&a)?.info.pages[page]; - let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); - ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - "flip_horizontal" => ImageEdit::Flip { horizontal: true }, - "flip_vertical" => ImageEdit::Flip { horizontal: false }, - "delete" => ImageEdit::Delete, - "replace" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; - ImageEdit::Replace { - name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - bytes: Arc::new(bytes), - } - } - other => return Err(ToolError::InvalidArgs(format!("unknown action {other:?}"))), - }; - self.apply(&a, Edit::EditPageImage { page, index, change })? - } - } - "text_paragraphs" => { - let page = self.page(&a)?; - let doc = self.doc_to_copy(&a)?; - let info = &doc.info.pages[page]; - let r = |x: f32| (x as f64 * 100.0).round() / 100.0; - let blocks: Vec = doc - .text_blocks(page) - .iter() - .enumerate() - .map(|(i, b)| { - let (u, v) = (info.user_to_view(b.rect[0] as f32, b.rect[1] as f32), info.user_to_view(b.rect[2] as f32, b.rect[3] as f32)); - json!({ - "paragraph": i + 1, - "text": b.text, - "editor_text": b.editor_text, - "rows": b.rows.iter().map(|row| row.iter().map(|i| i + 1).collect::>()).collect::>(), - "lines": b.lines.iter().map(|l| l + 1).collect::>(), - "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], - "font": b.base_font, - "size": (b.size * 100.0).round() / 100.0, - }) - }) - .collect(); - json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) - } - "text_font_files" => { - let roots = if let Some(paths) = a.get("directories") { - let paths = paths - .as_array() - .filter(|p| p.len() <= 16) - .ok_or_else(|| ToolError::InvalidArgs("directories must be at most 16 paths".into()))?; - paths - .iter() - .map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false)) - .collect::>>()? - } else if let Some(roots) = &self.roots { - // The installed fonts are outside the roots: look in the read root instead. - let read = roots - .read_root() - .ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?; - vec![self.resolve(&read.to_string_lossy(), false)?] - } else if let Some(root) = &self.root { - vec![root.clone()] - } else { - pdfcraft_engine::text_fonts::installed_directories() - }; - json!({"fonts": pdfcraft_engine::text_fonts::discover(&roots).into_iter().map(|f| json!({"path":f.path,"family":f.family,"font":f.face})).collect::>()}) - } - "text_font_import" => { - let page = self.page(&a)?; - let path = self.resolve(a.str("path")?, false)?; - let file = std::fs::File::open(&path).map_err(failed)?; - use std::io::Read; - let mut bytes = Vec::new(); - file.take((16 << 20) + 1).read_to_end(&mut bytes).map_err(failed)?; - if bytes.len() > 16 << 20 { - return Err(ToolError::InvalidArgs("font input exceeds 16 MiB".into())); - } - let before: Vec<_> = self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| f.resource).collect(); - let mut out = self.apply(&a, Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })?; - let added = self - .doc(&a)? - .text_editor_fonts(page) - .into_iter() - .find(|f| !before.contains(&f.resource) && !f.resource.starts_with("PCApproved")); - if let Some(f) = added { - out["resource"] = json!(f.resource); - out["font"] = json!(f.face); - } - out - } - "text_fonts" => { - let page = self.page(&a)?; - json!({"fonts": self.doc(&a)?.text_editor_fonts(page).into_iter().map(|f| json!({"resource":f.resource,"font":f.face,"full_face":f.resource.starts_with("PCFull"),"preview_only":f.resource.starts_with("PCApproved")})).collect::>()}) - } - "text_edit_layout" => { - let page = self.page(&a)?; - let doc = self.doc(&a)?; - let k = a.int("paragraph")?; - let blocks = doc.text_blocks(page); - if k < 1 || k as usize > blocks.len() { - return Err(ToolError::InvalidArgs("paragraph is out of range".into())); - } - let source = &blocks[k as usize - 1]; - let text = a.opt_str("text")?.unwrap_or(&source.editor_text); - let style = pdfcraft_engine::BlockStyle { - size: a.opt_num("size")?, - width: a.opt_num("width")?, - resource: a.opt_str("font_resource")?.map(str::to_owned), - ranges: parse_style_ranges(a.get("ranges"))?, - ..Default::default() - }; - let layout = doc.text_edit_layout(page, k as usize - 1, text, &style).map_err(failed)?; - let info = &doc.info.pages[page]; - let view_rect = |r: [f64; 4]| { - let a = info.user_to_view(r[0] as f32, r[1] as f32); - let b = info.user_to_view(r[2] as f32, r[3] as f32); - [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])] - }; - let fonts: Vec<_> = doc.text_editor_fonts(page).into_iter().map(|f| json!({"resource": f.resource, "face": f.face})).collect(); - json!({ "page": page + 1, "paragraph": k, "source_rect": view_rect(layout.source_rect), - "draft_rect": layout.draft_rect.map(view_rect), "overflow": layout.overflow, "embedded_fonts": fonts }) - } - "text_edit" if a.get("paragraph").is_some() => { - let page = self.page(&a)?; - let n = self.doc(&a)?.text_blocks(page).len(); - let k = a.int("paragraph")?; - if k < 1 || k as usize > n { - return Err(ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1))); - } - let block = self.doc(&a)?.text_blocks(page)[k as usize - 1].clone(); - let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); - let mut style = pdfcraft_engine::BlockStyle { - ranges: parse_style_ranges(a.get("ranges"))?, - resource: a.opt_str("font_resource")?.map(str::to_owned), - bold: a.opt_bool("bold")?, - size: a.opt_num("size")?, - underline: a.opt_bool("underline")?, - line_spacing: a.opt_num("line_spacing")?, - char_spacing: a.opt_num("char_spacing")?, - scale: a.opt_num("scale")?, - width: a.opt_num("width")?, - ..Default::default() - }; - let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); - if dx.is_some() || dy.is_some() { - style.offset = Some([dx.unwrap_or(0.0), dy.unwrap_or(0.0)]); - } - if let Some(f) = a.opt_str("font")? { - let family = match f { - "helvetica" => pdfcraft_engine::FontFamily::Helvetica, - "times" => pdfcraft_engine::FontFamily::Times, - "courier" => pdfcraft_engine::FontFamily::Courier, - other => return Err(ToolError::InvalidArgs(format!("unknown font {other:?} (helvetica, times, courier)"))), - }; - style.family = Some((family, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))); - } - if let Some(c) = a.opt_str("color")? { - style.color = Some(comments::parse_color(c)?); - } - if let Some(al) = a.opt_str("align")? { - style.align = Some(match al { - "left" => pdfcraft_engine::TextAlign::Left, - "center" => pdfcraft_engine::TextAlign::Center, - "right" => pdfcraft_engine::TextAlign::Right, - "justify" => pdfcraft_engine::TextAlign::Justify, - other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), - }); - } - let mut out = self.apply(&a, Edit::EditTextBlock { page, block: k as usize - 1, text, style })?; - if let Some(b) = self.doc(&a)?.text_blocks(page).get(k as usize - 1) { - out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font }); - } - out - } - "text_edit" => { - let page = self.page(&a)?; - let n = self.doc(&a)?.text_lines(page).len(); - let line = a.int("line")?; - if line < 1 || line as usize > n { - return Err(ToolError::InvalidArgs(format!("line {line} is out of range: page {} has {n} lines", page + 1))); - } - let text = a.str("text")?.to_owned(); - let mut out = self.apply(&a, Edit::EditTextLine { page, line: line as usize - 1, text })?; - let after = self.doc(&a)?.text_lines(page); - if let Some(l) = after.get(line as usize - 1) { - out["line"] = json!({ "text": l.text, "font": l.base_font }); - } - out - } - "doc_audit_space" => { - let rows: Vec = self - .doc(&a)? - .audit_space() - .iter() - .map(|u| json!({ "category": u.category.label(), "bytes": u.bytes, "percent": (u.percent * 100.0).round() / 100.0 })) - .collect(); - json!({ "categories": rows }) - } - "doc_open_revision" => { - let id = self.doc(&a)?.id; - let n = usize::try_from(a.opt_int("revision")?.ok_or_else(|| ToolError::InvalidArgs("revision is required".into()))?).unwrap_or(0); - let new = self.session.open_revision(id, n).map_err(failed)?; - summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?) - } - "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, - "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, - "doc_unprotect" => { - let mut out = self.apply(&a, Edit::RemoveProtection)?; - out["security"] = security(self.doc(&a)?); - out - } - "form_fields" => self.form_fields(&a)?, - "form_fill" => self.form_fill(&a)?, - "form_set_image" => { - let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; - self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? - } - "form_reset" => self.form_reset(&a)?, - "form_add_field" => self.form_add_field(&a)?, - "form_set_props" => self.form_set_props(&a)?, - "form_delete_field" => self.form_delete_field(&a)?, - "form_tab_order" => self.form_tab_order(&a)?, - "doc_export_data" => self.doc_export_data(&a)?, - "doc_import_data" => self.doc_import_data(&a)?, - "redact_mark" => self.redact_mark(&a)?, - "redact_apply" => self.redact_apply(&a)?, - "redact_clear" => self.redact_clear(&a)?, - "doc_hidden_info" => self.doc_hidden_info(&a)?, - "printers" => self.printers()?, - "printer_options" => self.printer_options(&a)?, - "link_list" => self.link_list(&a)?, - "link_open" => self.link_open(&a)?, - "link_add" => self.link_add(&a)?, - "link_edit" => self.link_edit(&a)?, - "link_delete" => self.link_delete(&a)?, - "links_from_urls" => self.links_from_urls(&a)?, - "links_remove" => self.links_remove(&a)?, - "object_list" => self.object_list(&a)?, - "object_move" => self.object_move(&a)?, - "content_list" => self.content_list(&a)?, - "page_add_text" => self.page_add_text(&a)?, - "page_add_image" => self.page_add_image(&a)?, - "content_update" => self.content_update(&a)?, - "content_delete" => self.content_delete(&a)?, - "doc_print" => self.doc_print(&a)?, - "doc_remove_hidden" => self.doc_remove_hidden(&a)?, - "doc_audit_redactions" => self.doc_audit_redactions(&a)?, - "fill_sign_add" => self.fill_sign_add(&a)?, - "fill_sign_date_format" => self.fill_sign_date_format(&a)?, - "measure_distance" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Distance)?, - "measure_perimeter" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Perimeter)?, - "measure_area" => self.measurement_add(&a, pdfcraft_engine::measure::Kind::Area)?, - "measure_info" => self.measurement_info(&a)?, - "measure_list" => self.measurement_list(&a)?, - "measure_scale" => self.measurement_scale(&a)?, - "measure_snap" => self.measurement_snap(&a)?, - "measure_export" => self.measurement_export(&a)?, - "comment_list" => self.comment_list(&a)?, - "comment_add" => self.comment_add(&a)?, - "stamp_custom" => self.stamp_custom(&a)?, - "comment_reply" => self.comment_reply(&a)?, - "comment_set_status" => self.comment_set_status(&a)?, - "sign_list" => self.sign_list(&a)?, - "accessibility_check" => self.a11y_check(&a)?, - "accessibility_find_untagged" => self.a11y_find_untagged(&a)?, - "accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?, - "accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?, - "accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?, - "ocr_recognize" => self.ocr_recognize(&a)?, - "js_run" => self.js_run(&a)?, - "js_document_scripts" => self.js_document_scripts(&a)?, - "js_set_document_script" => self.js_set_document_script(&a)?, - "js_enabled" => self.js_enabled(&a)?, - "form_set_script" => self.form_set_script(&a)?, - "form_merge_data" => self.form_merge_data(&a)?, - "form_actions" => self.form_actions(&a)?, - "form_detect_fields" => self.form_detect_fields(&a)?, - "doc_compare" => self.doc_compare(&a)?, - "action_list" => self.action_list()?, - "pdfa_verify" => self.pdfa_verify(&a)?, - "print_readiness" => self.print_readiness(&a)?, - "doc_export_office" => self.doc_export_office(&a)?, - "pdfa_convert" => self.pdfa_convert(&a)?, - "action_run" => self.action_run(&a)?, - "doc_compare_report" => self.doc_compare_report(&a)?, - "doc_compare_mark" => self.doc_compare_mark(&a)?, - "form_set_actions" => self.form_set_actions(&a)?, - "ocr_status" => self.ocr_status()?, - "ocr_recognize_files" => self.ocr_recognize_files(&a)?, - "accessibility_report" => self.a11y_report(&a)?, - "accessibility_review_record" => self.a11y_review_record(&a)?, - "accessibility_review_save" => self.a11y_review_save(&a)?, - "accessibility_review_load" => self.a11y_review_load(&a)?, - "accessibility_review_html" => self.a11y_review_html(&a)?, - "accessibility_fix" => self.a11y_fix(&a)?, - "accessibility_figures" => self.accessibility_figures(&a)?, - "accessibility_set_alt" => self.accessibility_set_alt(&a)?, - "accessibility_tags" => self.accessibility_tags(&a)?, - "accessibility_tag_content" => self.accessibility_tag_content(&a)?, - "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, - "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, - "accessibility_move_tag" => self.accessibility_move_tag(&a)?, - "sign_id_create" => self.sign_id_create(&a)?, - "sign_document" => self.sign_document(&a)?, - "sign_keychain_ids" => { - #[cfg(target_os = "macos")] - let ids: Vec = pdfcraft_engine::sign::keychain::identities(None) - .map_err(failed)? - .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::keychain::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) - .collect(); - #[cfg(not(target_os = "macos"))] - let ids: Vec = Vec::new(); - json!({ "count": ids.len(), "ids": ids }) - } - "sign_windows_ids" => { - #[cfg(target_os = "windows")] - let (ids, unusable): (Vec, Vec) = { - let listing = pdfcraft_engine::sign::windows::list().map_err(failed)?; - let ids = listing - .ids - .iter() - .map(|id| json!({ "id": pdfcraft_engine::sign::windows::reference(&id.certificate), "certificate": signing::cert_json(&id.certificate) })) - .collect(); - let unusable = listing - .unusable - .iter() - .map(|u| json!({ "subject": u.subject, "sha256": u.fingerprint, "reason": u.reason, "no_private_key": u.no_private_key })) - .collect(); - (ids, unusable) - }; - #[cfg(not(target_os = "windows"))] - let (ids, unusable): (Vec, Vec) = (Vec::new(), Vec::new()); - json!({ "count": ids.len(), "ids": ids, "unusable": unusable }) - } - "sign_trust" => self.sign_trust(&a)?, - "comment_mark" => self.comment_mark(&a)?, - "comment_lock" => self.comment_lock(&a)?, - "comments_hide" => self.comments_hide(&a)?, - "view_reading_colors" => self.view_reading_colors(&a)?, - "comments_summarize" => self.comments_summarize(&a)?, - "comment_edit" => self.comment_edit(&a)?, - "comment_delete" => self.comment_delete(&a)?, - other => return Err(ToolError::UnknownTool(other.into())), - }; - Ok(vec![Content::Json(out)]) - } - - // ---- documents --------------------------------------------------------------------------- - - /// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`). - fn bookmark_listing(&self, a: &Args) -> Result { - let id = a.int("doc")?; - let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?); - let offset = a.opt_int("offset")?.unwrap_or(0); - let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; - let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64); - let limit = usize::try_from(limit) - .ok() - .filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l)) - .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?; - let page = self - .session - .bookmark_page(id, offset, limit) - .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; - let pages = &self.doc(a)?.info.pages; - Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated })) - } - - fn doc(&self, a: &Args) -> Result<&Document> { - let id = a.int("doc")?; - let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?; - self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)"))) - } - - /// The document, if its security allows copying text and images out of it (Table 22, bit 5). - fn doc_to_copy(&self, a: &Args) -> Result<&Document> { - let doc = self.doc(a)?; - doc.check_copying().map_err(failed)?; - Ok(doc) - } - - fn doc_open(&mut self, a: &Args) -> Result { - let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; - let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; - Ok(summary(doc)) - } - - fn doc_close(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let discard_changes = a.opt_bool("discard_changes")?.unwrap_or(false); - if doc.dirty && !discard_changes { - return Err(failed("the document has unsaved changes: save it with doc_save, or pass discard_changes: true")); - } - self.session.close(id); - self.renderers.remove(&id); - self.texts.remove(&id); - Ok(json!({ "closed": id.0 })) - } - - fn doc_save(&mut self, a: &Args) -> Result { - self.doc_save_with(a, write_atomic) - } - - fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let target = match a.opt_str("path")? { - Some(p) => self.resolve(p, true)?, - None => { - // Saving in place writes where the file was opened from, which the read root - // allows and the write root may not. - let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?; - let own_path = PathBuf::from(&own); - self.check_roots(&own, &own_path, file_access::Access::Write)?; - own_path - } - }; - let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target); - // Saving to a new file is a full rewrite unless asked otherwise, like Save As. - let full = a.opt_bool("full")?.unwrap_or(!same_file); - let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false); - if flatten_fill_sign { - self.apply(a, Edit::FlattenFillSign)?; - } - let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) }.map_err(failed)?; - // A save that adds or removes encryption, or must not keep the old revision (Sanitize, - // redaction), is rewritten in full even when an incremental save was asked for. - let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes()); - self.publish_save(id, &target, bytes, incremental, write) - } - - /// Validate the prepared bytes before publishing them or marking the document saved. - fn publish_save( - &mut self, - id: DocId, - target: &Path, - bytes: Arc>, - incremental: bool, - write: impl FnOnce(&Path, &[u8]) -> Result<()>, - ) -> Result { - self.session.check_save(id, &bytes).map_err(failed)?; - write(target, &bytes)?; - let path = target.to_string_lossy().into_owned(); - self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?; - let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?; - Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) })) - } - - fn doc_revisions(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let ends = doc.revision_ends(); - let sigs = doc.signatures.clone(); - let list: Vec = ends - .iter() - .enumerate() - .map(|(i, end)| { - let start = if i == 0 { 0 } else { ends[i - 1] }; - // A signature signs the revision its byte range ends in. - let signed: Vec<&str> = - sigs.iter().filter(|s| s.signed && s.signed_len > start && s.signed_len <= *end).map(|s| s.field.as_str()).collect(); - json!({ "revision": i + 1, "end": end, "bytes": end - start, "signed_by": signed }) - }) - .collect(); - Ok(json!({ "revisions": list, "unsaved_changes": doc.dirty })) - } - - fn doc_initial_view(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; - let bad = |m: String| ToolError::InvalidArgs(m); - let mut v = self.doc(a)?.initial_view(); - let before = v.clone(); - if let Some(n) = a.opt_str("navigation")? { - v.navigation = match n { - "page" => N::PageOnly, - "bookmarks" => N::Bookmarks, - "pages" => N::Pages, - "attachments" => N::Attachments, - "layers" => N::Layers, - o => return Err(bad(format!("unknown navigation {o:?}"))), - }; - } - if let Some(l) = a.opt_str("layout")? { - v.layout = match l { - "default" => L::Default, - "single" => L::SinglePage, - "continuous" => L::SinglePageContinuous, - "two_up" => L::TwoUp, - "two_up_continuous" => L::TwoUpContinuous, - "two_up_cover" => L::TwoUpCoverPage, - "two_up_continuous_cover" => L::TwoUpContinuousCoverPage, - o => return Err(bad(format!("unknown layout {o:?}"))), - }; - } - match a.get("magnification") { - None => {} - Some(Value::Number(p)) => v.magnification = M::Percent(p.as_f64().unwrap_or(100.0).clamp(1.0, 6400.0)), - Some(Value::String(m)) => { - v.magnification = match m.as_str() { - "default" => M::Default, - "actual" => M::ActualSize, - "fit_page" => M::FitPage, - "fit_width" => M::FitWidth, - "fit_height" => M::FitHeight, - "fit_visible" => M::FitVisible, - o => return Err(bad(format!("unknown magnification {o:?}"))), - } - } - Some(_) => return Err(bad("magnification is a name or a percentage".into())), - } - if let Some(p) = a.opt_int("page")? { - v.page = (p.max(1) - 1) as usize; - } - for (k, f) in [ - ("fit_window", &mut v.fit_window), - ("center_window", &mut v.center_window), - ("full_screen", &mut v.full_screen), - ("display_title", &mut v.display_title), - ("hide_menubar", &mut v.hide_menubar), - ("hide_toolbar", &mut v.hide_toolbar), - ("hide_window_ui", &mut v.hide_window_ui), - ] { - if let Some(b) = a.opt_bool(k)? { - *f = b; - } - } - if let Some(l) = a.opt_str("language")? { - v.language = (!l.trim().is_empty()).then(|| l.trim().to_string()); - } - if let Some(b) = a.opt_str("binding")? { - v.right_to_left = match b { - "left" => false, - "right" => true, - o => return Err(bad(format!("binding is left or right, not {o:?}"))), - }; - } - let mut out = if v != before { self.apply(a, Edit::SetInitialView(Box::new(v.clone())))? } else { json!({}) }; - out["initial_view"] = json!({ - "navigation": format!("{:?}", v.navigation), - "layout": format!("{:?}", v.layout), - "magnification": format!("{:?}", v.magnification), - "page": v.page + 1, - "fit_window": v.fit_window, - "center_window": v.center_window, - "full_screen": v.full_screen, - "display_title": v.display_title, - "hide_menubar": v.hide_menubar, - "hide_toolbar": v.hide_toolbar, - "hide_window_ui": v.hide_window_ui, - "language": v.language, - "binding": if v.right_to_left { "right" } else { "left" }, - }); - Ok(out) - } - - fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset}; - let id = self.doc(a)?.id; - let before = self.doc(a)?.bytes.len(); - let path = self.resolve(a.str("path")?, true)?; - let mut settings = match a.opt_str("preset")? { - None => Preset::Standard.settings(), - Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(), - }; - let images = |key: &str, base: ImageSettings| -> Result { - let Some(v) = a.get(key) else { return Ok(base) }; - let mut s = base; - if let Some(d) = v.get("downsample").and_then(Value::as_bool) { - s.downsample = d; - } - if let Some(p) = v.get("ppi").and_then(Value::as_f64) { - s.target_ppi = p.clamp(9.0, 2400.0); - } - if let Some(p) = v.get("above_ppi").and_then(Value::as_f64) { - s.above_ppi = p.clamp(9.0, 2400.0); - } - s.above_ppi = s.above_ppi.max(s.target_ppi); - if let Some(c) = v.get("compression").and_then(Value::as_str) { - let q = v.get("quality").and_then(Value::as_u64).unwrap_or(60).clamp(1, 100) as u8; - s.compression = match c { - "jpeg" => Compression::Jpeg(q), - "zip" | "flate" => Compression::Flate, - "retain" => Compression::Retain, - other => return Err(ToolError::InvalidArgs(format!("unknown compression {other:?} (jpeg, zip, retain)"))), - }; - } - Ok(s) - }; - settings.color = images("color", settings.color)?; - settings.gray = images("gray", settings.gray)?; - for (key, flag) in [ - ("merge_fragments", &mut settings.merge_fragments), - ("discard_thumbnails", &mut settings.discard_thumbnails), - ("discard_alternate_images", &mut settings.discard_alternate_images), - ("discard_tags", &mut settings.discard_tags), - ("discard_print_settings", &mut settings.discard_print_settings), - ("flate_unencoded", &mut settings.flate_unencoded), - ("remove_invalid_links", &mut settings.remove_invalid_links), - ("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests), - ] { - if let Some(b) = a.opt_bool(key)? { - *flag = b; - } - } - let discard: Vec = match a.get("discard") { - None => Vec::new(), - Some(v) => v - .as_array() - .ok_or_else(|| ToolError::InvalidArgs("discard must be an array".into()))? - .iter() - .map(|x| { - x.as_str() - .and_then(pdfcraft_engine::Hidden::from_id) - .ok_or_else(|| ToolError::InvalidArgs(format!("unknown discard category {x}"))) - }) - .collect::>()?, - }; - let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?; - write_atomic(&path, &bytes)?; - let o = &r.optimize; - Ok(json!({ - "path": path.to_string_lossy(), - "bytes_before": before, - "bytes_after": bytes.len(), - "merged_images": o.merged_images, - "fragments_merged": o.fragments_merged, - "images": o.images, - "images_resampled": o.images_resampled, - "images_recompressed": o.images_recompressed, - "image_bytes_before": o.image_bytes_before, - "image_bytes_after": o.image_bytes_after, - "thumbnails": o.thumbnails, - "alternate_images": o.alternate_images, - "tags_removed": o.tags_removed, - "streams_compressed": o.streams_compressed, - "invalid_links": o.invalid_links, - "invalid_bookmarks": o.invalid_bookmarks, - "unreferenced_dests": o.unreferenced_dests, - "unused_xobjects": o.unused_xobjects, - "merged_objects": r.merged, - "discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::>(), - })) - } - - /// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based). - fn mark_file(&self, a: &Args) -> Result> { - let Some(p) = a.opt_str("file")? else { return Ok(None) }; - let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; - let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; - Ok(Some(pdfcraft_engine::MarkFile { - name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), - bytes: Arc::new(bytes), - page, - })) - } - - fn apply(&mut self, a: &Args, edit: Edit) -> Result { - let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - let doc = self.doc(a)?; - let mut out = summary(doc); - // Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent. - if !doc.edit_notices.is_empty() { - out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::>()); - } - Ok(out) - } - - fn marks(&mut self, tool: &str, a: &Args) -> Result { - use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; - let n = self.doc(a)?.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let color = - |key: &str, default: [f64; 3]| -> Result<[f64; 3]> { Ok(a.opt_str(key)?.map(comments::parse_color).transpose()?.unwrap_or(default)) }; - let replace = a.opt_bool("replace")?.unwrap_or(false); - let edit = match tool { - "doc_header_footer" => { - let mut hf = HeaderFooter::default(); - for (k, key) in ["header_left", "header_center", "header_right", "footer_left", "footer_center", "footer_right"].iter().enumerate() { - hf.text[k] = a.opt_str(key)?.unwrap_or_default().to_string(); - } - if let Some(s) = a.opt_num("font_size")? { - hf.font_size = s; - } - hf.color = color("color", hf.color)?; - if let Some(m) = a.get("margins").and_then(Value::as_array) { - let m: Vec = m.iter().filter_map(Value::as_f64).collect(); - hf.margins = <[f64; 4]>::try_from(m).map_err(|_| ToolError::InvalidArgs("margins must be 4 numbers".into()))?; - } - if let Some(s) = a.opt_int("start_number")? { - hf.start_number = s.clamp(1, u32::MAX as i64) as u32; - } - Edit::AddHeaderFooter { pages, settings: hf, replace } - } - "doc_watermark" => { - let d = Watermark::default(); - let file = self.mark_file(a)?; - let text = match &file { - Some(_) => a.opt_str("text")?.unwrap_or_default().to_string(), - None => a.str("text")?.to_string(), - }; - let wm = Watermark { - text, - source: None, - scale: a.opt_num("scale")?.unwrap_or(d.scale).clamp(0.01, 1.0), - font_size: a.opt_num("font_size")?.unwrap_or(0.0), - color: color("color", d.color)?, - opacity: a.opt_num("opacity")?.unwrap_or(d.opacity), - rotation: a.opt_num("rotation")?.unwrap_or(d.rotation), - behind: a.opt_bool("behind")?.unwrap_or(false), - offset: [ - a.opt_num("offset_x")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), - a.opt_num("offset_y")?.unwrap_or(0.0).clamp(-WATERMARK_MAX_OFFSET, WATERMARK_MAX_OFFSET), - ], - }; - Edit::AddWatermark { pages, settings: wm, replace, file } - } - "doc_background" => Edit::AddBackground { - pages, - settings: Background { - color: color("color", [1.0; 3])?, - opacity: a.opt_num("opacity")?.unwrap_or(1.0), - scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), - ..Background::default() - }, - replace, - file: self.mark_file(a)?, - }, - _ => { - let kind = match a.str("kind")? { - "header_footer" => MarkKind::HeaderFooter, - "watermark" => MarkKind::Watermark, - "background" => MarkKind::Background, - other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))), - }; - Edit::RemoveMarks { kind } - } - }; - self.apply(a, edit) - } - - fn export(&mut self, tool: &str, a: &Args) -> Result { - let doc = self.doc_to_copy(a)?; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); - let mut ex = pdfcraft_engine::export::Exporter::new(doc); - if tool == "doc_export_text" { - let path = self.resolve(a.str("path")?, true)?; - let text = ex.text_of(&pages).map_err(failed)?; - write_atomic(&path, text.as_bytes())?; - return Ok(json!({ "path": path.to_string_lossy(), "pages": pages.len(), "bytes": text.len() })); - } - let folder = self.resolve(a.str("folder")?, true)?; - std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?; - if tool == "doc_export_all_images" { - let min = u32::try_from(a.opt_int("min_size")?.unwrap_or(0).max(0)).unwrap_or(u32::MAX); - let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; - let mut files = Vec::new(); - for (k, img) in out.images.iter().enumerate() { - let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len())); - write_atomic(&path, &img.data)?; - files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); - } - let skipped: Vec = out.skipped.iter().map(|(p, r, why)| json!({ "page": p + 1, "object": r.num, "reason": why })).collect(); - return Ok(json!({ "count": files.len(), "files": files, "skipped": skipped })); - } - let dpi = a.opt_num("dpi")?.unwrap_or(150.0); - let quality = a.opt_int("quality")?.unwrap_or(85).clamp(1, 100) as u8; - let format = match a.opt_str("format")?.unwrap_or("png") { - "png" => pdfcraft_engine::export::ImageFormat::Png, - "jpeg" | "jpg" => pdfcraft_engine::export::ImageFormat::Jpeg { quality }, - "tiff" | "tif" => pdfcraft_engine::export::ImageFormat::Tiff, - f => return Err(ToolError::InvalidArgs(format!("unknown format {f:?} (png, jpeg, tiff)"))), - }; - let mut files = Vec::new(); - let mut lowered = Vec::new(); - for p in pages { - let img = ex.image(p, dpi, format).map_err(failed)?; - let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); - write_atomic(&path, &img)?; - files.push(path.to_string_lossy().into_owned()); - // A page too large for the renderer at `dpi` is drawn at the most it allows. - let used = ex.dpi_used(p, dpi); - if used < dpi.clamp(18.0, 1200.0) - 0.5 { - lowered.push(json!({ "page": p + 1, "dpi": used.floor() })); - } - } - if lowered.is_empty() { - Ok(json!({ "count": files.len(), "files": files })) - } else { - Ok(json!({ "count": files.len(), "files": files, "lower_dpi": lowered })) - } - } - - fn doc_create(&mut self, a: &Args) -> Result { - let (name, bytes) = match a.str("from")? { - "blank" => { - let n = a.opt_int("pages")?.unwrap_or(1).clamp(1, 10_000) as usize; - let (w, h) = (a.opt_num("width")?.unwrap_or(612.0), a.opt_num("height")?.unwrap_or(792.0)); - ("Untitled.pdf".to_string(), self.session.create_blank(w, h, n).map_err(failed)?) - } - "images" => { - let resolution = a.opt_num("dpi")?.map_or(pdfcraft_engine::ImageResolution::Embedded, pdfcraft_engine::ImageResolution::Dpi); - let mut images = Vec::new(); - for p in a.strs("paths")? { - let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; - images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); - } - let name = if images.len() == 1 { - format!("{}.pdf", images[0].0.rsplit_once('.').map_or(images[0].0.as_str(), |(s, _)| s)) - } else { - "Images.pdf".into() - }; - (name, self.session.create_from_images_with_resolution(&images, resolution).map_err(failed)?) - } - "text" => { - let (title, text) = match (a.opt_str("text")?, a.opt_str("path")?) { - (Some(t), _) => ("Text".to_string(), t.to_string()), - (None, Some(p)) => { - let path = self.resolve(p, false)?; - let t = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; - (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) - } - (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), - }; - // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with - // its line) by `pdfcraft_create::from_text`, as for every other caller. - let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { - pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), - e => failed(e), - })?; - (format!("{title}.pdf"), bytes) - } - other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))), - }; - let name = a.opt_str("name")?.map(str::to_owned).unwrap_or(name); - let id = self.session.open_new(name, bytes).map_err(failed)?; - Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) - } - - fn page_transition_set(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{Transition, TransitionSpec}; - let n = self.doc(a)?.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let transition = match a.opt_str("style")? { - None | Some("none") => None, - Some(style) => { - let mut t = Transition::new(style); - if let Some(d) = a.opt_num("duration")? { - t.duration = d as f32; - } - if let Some(dm) = a.opt_str("dimension")? { - t.horizontal = match dm { - "horizontal" => true, - "vertical" => false, - other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))), - }; - } - if let Some(m) = a.opt_str("motion")? { - t.inward = match m { - "inward" => true, - "outward" => false, - other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))), - }; - } - match a.get("direction") { - None => {} - Some(Value::String(s)) if s == "none" => t.direction = None, - Some(v) => { - t.direction = Some( - v.as_u64() - .and_then(|d| u16::try_from(d).ok()) - .ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?, - ); - } - } - if let Some(s) = a.opt_num("scale")? { - t.scale = s as f32; - } - if let Some(b) = a.opt_bool("opaque")? { - t.opaque = b; - } - Some(t) - } - }; - let display = a.opt_num("display")?.map(|s| s as f32); - self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } }) - } - - fn page_set_box(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{BoxSpec, PageBox}; - let doc = self.doc(a)?; - let n = doc.info.pages.len(); - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), - }; - let which = match a.opt_str("box")? { - None => PageBox::Crop, - Some(b) => PageBox::from_name(b).ok_or_else(|| ToolError::InvalidArgs(format!("unknown box {b:?}")))?, - }; - let nums = |key: &str| -> Result> { - a.get(key) - .map(|v| { - let v: Vec = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); - <[f64; 4]>::try_from(v).map_err(|_| ToolError::InvalidArgs(format!("{key} must be 4 numbers"))) - }) - .transpose() - }; - let spec = match (nums("margins")?, nums("rect")?) { - (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass margins or rect, not both".into())), - (Some(m), None) => BoxSpec::Margins(m), - (None, Some(r)) => { - // Top-left-origin points on the displayed page → user space (one page at a time). - if pages.len() != 1 { - return Err(ToolError::InvalidArgs("rect applies to a single page; use margins for several".into())); - } - let p = &doc.info.pages[pages[0]]; - let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); - BoxSpec::Rect([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) - } - (None, None) => BoxSpec::Remove, - }; - let mut out = self.apply(a, Edit::SetPageBox { pages, which, spec })?; - out["page_sizes"] = json!(self.doc(a)?.info.pages.iter().map(|p| [p.width, p.height]).collect::>()); - Ok(out) - } - - fn doc_protect(&mut self, a: &Args) -> Result { - use pdfcraft_engine::{Algorithm, Changes, Printing, Protection}; - let d = Protection::default(); - // Restrictions only exist behind a permissions password (ISO 32000-2 §7.6.4.4: /P is - // enforced against the owner password; without one everything stays allowed). Refuse a - // restriction that could not take effect instead of writing an unrestricted file (#134). - let restriction = ["printing", "changes", "copy", "accessibility"].into_iter().find(|k| a.get(k).is_some()); - if let (None, Some(key)) = (a.opt_str("permissions_password")?, restriction) { - return Err(ToolError::InvalidArgs(format!( - "`{key}` needs `permissions_password`: with open_password alone the document is encrypted but nothing is restricted" - ))); - } - let p = Protection { - open_password: a.opt_str("open_password")?.map(str::to_owned), - permissions_password: a.opt_str("permissions_password")?.map(str::to_owned), - printing: match a.opt_str("printing")? { - None => d.printing, - Some("none") => Printing::None, - Some("low") => Printing::Low, - Some("high") => Printing::High, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown printing {o:?}"))), - }, - changes: match a.opt_str("changes")? { - None => d.changes, - Some("none") => Changes::None, - Some("pages") => Changes::Pages, - Some("fill-sign") => Changes::FillSign, - Some("comment-fill-sign") => Changes::CommentFillSign, - Some("any-except-extract") => Changes::AnyExceptExtract, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown changes {o:?}"))), - }, - copy: a.opt_bool("copy")?.unwrap_or(d.copy), - accessibility: a.opt_bool("accessibility")?.unwrap_or(d.accessibility), - algorithm: match a.opt_str("compatibility")? { - None | Some("aes-256") => Algorithm::Aes256, - Some("aes-128") => Algorithm::Aes128, - Some("rc4-128") => Algorithm::Rc4_128, - Some("rc4-40") => Algorithm::Rc4_40, - Some(o) => return Err(ToolError::InvalidArgs(format!("unknown compatibility {o:?}"))), - }, - encrypt_metadata: a.opt_bool("encrypt_metadata")?.unwrap_or(d.encrypt_metadata), - }; - let mut out = self.apply(a, Edit::Protect(p))?; - out["security"] = security(self.doc(a)?); - Ok(out) - } - - fn insert_blank(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let at = self.position(a, "at")?; - // Default to the size of the neighbouring page, as Acrobat does; Letter for empty files. - let near = doc.info.pages.get(at.saturating_sub(1)).or(doc.info.pages.first()); - let (w, h) = near.map(|p| (f64::from(p.width), f64::from(p.height))).unwrap_or((612.0, 792.0)); - let width = a.opt_num("width")?.unwrap_or(w); - let height = a.opt_num("height")?.unwrap_or(h); - if !(1.0..=14400.0).contains(&width) || !(1.0..=14400.0).contains(&height) { - return Err(ToolError::InvalidArgs("width and height must be between 1 and 14400 points".into())); - } - self.apply(a, Edit::InsertBlankPage { at, width, height }) - } - - fn insert_file(&mut self, a: &Args) -> Result { - let at = self.position(a, "at")?; - let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; - let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) - } - - /// Write freshly created bytes to `out` and/or open them as a new document. - fn deliver(&mut self, a: &Args, name: &str, bytes: Arc>) -> Result { - let mut result = json!({ "bytes": bytes.len() }); - let out = a.opt_str("out")?; - if let Some(out) = out { - let path = self.resolve(out, true)?; - write_atomic(&path, &bytes)?; - result["path"] = json!(path.to_string_lossy()); - } - if a.opt_bool("open")?.unwrap_or(out.is_none()) { - let id = self.session.open_new(name, bytes).map_err(failed)?; - result["document"] = summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?); - } - Ok(result) - } - - fn doc_create_multiple(&mut self, a: &Args) -> Result { - let paths = a.strs("paths")?; - if paths.is_empty() { - return Err(ToolError::InvalidArgs("paths must list at least one file".into())); - } - let separate = match a.opt_str("mode")? { - None | Some("combine") => false, - Some("separate") => true, - Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))), - }; - if separate { - return self.create_separate(a, &paths); - } - let ranges: Vec> = match a.get("pages") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(), - Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), - }; - // Each file is read, converted and copied in turn, so a long list holds one file at a time. - let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { - let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; - let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; - let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - Ok((title, pdf, range)) - }); - let bytes = self.session.combine_each(sources)?.ok_or_else(|| ToolError::InvalidArgs("paths must list at least one file".into()))?; - self.deliver(a, "Combined", bytes) - } - - /// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others. - fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result { - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut out = Vec::new(); - for &p in paths { - let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { - let bytes = std::fs::read(&src).map_err(|e| format!("{}: {e}", src.display()))?; - let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); - let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; - Ok((kind, stem, pdf)) - }); - out.push(match converted { - Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }), - Ok((_, stem, pdf)) => { - let target = unused(&dir, &stem); - write_atomic(&target, &pdf)?; - json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() }) - } - Err(e) => json!({ "path": p, "error": e }), - }); - } - Ok(json!({ "files": out })) - } - - fn page_extract(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let (id, name) = (doc.id, format!("{} (extract)", doc.name)); - let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "page".into()); - let pages = self.pages(a, "pages")?; - let mut out = if a.opt_bool("separate")?.unwrap_or(false) { - // Each page as its own file. - let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - for &p in &pages { - let bytes = self.session.extract(id, &[p]).map_err(failed)?; - let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1)); - write_atomic(&path, &bytes)?; - files.push(path.to_string_lossy().into_owned()); - } - json!({ "files": files }) - } else { - let bytes = self.session.extract(id, &pages).map_err(failed)?; - self.deliver(a, &name, bytes)? - }; - if a.opt_bool("delete")?.unwrap_or(false) { - let deleted = self.apply(a, Edit::DeletePages { pages })?; - out["original"] = deleted; - } - Ok(out) - } - - fn doc_combine(&mut self, a: &Args) -> Result { - let paths = a.strs("paths")?; - if paths.len() < 2 { - return Err(ToolError::InvalidArgs("combine needs at least two files".into())); - } - let ranges: Vec> = match a.get("pages") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() => v - .iter() - .enumerate() - .map(|(index, value)| match value { - Value::Null => Ok(None), - Value::String(range) => Ok(Some(range.clone())), - _ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))), - }) - .collect::>()?, - Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())), - }; - let passwords: Vec> = match a.get("passwords") { - None | Some(Value::Null) => vec![None; paths.len()], - Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => { - v.iter().map(|x| x.as_str().map(str::to_owned)).collect() - } - Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())), - }; - // Entries sharing a group number are one file split into parts placed apart. - let groups: Option>> = match a.get("groups") { - None | Some(Value::Null) => None, - Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_u64() || x.is_null()) => { - Some(v.iter().map(Value::as_u64).collect()) - } - Some(_) => return Err(ToolError::InvalidArgs("groups must list a number (or null) for each path".into())), - }; - let field_conflicts = match a.opt_str("field_conflicts")? { - None | Some("rename") => pdfcraft_organize::FieldConflicts::Rename, - Some("merge") => pdfcraft_organize::FieldConflicts::Merge, - Some(other) => { - return Err(ToolError::InvalidArgs(format!("field_conflicts must be \"rename\" or \"merge\", not {other:?}"))); - } - }; - let mut sources: Vec = Vec::new(); - let mut firsts: Vec<(u64, PathBuf, usize)> = Vec::new(); - for (i, (p, range)) in paths.into_iter().zip(ranges).enumerate() { - let path = self.resolve(p, false)?; - let group = groups.as_ref().and_then(|g| g.get(i).copied().flatten()); - let first = group.and_then(|g| firsts.iter().find(|f| f.0 == g)); - let bytes = match first { - Some((g, first_path, at)) => { - if *first_path != path { - return Err(ToolError::InvalidArgs(format!("groups[{i}]: group {g} is {}, not {}", first_path.display(), path.display()))); - } - sources.get(*at).map(|s| s.1.clone()).ok_or_else(|| failed("group source missing"))? - } - None => { - if let Some(g) = group { - firsts.push((g, path.clone(), sources.len())); - } - let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?); - // Images and text files are made into PDFs first (#569), as in the Combine files tab. - let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - self.session.as_pdf(&file_name, &bytes).map_err(failed)? - } - }; - let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - sources.push((name, bytes, range)); - } - let passwords: Vec> = passwords.iter().map(Option::as_deref).collect(); - let combined = match groups { - None => self.session.combine_unlocked_reporting(&sources, &passwords, field_conflicts), - Some(groups) => { - // A path without a group is a file of its own: give it a key no group uses. - let mut next = groups.iter().flatten().max().map_or(Some(0), |m| m.checked_add(1)); - let mut keys = Vec::with_capacity(groups.len()); - for g in &groups { - keys.push(match g { - Some(g) => *g, - None => { - let own = next.ok_or_else(|| ToolError::InvalidArgs("group numbers are too large".into()))?; - next = own.checked_add(1); - own - } - }); - } - self.session.combine_grouped_reporting(&sources, &keys, &passwords, field_conflicts) - } - } - .map_err(failed)?; - let mut result = self.deliver(a, "Combined", combined.bytes)?; - if !combined.renamed_fields.is_empty() { - result["renamed_fields"] = json!(combined.renamed_fields.iter().map(|(old, new)| json!([old, new])).collect::>()); - } - Ok(result) - } - - fn doc_split(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; - let id = doc.id; - let stem = Path::new(&doc.name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "part".into()); - let bookmarks = a.opt_bool("bookmarks")?.unwrap_or(false); - let max_mb = a.opt_num("max_mb")?; - let chosen = [a.get("every").is_some(), a.get("before").is_some(), bookmarks, max_mb.is_some()].iter().filter(|x| **x).count(); - if chosen != 1 { - return Err(ToolError::InvalidArgs( - "pass exactly one of every (pages per file), before (page numbers), bookmarks: true, or max_mb".into(), - )); - } - let mut titles: Vec<(usize, String)> = Vec::new(); - let parts = if let Some(mb) = max_mb { - if !mb.is_finite() || mb <= 0.0 { - return Err(ToolError::InvalidArgs("max_mb must be positive".into())); - } - self.session.split_by_size(id, (mb * 1_048_576.0) as usize).map_err(failed)? - } else { - let by = match (a.opt_int("every")?, a.opt_ints("before")?) { - (Some(n), None) if n > 0 => pdfcraft_organize::SplitBy::PageCount(n as usize), - (None, Some(b)) => pdfcraft_organize::SplitBy::Before(one_based(&b)?), - _ if bookmarks => { - titles = self.session.bookmark_splits(id); - if titles.is_empty() { - return Err(failed("the document has no top-level bookmarks")); - } - pdfcraft_organize::SplitBy::Before(titles.iter().map(|t| t.0).collect()) - } - _ => return Err(ToolError::InvalidArgs("every must be a positive page count".into())), - }; - self.session.split(id, &by).map_err(failed)? - }; - let dir = self.resolve(a.str("out_dir")?, true)?; - std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let mut files = Vec::new(); - let mut used = std::collections::HashSet::new(); - for (i, (first, last, bytes)) in parts.iter().enumerate() { - let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); - let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { - Some((_, t)) => format!("{stem}-{}", safe(t)), - None => format!("{stem}-part{}", i + 1), - }; - // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. - if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { - base.truncate(end); - } - let file = format!("{base}.pdf"); - // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. - let mut unique = file; - let mut n = 1; - while !used.insert(unique.to_lowercase()) { - n += 1; - unique = format!("{base}-{n}.pdf"); - } - let path = child(&dir, &unique); - write_atomic(&path, bytes)?; - files.push(json!({ "path": path.to_string_lossy(), "first_page": first, "last_page": last })); - } - Ok(json!({ "files": files })) - } - - // ---- pages and text ---------------------------------------------------------------------- - - /// 1-based page list → validated 0-based indices. - fn pages(&self, a: &Args, key: &str) -> Result> { - let n = self.doc(a)?.info.pages.len(); - let pages = one_based(&a.ints(key)?)?; - if pages.is_empty() { - return Err(ToolError::InvalidArgs(format!("{key} must list at least one page"))); - } - if let Some(p) = pages.iter().find(|p| **p >= n) { - return Err(ToolError::InvalidArgs(format!("page {} is out of range: the document has {n} pages", p + 1))); - } - Ok(pages) - } - - /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. - fn position(&self, a: &Args, key: &str) -> Result { - self.opt_position(a, key)?.ok_or_else(|| Args::missing(key)) - } - - /// `position`, but `None` when the key is absent. - fn opt_position(&self, a: &Args, key: &str) -> Result> { - let Some(at) = a.opt_int(key)? else { return Ok(None) }; - let n = self.doc(a)?.info.pages.len(); - if at < 1 || at as usize > n + 1 { - return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1))); - } - Ok(Some(at as usize - 1)) - } - - fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { - let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; - let colors = self.session.reading_colors(id).unwrap_or_default(); - let fresh = || { - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), reading_colors: colors, ..Default::default() }; - (doc.display.clone(), PageRenderer::new(doc.display.clone(), config)) - }; - let entry = self.renderers.entry(id).or_insert_with(fresh); - if !Arc::ptr_eq(&entry.0, &doc.display) { - *entry = fresh(); - } - Ok(&mut entry.1) - } - - fn page_render(&mut self, a: &Args) -> Result { - let id = self.doc(a)?.id; - let page = self.page(a)?; - let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI); - if !(1.0..=MAX_DPI).contains(&dpi) { - return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); - } - let scale = (dpi / 72.0) as f32; - let block = a.opt_int("hide_text_block")?; - let added = a.opt_int("hide_added_text")?; - if block.is_some() && added.is_some() { - return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); - } - let out = if let Some(block) = block { - let index = usize::try_from(block) - .ok() - .and_then(|n| n.checked_sub(1)) - .ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?; - self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? - } else if let Some(added) = added { - let index = usize::try_from(added) - .ok() - .and_then(|n| n.checked_sub(1)) - .ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?; - self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? - } else { - self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) - }; - if let Some(e) = out.error { - return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); - } - let data = encode_png(out.width, out.height, &out.rgba)?; - Ok(Content::Png { data, width: out.width, height: out.height }) - } - - /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels - /// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas: - /// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted - /// work on every scroll. It uses the same renderer, and the same per-document cache, as - /// `page_render`. - pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> { - if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) { - return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0))); - } - let id = DocId(doc); - let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len(); - if page < 1 || page > n { - return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages"))); - } - let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); - if let Some(e) = out.error { - return Err(failed(format!("page {page} could not be rendered: {e}"))); - } - Ok((out.width, out.height, out.rgba)) - } - - fn page(&self, a: &Args) -> Result { - let n = self.doc(a)?.info.pages.len(); - let p = a.int("page")?; - if p < 1 || p as usize > n { - return Err(ToolError::InvalidArgs(format!("page {p} is out of range: the document has {n} pages"))); - } - Ok(p as usize - 1) - } - - /// The text of `pages` (0-based), from the cache or extracted in parallel. - fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result>> { - self.page_text_results(id, pages, &mut extract_parallel)? - .into_iter() - .map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1)))) - .collect() - } - - /// Keep per-page failures until the caller chooses strict or partial-result semantics. - fn page_text_results( - &mut self, - id: DocId, - pages: &[usize], - extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, - ) -> Result { - let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; - let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len()); - let password: Option> = doc.password.as_deref().map(Arc::from); - self.texts.prepare(id, &bytes); - let mut results = HashMap::new(); - let mut missing = Vec::new(); - for &p in pages { - if p >= n { - results.insert(p, Err("page is out of range".to_owned())); - } else if let Some(text) = self.texts.get(id, p) { - results.insert(p, Ok(text)); - } else { - missing.push(p); - } - } - missing.sort_unstable(); - missing.dedup(); - if !missing.is_empty() { - for (p, text) in extract(&bytes, password, &missing) { - if let Ok(text) = &text { - self.texts.insert(id, p, &bytes, text.clone()); - } - results.insert(p, text); - } - } - // The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A - // worker that fails to return a page is an explicit failure, never an empty success. - Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) - } - - fn text_word_count(&self, a: &Args) -> Result { - use pdfcraft_engine::word_count::{self, Counts, Options}; - let doc = self.doc_to_copy(a)?; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - let options = Options { - exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), - exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), - exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), - }; - let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; - let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); - Ok(json!({ - "total": counts(report.total), - "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), - "pages_without_text": report.pages_without_text, - "unclassified_structure": report.unclassified_structure, - })) - } - - fn text_extract(&mut self, a: &Args) -> Result { - let doc = self.doc_to_copy(a)?; - let id = doc.id; - let pages = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..doc.info.pages.len()).collect(), - }; - // Column select (#740): only the text inside the rectangle, row by row. - let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32)); - let texts = self.page_texts(id, &pages)?; - let text = |t: &PageText| match rect { - Some(r) => t.column_text(&t.glyphs_in(r)), - None => t.plain_text(), - }; - let out: Vec = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect(); - Ok(json!({ "pages": out })) - } - - /// Matches in document order, extracting page text in growing batches and stopping as soon as - /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document - /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported - /// as bounded warnings; `complete` requires both no continuation and no failed pages. - fn text_find(&mut self, a: &Args) -> Result { - self.text_find_with(a, &mut extract_parallel) - } - - fn text_find_with(&mut self, a: &Args, extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts) -> Result { - let doc = self.doc(a)?; - let (id, n) = (doc.id, doc.info.pages.len()); - let query = a.str("query")?; - let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX); - let start = match a.opt_int("start_page")? { - None => 0, - Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0), - Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), - }; - let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); - let mut matches = Vec::new(); - let mut next = None; - let mut warnings = Vec::new(); - let mut warning_count = 0usize; - let (mut first, mut batch) = (start, FIND_FIRST_BATCH); - 'search: while first < n { - let end = first.saturating_add(batch).min(n); - let pages: Vec = (first..end).collect(); - let texts = self.page_text_results(id, &pages, extract)?; - for (p, text) in texts { - let text = match text { - Ok(text) => text, - Err(mut error) => { - warning_count += 1; - if warnings.len() < FIND_MAX_WARNINGS { - let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len())); - let truncated = end < error.len(); - error.truncate(end); - warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated })); - } - continue; - } - }; - let skip_here = if p == start { skip } else { 0 }; - // One extra match proves that the caller needs a continuation. Include skipped - // matches on the starting page when bounding the temporary per-page result. - let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); - for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { - if matches.len() == limit { - next = Some(json!({ "start_page": p + 1, "skip": k })); - break 'search; - } - matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); - } - } - first = end; - batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); - } - let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, - "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); - if let (Some(next), Some(o)) = (next, out.as_object_mut()) { - o.insert("next".into(), next); - } - Ok(out) - } - - fn command_list(&self, a: &Args) -> Result { - let active = match a.opt_int("doc")? { - Some(_) => Some(self.doc(a)?.id), - None => None, - }; - let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase(); - let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false); - let list: Vec = commands::COMMANDS - .iter() - .map(|c| { - json!({ - "id": c.id, - "label": commands::current_label(c, &self.session, active), - "menu": c.menu, - "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))), - "enabled": commands::is_enabled(c, &self.session, active), - "tool": tools::tool_for_command(c.id), - "params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema), - }) - }) - .collect(); - let list: Vec = list - .into_iter() - .filter(|c| { - (!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true)) - && (filter.is_empty() - || ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter)))) - }) - .collect(); - Ok(json!({ "commands": list })) - } - - // ---- paths ------------------------------------------------------------------------------- - - /// Resolve a user-supplied path, enforcing the root (if any). `for_write` allows a file that - /// does not exist yet (its nearest existing ancestor must be inside the root). - /// - /// Every path outside the root gets the same refusal, so a confined client can't learn what - /// exists out there (#136): `..` is resolved by name first, another network share or device - /// namespace is refused without touching it, and the deepest existing ancestor (links - /// followed) must be inside the root before anything about the rest is reported. - fn resolve(&self, path: &str, for_write: bool) -> Result { - let access = if for_write { file_access::Access::Write } else { file_access::Access::Read }; - self.resolve_for(path, access, for_write) - } - - /// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that - /// is only read may still be missing (a path checked up front, whose absence is reported per - /// file later). The automation roots, if any, get the last word. - fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result { - let target = self.resolve_in_root(path, may_be_missing)?; - self.check_roots(path, &target, access)?; - Ok(target) - } - - /// Hold `target`, which `path` named, to the automation read or write root. Always passes - /// without roots. - fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> { - match &self.roots { - Some(roots) => roots.check_path(path, target, access).map_err(failed), - None => Ok(()), - } - } - - fn resolve_in_root(&self, path: &str, for_write: bool) -> Result { - let p = Path::new(path); - let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; - let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); - let joined = lexical(&root.join(p)).ok_or_else(outside)?; - if foreign_share(&joined, root) { - return Err(outside()); - } - let mut existing = joined.as_path(); - let mut rest = Vec::new(); - while !existing.exists() { - if existing.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) { - return Err(outside()); // a broken link: going on would tell whether its target exists - } - match (existing.file_name(), existing.parent()) { - (Some(name), Some(parent)) => { - rest.push(name); - existing = parent; - } - _ => return Err(outside()), // not even a drive or share that exists - } - } - let mut real = existing.canonicalize().map_err(|_| outside())?; - if !real.starts_with(root) { - return Err(outside()); - } - if !rest.is_empty() && !for_write { - // Missing, below a folder inside the root: say where it was looked for, so a path - // written for another working directory can be corrected (#1890), and otherwise - // why, as the system reports it. `joined` is under the root, so naming it reveals - // nothing outside. - real = joined.canonicalize().map_err(|e| { - let (looked, root) = (joined.display(), root.display()); - match e.kind() { - std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")), - _ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")), - } - })?; - if !real.starts_with(root) { - return Err(outside()); // it appeared, as a link out, since the check above - } - return Ok(real); - } - real.extend(rest.iter().rev()); - Ok(real) - } -} - -/// `path` with `.` and `..` resolved by name, before the filesystem is consulted (Windows does -/// the same, and so does joining onto a canonical root there). `None` if a `..` would climb -/// above the start of the path. -fn lexical(path: &Path) -> Option { - use std::path::Component; - let mut parts: Vec = Vec::new(); - for c in path.components() { - match c { - Component::CurDir => {} - Component::ParentDir => match parts.last() { - Some(Component::Normal(_)) => { - parts.pop(); - } - _ => return None, - }, - other => parts.push(other), - } - } - Some(parts.iter().collect()) -} - -/// Whether `path` names another network share or device namespace than `root` (Windows -/// `\\host\share`, `\\?\UNC\…`, `\\.\…`, `\\?\…`). Those are refused by name: even checking -/// that one exists would contact the host. Drive letters are left to the normal check. -fn foreign_share(path: &Path, root: &Path) -> bool { - use std::path::{Component, Prefix}; - fn share(p: &Path) -> Option { - let Some(Component::Prefix(prefix)) = p.components().next() else { return None }; - let (kind, a, b) = match prefix.kind() { - Prefix::Disk(_) | Prefix::VerbatimDisk(_) => return None, - Prefix::UNC(host, share) | Prefix::VerbatimUNC(host, share) => ("unc", host, share), - Prefix::DeviceNS(name) => ("device", name, std::ffi::OsStr::new("")), - Prefix::Verbatim(name) => ("verbatim", name, std::ffi::OsStr::new("")), - }; - Some(format!("{kind}\\{}\\{}", a.to_string_lossy(), b.to_string_lossy()).to_lowercase()) - } - share(path).is_some_and(|s| share(root).as_ref() != Some(&s)) -} - -// ---- JSON helpers ---------------------------------------------------------------------------- - -fn parse_style_ranges(value: Option<&Value>) -> Result> { - let Some(value) = value else { return Ok(Vec::new()) }; - let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; - values - .iter() - .map(|v| { - let a = Args(v); - let bounds = a - .get("range") - .and_then(Value::as_array) - .filter(|v| v.len() == 2) - .ok_or_else(|| Args::wrong("range", "[start,end] UTF-8 byte offsets"))?; - let start = bounds - .first() - .and_then(Value::as_u64) - .and_then(|n| usize::try_from(n).ok()) - .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; - let end = bounds - .get(1) - .and_then(Value::as_u64) - .and_then(|n| usize::try_from(n).ok()) - .ok_or_else(|| Args::wrong("range", "nonnegative byte offsets"))?; - let family = a - .opt_str("font")? - .map(|f| match f { - "helvetica" => Ok(pdfcraft_engine::FontFamily::Helvetica), - "times" => Ok(pdfcraft_engine::FontFamily::Times), - "courier" => Ok(pdfcraft_engine::FontFamily::Courier), - _ => Err(Args::wrong("font", "helvetica, times or courier")), - }) - .transpose()? - .map(|f| -> Result<_> { Ok((f, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))) }) - .transpose()?; - Ok(pdfcraft_engine::RangeStyle { - range: start..end, - family, - resource: a.opt_str("font_resource")?.map(str::to_owned), - size: a.opt_num("size")?, - color: a.opt_str("color")?.map(comments::parse_color).transpose()?, - underline: a.opt_bool("underline")?, - char_spacing: a.opt_num("char_spacing")?, - scale: a.opt_num("scale")?, - }) - }) - .collect() -} - -/// Typed access to validated arguments. -struct Args<'a>(&'a Value); - -impl Args<'_> { - fn get(&self, key: &str) -> Option<&Value> { - self.0.get(key).filter(|v| !v.is_null()) - } - fn missing(key: &str) -> ToolError { - ToolError::InvalidArgs(format!("missing argument {key}")) - } - fn wrong(key: &str, what: &str) -> ToolError { - ToolError::InvalidArgs(format!("{key} must be {what}")) - } - fn str(&self, key: &str) -> Result<&str> { - self.opt_str(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_str(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_str().ok_or_else(|| Self::wrong(key, "a string"))).transpose() - } - fn int(&self, key: &str) -> Result { - self.opt_int(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_int(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_i64().ok_or_else(|| Self::wrong(key, "an integer"))).transpose() - } - fn opt_num(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| Self::wrong(key, "a number"))).transpose() - } - fn opt_bool(&self, key: &str) -> Result> { - self.get(key).map(|v| v.as_bool().ok_or_else(|| Self::wrong(key, "true or false"))).transpose() - } - fn ints(&self, key: &str) -> Result> { - self.opt_ints(key)?.ok_or_else(|| Self::missing(key)) - } - fn opt_ints(&self, key: &str) -> Result>> { - let Some(v) = self.get(key) else { return Ok(None) }; - let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; - arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) - } - /// A 1-based bookmark path → 0-based indices. - fn path(&self, key: &str) -> Result> { - let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?; - if p.is_empty() { - return Err(ToolError::InvalidArgs(format!("{key} must not be empty"))); - } - Ok(p) - } - fn opt_path(&self, key: &str) -> Result>> { - let Some(v) = self.opt_ints(key)? else { return Ok(None) }; - v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some) - } - fn strs(&self, key: &str) -> Result> { - let v = self.get(key).ok_or_else(|| Self::missing(key))?; - let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; - arr.iter().map(|x| x.as_str().ok_or_else(|| Self::wrong(key, "an array of strings"))).collect() - } -} - -/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the -/// next level down in `children`, and paths and pages are 1-based. -fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { - // The open rows, outermost first, with their depth and the children gathered so far. - let mut open: Vec<(usize, Value, Vec)> = Vec::new(); - let mut roots = Vec::new(); - for row in rows { - let depth = row.path.len().saturating_sub(1); - while open.last().is_some_and(|(d, _, _)| *d >= depth) { - close_row(&mut open, &mut roots); - } - let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); - let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); - if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { - fields["destination"] = destination; - } - open.push((depth, fields, Vec::new())); - } - while !open.is_empty() { - close_row(&mut open, &mut roots); - } - roots -} - -/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page -/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its -/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the -/// reader's position and zoom). `None` for a destination without a type. -pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { - use pdfcraft_render::DestView; - let points = |left: Option, top: Option| { - let [x, y] = page.dest_fraction(left, top, 0); - let round = |v: f32| f64::from((v * 100.0).round() / 100.0); - (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) - }; - Some(match view { - DestView::Top => return None, - DestView::Xyz { left, top, zoom } => { - let (x, y) = points(*left, *top); - json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) - } - DestView::Fit => json!({ "fit": "Fit" }), - DestView::FitH { top } => { - let (x, y) = points(None, *top); - json!({ "fit": "FitH", "left": x, "top": y }) - } - DestView::FitV { left } => { - let (x, y) = points(*left, None); - json!({ "fit": "FitV", "left": x, "top": y }) - } - DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), - }) -} - -/// Closes the innermost open row: it joins the row above it as a child, or the top level. -fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { - if let Some((_, mut fields, children)) = open.pop() { - fields["children"] = Value::Array(children); - match open.last_mut() { - Some((_, _, siblings)) => siblings.push(fields), - None => roots.push(fields), - } - } -} - -fn one_based(pages: &[i64]) -> Result> { - pages - .iter() - .map(|p| if *p >= 1 { Ok(*p as usize - 1) } else { Err(ToolError::InvalidArgs(format!("page numbers start at 1 (got {p})"))) }) - .collect() -} - -/// The document's security as the next save writes it (never includes passwords). -fn security(d: &Document) -> Value { - match d.security_summary() { - None => json!({ "protected": false }), - Some(s) => { - let p = s.permissions; - json!({ - "protected": true, - "method": s.method, - "pending": s.pending, - "printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" }, - "modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(), - "fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(), - }) - } - } -} - -/// What every tool that changes a document returns. -fn summary(d: &Document) -> Value { - json!({ - "doc": d.id.0, - "name": d.name, - "path": d.path, - "pages": d.info.pages.len(), - "dirty": d.dirty, - "editable": d.read_only_reason.is_none(), - "read_only_reason": d.read_only_reason, - "encrypted": d.info.encrypted, - "undo": d.can_undo(), - "redo": d.can_redo(), - }) -} - -fn info(d: &Document) -> Value { - let i = &d.info; - let page1 = |p: usize| p + 1; - let mut security = security(d); - if let Some(s) = d.security_summary() { - security["opened_as_owner"] = json!(s.owner); - } - json!({ - "document": summary(d), - "pdf_version": i.pdf_version, - "file_size": i.file_size, - "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, - // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the - // ones the spec names, set and cleared with doc_set_info. - "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), - "creator": i.creator, "producer": i.producer, - "tagged": i.tagged, - "has_javascript": i.has_javascript, - // XFA forms: "static" (the PDF's own fields work; the XFA data is ignored) or "dynamic" - // (laid out from the template by PdfCraft, see xfa_layout; placeholder pages when that failed). - "xfa": i.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), - "xfa_layout": d.xfa.as_ref().map(|x| json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })), - // A script of the form ran too long and was abandoned: its scripts no longer run in this - // document (calculations, buttons) until it is opened again. - "xfa_scripts_off": d.xfa_scripts_off(), - // What was rewritten from, or could not be written to, the XFA data. - "xfa_warnings": d.xfa_warnings, - "security": security, - "pages": i.pages.iter().enumerate().map(|(n, p)| json!({ - "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, - })).collect::>(), - "outline": outline(&i.outline), - "outline_more": i.outline_more, - // Rectangles use the tools' convention (top-left of the displayed page, like - // comment_list and link_list), not raw PDF user space, so they can be fed back to - // geometry-taking tools (#129). - "annotations": i.annotations.iter().map(|a| json!({ - "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, - "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, "rect": view_rect(i, a.page, a.rect), - })).collect::>(), - "fields": i.fields.iter().map(|f| json!({ - "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), - "tooltip": f.tooltip, "has_actions": f.has_actions, - })).collect::>(), - "links": i.links.iter().map(|l| json!({ - "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), - "target": match &l.target { - pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), - pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), - pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ - "layers": changes.iter().map(|(op, ocg)| json!({ - "layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()), - "state": match op { - pdfcraft_render::LayerOp::On => "on", - pdfcraft_render::LayerOp::Off => "off", - pdfcraft_render::LayerOp::Toggle => "toggle", - }, - })).collect::>(), - "preserve_rb": preserve_rb, - }), - pdfcraft_render::LinkTarget::RemoteGoTo { file, destination, new_window } => json!({ - "remote_document": file, - "destination": match destination { - pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }), - pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }), - pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }), - }, - "new_window": new_window, - }), - pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), - }, - })).collect::>(), - "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>(), - // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with - // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. - "transitions": i.transitions.iter().map(|t| json!({ - "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, - "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, - "display": t.display, - })).collect::>(), - "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), - // `substitute` names the face standing in for a program the document does not embed, and - // `substitution` says why. Both are null for an embedded font: nothing stands in. A null - // `substitute` with a non-null `substitution` means no face was available at all, so - // nothing is drawn for that font -- which is the answer to "why is my text missing?". - "fonts": i.fonts.iter().map(|f| json!({ - "name": f.name, "kind": f.kind, "embedded": f.embedded, "subset": f.subset, "encoding": f.encoding, - "substitute": f.substitute, "substitution": f.substitution, - })).collect::>(), - "warnings": i.warnings, - "repairs": d.repair_log(), - }) -} - -/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the -/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports -/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page). -/// Public so `pdfcraft-cli` reports the same rectangles as the tools. -pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] { - i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect)) -} - -fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { - Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) -} - -fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { - let mut rgba = premultiplied.to_vec(); - for px in rgba.as_chunks_mut::<4>().0 { - let a = u32::from(px[3]); - if a != 0 && a != 255 { - for c in &mut px[..3] { - *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; - } - } - } - let mut out = Vec::new(); - let mut enc = png::Encoder::new(&mut out, width, height); - enc.set_color(png::ColorType::Rgba); - enc.set_depth(png::BitDepth::Eight); - let mut w = enc.write_header().map_err(failed)?; - w.write_image_data(&rgba).map_err(failed)?; - w.finish().map_err(failed)?; - Ok(out) -} - -/// Extract the text of `pages` (0-based) with one renderer per worker thread. -fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { - let extract = |r: &mut PageRenderer, p: usize| { - let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); - match out.error { - Some(e) => Err(e), - None => Ok(out.text.unwrap_or_default()), - } - }; - let config = RenderConfig { password, ..Default::default() }; - let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).min(8) }; - // Small jobs aren't worth a second parse of the document. - if workers == 1 || pages.len() < 8 { - let mut r = PageRenderer::new(bytes.clone(), config); - return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect(); - } - let next = std::sync::atomic::AtomicUsize::new(0); - let mut out: PageTexts = std::thread::scope(|s| { - let handles: Vec<_> = (0..workers) - .map(|_| { - s.spawn(|| { - let mut r = PageRenderer::new(bytes.clone(), config.clone()); - let mut done = Vec::new(); - loop { - let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let Some(p) = pages.get(i) else { break }; - done.push((*p, extract(&mut r, *p))); - } - done - }) - }) - .collect(); - handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect() - }); - out.sort_by_key(|(p, _)| *p); - out -} - -/// The file `name` inside `dir`, where `name` comes from a document or an argument: separators, -/// colons (a Windows drive or stream) and control characters become `_`, and a name of only dots -/// gets a leading `_`, so it is one plain file name and can't lead out of `dir`. -fn child(dir: &Path, name: &str) -> PathBuf { - let mut safe: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect(); - if safe.chars().all(|c| c == '.') { - safe.insert(0, '_'); - } - dir.join(safe) -} - -/// `.pdf` in `dir`, or ` (2).pdf` and so on when that name is taken. -fn unused(dir: &Path, stem: &str) -> PathBuf { - let first = child(dir, &format!("{stem}.pdf")); - if !first.exists() { - return first; - } - (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) -} - -/// Write and sync a temporary file in the same directory, then rename over the target. -/// This flushes staged contents; it does not promise parent-directory durability after a -/// power loss. Filesystems that refuse sync cause the save to fail before publication. -fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { - write_atomic_with(path, bytes, staging_suffixes()) -} - -/// [`write_atomic`], trying the staging names that `suffixes` give. -fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator) -> Result<()> { - write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all) -} - -fn write_atomic_synced( - path: &Path, - bytes: &[u8], - suffixes: impl IntoIterator, - sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, -) -> Result<()> { - // A folder can't be replaced, and staging beside it could land outside the root: "." names - // the root itself, whose parent isn't ours to write in. - if path.is_dir() { - return Err(failed(format!("{}: is a folder, not a file", path.display()))); - } - let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; - std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - // The writer itself is the platform crate's, shared with the desktop app and the CLI - // (#2166): the staging file is written, flushed (delayed writeback failures surface before - // the destination is replaced or a document marked clean), closed and renamed; a failure at - // any step removes it and leaves the destination as it was. - pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync) - .map_err(|e| failed(format!("{}: {e}", path.display()))) } #[cfg(test)] -mod tests { - use super::*; - use pdfcraft_platform::staging::STAGING_ATTEMPTS; - - /// `n` pages, page i showing "Page i". - fn numbered_pages(n: usize) -> Vec { - let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; - let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); - objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" "))); - objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); - for i in 0..n { - objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); - let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); - objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); - } - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); - out - } - - fn json(a: &mut Automation, tool: &str, args: Value) -> Value { - match a.call(tool, &args).map(|mut c| c.remove(0)) { - Ok(Content::Json(v)) => v, - other => panic!("{tool} {args}: {other:?}"), - } - } - - fn search_document(a: &mut Automation, pages: usize) -> DocId { - a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap() - } - - #[test] - fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() { - let mut a = Automation::new(); - a.texts = TextCache::new(16 * 1024); - let first = search_document(&mut a, 200); - let second = search_document(&mut a, 200); - for id in [first, second] { - let result = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent" })); - assert_eq!(result["complete"], true); - assert!(a.texts.bytes() <= 16 * 1024); - assert!(a.texts.len() < 200, "an exhaustive search must not retain every page"); - let found = json(&mut a, "text_find", json!({ "doc": id.0, "query": "Page", "limit": 1000 })); - assert_eq!(found["count"], 200, "eviction inside a batch must not discard its matches"); - eprintln!("200 pages searched: {} resident pages, {} estimated bytes (budget 16384)", a.texts.len(), a.texts.bytes()); - } - assert!(a.texts.get(first, 0).is_none()); - let text = a.page_texts(first, &[0]).unwrap().remove(0); - assert_eq!(text.plain_text(), "Page 1"); - let again = a.page_texts(first, &[0]).unwrap().remove(0); - assert!(Arc::ptr_eq(&text, &again), "cache hits reuse glyph storage"); - assert!(a.texts.bytes() <= 16 * 1024); - json(&mut a, "page_delete", json!({ "doc": first.0, "pages": [1] })); - let edited = a.page_texts(first, &[0]).unwrap().remove(0); - assert_eq!(edited.plain_text(), "Page 2"); - assert!(!Arc::ptr_eq(&text, &edited)); - json(&mut a, "doc_close", json!({ "doc": first.0, "discard_changes": true })); - json(&mut a, "doc_close", json!({ "doc": second.0 })); - assert_eq!((a.texts.bytes(), a.texts.len()), (0, 0)); - } - - #[test] - fn strict_text_collection_refuses_partial_results_but_keeps_successful_pages() { - let mut a = Automation::new(); - let id = search_document(&mut a, 3); - let error = a.page_texts(id, &[0, 3, 1]).unwrap_err(); - assert!(error.to_string().contains("page 4: page is out of range")); - assert!(a.texts.get(id, 0).is_some()); - assert!(a.texts.get(id, 1).is_some(), "successful extraction after the failed entry is retained"); - let results = a.page_text_results(id, &[2], &mut |_, _, _| vec![]).unwrap(); - assert!(results[0].1.is_err(), "a missing worker result must not become an empty page"); - } - - #[test] - fn search_keeps_matches_around_failed_pages_and_continuations() { - let mut a = Automation::new(); - let id = search_document(&mut a, 16); - let mut extract = |bytes: &Arc>, password, pages: &[usize]| { - extract_parallel(bytes, password, pages) - .into_iter() - .map(|(p, result)| (p, if [2, 6].contains(&p) { Err(format!("synthetic page {} failure", p + 1)) } else { result })) - .collect() - }; - let all = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut extract).unwrap(); - assert_eq!(all["count"], 14); - assert_eq!(all["complete"], false); - assert_eq!(all["warning_count"], 2); - assert_eq!(all["warnings"][0]["page"], 3); - assert_eq!(all["warnings"][1]["page"], 7); - assert!(all.get("next").is_none()); - assert_eq!(all["matches"][13]["page"], 16); - assert!(a.texts.get(id, 7).is_some(), "success after a failure in the same batch is cached"); - let mut args = json!({ "doc": id.0, "query": "Page", "limit": 4 }); - let mut matches = Vec::new(); - let mut warned = Vec::new(); - loop { - let part = a.text_find_with(&Args(&args), &mut extract).unwrap(); - matches.extend(part["matches"].as_array().unwrap().iter().cloned()); - warned.extend(part["warnings"].as_array().unwrap().iter().map(|w| w["page"].clone())); - if !part["warnings"].as_array().unwrap().is_empty() { - assert_eq!(part["complete"], false); - } - let Some(next) = part.get("next") else { break }; - args["start_page"] = next["start_page"].clone(); - args["skip"] = next["skip"].clone(); - } - assert_eq!(matches, *all["matches"].as_array().unwrap(), "no readable match is lost or repeated"); - assert_eq!(warned, vec![json!(3), json!(7)]); - } - - #[test] - fn search_bounds_warning_volume_and_does_not_reach_distant_failures() { - let mut a = Automation::new(); - let id = search_document(&mut a, 200); - let first = a - .text_find_with(&Args(&json!({ "doc": id.0, "query": "Page", "limit": 1 })), &mut |bytes, password, pages| { - assert!(pages.iter().all(|p| *p < FIND_FIRST_BATCH), "a bounded search must not reach the bad page at the end"); - extract_parallel(bytes, password, pages) - }) - .unwrap(); - assert_eq!(first["count"], 1); - assert_eq!(first["warning_count"], 0); - assert_eq!(first["next"], json!({ "start_page": 2, "skip": 0 })); - a.texts.remove(&id); - let failures = a - .text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut |_, _, pages| { - pages.iter().map(|p| (*p, Err("界".repeat(2000)))).collect() - }) - .unwrap(); - assert_eq!(failures["count"], 0); - assert_eq!(failures["complete"], false); - assert!(failures.get("next").is_none()); - assert_eq!(failures["warning_count"], 200); - assert_eq!(failures["warnings_truncated"], true); - let warnings = failures["warnings"].as_array().unwrap(); - assert_eq!(warnings.len(), FIND_MAX_WARNINGS); - assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true)); - } - - #[test] - fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() { - let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("long.pdf"), numbered_pages(200)).unwrap(); - let mut a = Automation::new().with_root(&dir).unwrap(); - let doc = json(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].clone(); - let extracted = |a: &Automation| a.texts.len(); - - // #971: the first match is on page 1 and the next on page 10, so the rest of the 200 pages - // aren't extracted or cached. - let first = json(&mut a, "text_find", json!({ "doc": doc, "query": "Page 1", "limit": 1 })); - assert_eq!(first["count"], 1); - assert_eq!(first["matches"][0]["page"], 1); - assert_eq!(first["complete"], false, "{first}"); - assert_eq!(first["next"], json!({ "start_page": 10, "skip": 0 })); - assert!(extracted(&a) <= 3 * FIND_FIRST_BATCH, "{} pages extracted for one match", extracted(&a)); - - // Following `next` returns every match exactly once, in order, and then says it's complete. - // "1" appears twice on pages like 11 and 101, so continuing has to skip within a page. - let all = json(&mut a, "text_find", json!({ "doc": doc, "query": "1", "limit": 10_000 })); - assert_eq!(all["complete"], true); - assert!(all.get("next").is_none()); - let mut pieced = Vec::new(); - let mut args = json!({ "doc": doc, "query": "1", "limit": 3 }); - loop { - let found = json(&mut a, "text_find", args.clone()); - pieced.extend(found["matches"].as_array().unwrap().iter().map(|m| (m["page"].clone(), m["rects"].clone()))); - if found["complete"] == true { - break; - } - args["start_page"] = found["next"]["start_page"].clone(); - args["skip"] = found["next"]["skip"].clone(); - } - let expected: Vec<_> = all["matches"].as_array().unwrap().iter().map(|m| (m["page"].clone(), m["rects"].clone())).collect(); - assert_eq!(all["count"], expected.len()); - assert_eq!(pieced, expected); - - // A search that runs to the end below its limit is complete; a start page past the end is refused. - let few = json(&mut a, "text_find", json!({ "doc": doc, "query": "Page 200", "start_page": 150 })); - assert_eq!((few["count"].clone(), few["complete"].clone()), (json!(1), json!(true))); - assert!(a.call("text_find", &json!({ "doc": doc, "query": "x", "start_page": 201 })).is_err()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() { - let base = std::env::temp_dir(); - let l = |rel: &str| lexical(&base.join(rel)); - assert_eq!(l("a/./b/../c"), Some(base.join("a").join("c"))); - assert_eq!(l("missing/../../x"), lexical(&base.join("..").join("x"))); - assert_eq!(l("a/.."), Some(lexical(&base).unwrap())); - let deep = "../".repeat(base.components().count() + 1); - assert_eq!(lexical(&base.join(&deep)), None); - assert_eq!(lexical(Path::new("..")), None); - } - - #[test] - fn names_from_documents_become_one_plain_file_name() { - let dir = std::env::temp_dir().join("out"); - for name in ["../../x.png", "/abs/x.png", r"..\..\x.png", "C:x.png", r"\\host\share\x.png", "a:stream", "..", ".", "", "tab\there", "ok.png"] - { - let p = child(&dir, name); - assert_eq!(p.parent(), Some(dir.as_path()), "{name:?} -> {}", p.display()); - assert_eq!(p.components().count(), dir.components().count() + 1, "{name:?} -> {}", p.display()); - } - assert_eq!(child(&dir, "ok.png"), dir.join("ok.png")); - assert_eq!(child(&dir, "../x.png"), dir.join(".._x.png")); - } - - #[cfg(windows)] - #[test] - fn other_shares_and_device_paths_are_refused_by_name() { - let local = Path::new(r"\\?\C:\work\root"); - for p in [ - r"\\host\share\x.pdf", - "//host/share/x.pdf", - r"\/host/share/x.pdf", - r"\\?\UNC\host\share\x.pdf", - r"\\.\pipe\x", - r"\\.\C:\work\root\x.pdf", - r"\\?\GLOBALROOT\Device\x", - ] { - assert!(foreign_share(Path::new(p), local), "{p}"); - } - for p in [r"C:\work\root\x.pdf", r"c:\elsewhere\x.pdf", r"\\?\C:\work\root\x.pdf", r"D:\x.pdf", r"C:x.pdf"] { - assert!(!foreign_share(Path::new(p), local), "{p}"); - } - // A root on a share accepts that share, however it is spelled, and nothing else. - let shared = Path::new(r"\\?\UNC\Server\Docs\root"); - assert!(!foreign_share(Path::new(r"\\server\docs\root\x.pdf"), shared)); - assert!(!foreign_share(Path::new(r"\\?\UNC\SERVER\DOCS\x.pdf"), shared)); - assert!(foreign_share(Path::new(r"\\server\other\x.pdf"), shared)); - assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared)); - } - - /// A fresh, empty folder for one staging test. - fn staging_dir(test: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-staging-{}-{test}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn staged(dir: &Path, suffix: u64) -> PathBuf { - dir.join(format!(".out.pdf.{suffix:016x}.pdfcraft-tmp")) - } - - fn read(p: &Path) -> String { - std::fs::read_to_string(p).unwrap() - } - - /// A symbolic link to a file, where the system allows one (Windows needs Developer Mode or an - /// administrator for it). - fn file_symlink(target: &Path, link: &Path) -> std::io::Result<()> { - #[cfg(unix)] - { - std::os::unix::fs::symlink(target, link) - } - #[cfg(windows)] - { - std::os::windows::fs::symlink_file(target, link) - } - #[cfg(not(any(unix, windows)))] - { - Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display()))) - } - } - - #[test] - fn staging_never_writes_through_a_file_planted_at_its_name() { - let dir = staging_dir("planted"); - // A file elsewhere that a planted link points at. - let outside = dir.join("outside.txt"); - std::fs::write(&outside, "PRECIOUS").unwrap(); - let target = dir.join("out.pdf"); - // A hard link needs no privileges on any system, and writing to it writes to `outside`. - std::fs::hard_link(&outside, staged(&dir, 1)).unwrap(); - std::fs::write(staged(&dir, 2), "PLANTED").unwrap(); - write_atomic_with(&target, b"NEW", [1, 2, 3]).unwrap(); - assert_eq!(read(&target), "NEW"); - assert_eq!(read(&outside), "PRECIOUS"); - assert_eq!(read(&staged(&dir, 1)), "PRECIOUS"); - assert_eq!(read(&staged(&dir, 2)), "PLANTED"); - assert!(!staged(&dir, 3).exists(), "the staging file was renamed into place"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn staging_never_writes_through_a_symbolic_link_planted_at_its_name() { - let dir = staging_dir("symlink"); - let outside = dir.join("outside.txt"); - std::fs::write(&outside, "PRECIOUS").unwrap(); - let target = dir.join("out.pdf"); - if let Err(e) = file_symlink(&outside, &staged(&dir, 1)) { - eprintln!("symbolic links not checked: {e}"); - return; - } - write_atomic_with(&target, b"NEW", [1, 2]).unwrap(); - assert_eq!(read(&target), "NEW"); - assert_eq!(read(&outside), "PRECIOUS"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn staging_never_creates_a_file_through_a_dangling_link_at_its_name() { - let dir = staging_dir("dangling"); - let unborn = dir.join("created-through-a-link.txt"); - let target = dir.join("out.pdf"); - if let Err(e) = file_symlink(&unborn, &staged(&dir, 1)) { - eprintln!("symbolic links not checked: {e}"); - return; - } - write_atomic_with(&target, b"NEW", [1, 2]).unwrap(); - assert_eq!(read(&target), "NEW"); - assert!(!unborn.exists(), "nothing was created through the dangling link"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn staging_gives_up_rather_than_reuse_a_taken_name() { - let dir = staging_dir("taken"); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - for s in 1..=STAGING_ATTEMPTS as u64 { - std::fs::write(staged(&dir, s), "PLANTED").unwrap(); - } - assert!(write_atomic_with(&target, b"NEW", 1..).is_err()); - assert_eq!(read(&target), "OLD"); - for s in 1..=STAGING_ATTEMPTS as u64 { - assert_eq!(read(&staged(&dir, s)), "PLANTED"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_failed_staging_sync_keeps_the_destination_and_cleans_up() { - for exists in [false, true] { - let dir = staging_dir(if exists { "sync-existing" } else { "sync-new" }); - let target = dir.join("out.pdf"); - if exists { - std::fs::write(&target, "OLD").unwrap(); - } - let result = write_atomic_synced(&target, b"NEW", [1], |_| { - assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW", "sync follows writing"); - if exists { - assert_eq!(read(&target), "OLD", "sync precedes publication"); - } - Err(std::io::Error::other("injected storage sync failure")) - }); - assert!(result.is_err(), "sync failure must refuse publication"); - assert!(result.unwrap_err().to_string().contains("injected storage sync failure")); - if exists { - assert_eq!(read(&target), "OLD"); - assert_eq!(listing(&dir), ["out.pdf"]); - } else { - assert!(!target.exists()); - assert!(listing(&dir).is_empty()); - } - std::fs::remove_dir_all(dir).unwrap(); - } - } - - #[test] - fn invalid_save_bytes_never_reach_publication_or_mark_saved() { - let dir = staging_dir("invalid-save-bytes"); - let target = dir.join("out.pdf"); - let mut a = Automation::new().with_root(&dir).unwrap(); - let id = search_document(&mut a, 2); - json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"})); - let old = std::fs::read(&target).unwrap(); - json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); - let before = summary(a.session.get(id).unwrap()); - // Exercise both parse failure and a valid PDF that loses a page. This does not - // depend on a malformed input remaining unrepaired by future reader improvements. - for bytes in [b"not a PDF".to_vec(), numbered_pages(1)] { - let mut published = false; - let result = a.publish_save(id, &target, Arc::new(bytes), false, |_, _| { - published = true; - Ok(()) - }); - assert!(result.is_err(), "invalid output must be refused"); - assert!(result.unwrap_err().to_string().contains("could not be reopened")); - assert!(!published, "validation must precede the writer"); - assert_eq!(summary(a.session.get(id).unwrap()), before, "failed validation must preserve document state"); - assert_eq!(std::fs::read(&target).unwrap(), old); - assert_eq!(listing(&dir), ["out.pdf"]); - } - json(&mut a, "doc_save", json!({"doc": id.0})); - assert!(!a.session.get(id).unwrap().dirty); - assert_ne!(std::fs::read(&target).unwrap(), old); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() { - let dir = staging_dir("sync-dirty"); - let target = dir.join("out.pdf"); - let mut a = Automation::new().with_root(&dir).unwrap(); - let id = search_document(&mut a, 2); - json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"})); - let old = std::fs::read(&target).unwrap(); - json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); - let args = json!({"doc": id.0}); - let before = summary(a.doc(&Args(&args)).unwrap()); - let result = a.doc_save_with(&Args(&args), |path, bytes| { - write_atomic_synced(path, bytes, [1], |_| Err(std::io::Error::other("injected storage sync failure"))) - }); - assert!(result.is_err()); - let doc = a.doc(&Args(&args)).unwrap(); - assert!(doc.dirty); - assert_eq!(summary(doc), before, "failed save must not reset history or saved state"); - assert_eq!(std::fs::read(&target).unwrap(), old); - assert_eq!(listing(&dir), ["out.pdf"]); - json(&mut a, "doc_save", args.clone()); - assert!(!a.doc(&Args(&args)).unwrap().dirty); - assert_ne!(std::fs::read(&target).unwrap(), old); - std::fs::remove_dir_all(dir).unwrap(); - } - - #[test] - fn successful_staging_sync_precedes_publication() { - let dir = staging_dir("sync-success"); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - let mut called = false; - write_atomic_synced(&target, b"NEW", [1], |file| { - called = true; - assert_eq!(read(&target), "OLD"); - assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW"); - file.sync_all() - }) - .unwrap(); - assert!(called); - assert_eq!(read(&target), "NEW"); - assert_eq!(listing(&dir), ["out.pdf"]); - std::fs::remove_dir_all(dir).unwrap(); - } - - /// The names in a folder: what a test can see was left behind. - fn listing(dir: &Path) -> Vec { - let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); - names.sort(); - names - } - - #[test] - fn staging_names_differ_between_saves_and_fit_long_names() { - let suffixes: std::collections::HashSet = staging_suffixes().take(64).collect(); - assert_eq!(suffixes.len(), 64); - assert_ne!(staging_suffixes().next(), staging_suffixes().next(), "each save draws new names"); - // 60 four-byte characters: a 244-byte name, within every system's limit. Its staging name - // must be too (on Linux the whole name in it would be 275 bytes). - let dir = staging_dir("long"); - let name = format!("{}.pdf", "\u{1F600}".repeat(60)); - write_atomic(&dir.join(&name), b"NEW").unwrap(); - assert_eq!(read(&dir.join(&name)), "NEW"); - assert_eq!(listing(&dir), [name], "no staging file is left behind"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_folder_at_the_staging_name_is_left_alone() { - let dir = staging_dir("folder"); - std::fs::create_dir(staged(&dir, 1)).unwrap(); - std::fs::write(staged(&dir, 1).join("inside.txt"), "PLANTED").unwrap(); - let target = dir.join("out.pdf"); - write_atomic_with(&target, b"NEW", [1, 2]).unwrap(); - assert_eq!(read(&target), "NEW"); - assert_eq!(read(&staged(&dir, 1).join("inside.txt")), "PLANTED"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Windows refuses to replace a read-only file, so the rename fails: the staging file must not - /// be left behind. - #[cfg(windows)] - #[test] - fn a_failed_rename_removes_the_staging_file() { - let dir = staging_dir("readonly"); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - let mut perms = std::fs::metadata(&target).unwrap().permissions(); - perms.set_readonly(true); - std::fs::set_permissions(&target, perms.clone()).unwrap(); - let e = write_atomic_with(&target, b"NEW", [7]).unwrap_err(); - assert!(e.to_string().contains(&target.display().to_string()), "the rename failed, not the staging: {e}"); - assert_eq!(read(&target), "OLD"); - assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); - #[allow(clippy::permissions_set_readonly_false)] - perms.set_readonly(false); - std::fs::set_permissions(&target, perms).unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A recovered document, or a new one, rotated and then undone still has never been saved: - /// `doc_close` refuses it unless told to discard it (#2346 review). - #[test] - fn closing_a_recovered_or_new_document_after_undo_is_refused() { - let mut a = Automation::new(); - let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap(); - a.session.mark_recovered(recovered, Some("Report.pdf".into())); - let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap(); - for id in [recovered, new] { - json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); - json(&mut a, "edit_undo", json!({"doc": id.0})); - let refused = a.call("doc_close", &json!({"doc": id.0})); - assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}"); - assert!(a.session.get(id).is_some(), "still open"); - json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true})); - assert!(a.session.get(id).is_none()); - } - } -} +mod tests; diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index cd0fb9da09..41fda9b7db 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -70,9 +70,8 @@ impl Automation { .links() .iter() .map(|l| { - let p = &doc.info.pages[l.page]; - let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); - let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0); + // The link's page may be one the viewer could not read (#2660): rect unchanged then. + let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32)); let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible }); // Where an in-document link goes, as the viewer resolves it: named destinations // included, with the position on the page. @@ -118,7 +117,7 @@ impl Automation { .iter() .find(|link| link.page == page && link.index == index) .ok_or_else(|| failed(format!("page {page_number} has no link {index_number} (see link_list)")))?; - let LinkTarget::RemoteGoTo { file, destination, new_window } = &link.target else { + let LinkTarget::RemoteGoTo { file, destination, new_window, .. } = &link.target else { return Err(bad("the selected link is not a supported local PDF GoToR link")); }; (source.path.clone(), file.clone(), destination.clone(), *new_window) diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index f5fda5be97..84cc21b258 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -25,6 +25,10 @@ const PARSE_ERROR: i64 = -32700; const INVALID_REQUEST: i64 = -32600; const METHOD_NOT_FOUND: i64 = -32601; const INVALID_PARAMS: i64 = -32602; +const INTERNAL_ERROR: i64 = -32603; + +/// Longest JSON-RPC request line [`McpServer::serve`] buffers into memory (16 MiB). +pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; const INSTRUCTIONS: &str = "PdfCraft edits PDFs. Open a file with doc_open to get a document id, then inspect \ (doc_info, text_extract, text_find, page_render) or edit it (page_*, doc_set_info). Edits are undoable \ @@ -83,18 +87,36 @@ impl McpServer { } /// Serve until `input` reaches end of file. - pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - for line in input.lines() { - let line = line?; - if line.trim().is_empty() { - continue; + pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + let mut buf = Vec::new(); + loop { + match read_line_limited(&mut input, &mut buf, MAX_LINE_BYTES)? { + LineRead::End => return Ok(()), + LineRead::TooLong => { + drain_line(&mut input)?; + buf.clear(); + buf.shrink_to(64 * 1024); + let reply = error(Value::Null, INVALID_REQUEST, &format!("request line exceeds {MAX_LINE_BYTES} bytes")); + writeln!(output, "{reply}")?; + output.flush()?; + continue; + } + LineRead::Line => {} } - if let Some(reply) = self.handle_line(&line) { + let reply = match std::str::from_utf8(&buf) { + Ok(line) if line.trim().is_empty() => None, + Ok(line) => self.handle_line(line), + Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("parse error: {e}")).to_string()), + }; + if buf.capacity() > 64 * 1024 { + buf.clear(); + buf.shrink_to(64 * 1024); + } + if let Some(reply) = reply { writeln!(output, "{reply}")?; output.flush()?; } } - Ok(()) } /// Handle one JSON-RPC message; returns the serialized reply, if one is due. @@ -116,7 +138,7 @@ impl McpServer { }; let params = obj.get("params").cloned().unwrap_or(Value::Null); let id = id?; // notifications/initialized, notifications/cancelled, …: nothing to answer - let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, ¶ms)) } else { self.dispatch(method, ¶ms) }; + let result = guarded_dispatch(method, || self.dispatch(method, ¶ms)); Some(match result { Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), Err((code, message)) => error(id, code, &message), @@ -323,11 +345,67 @@ fn unwrap_tool_call(args: &Value) -> Result<(String, Value), String> { Ok((name.to_owned(), inner)) } +/// What [`read_line_limited`] found. +#[derive(Debug, PartialEq, Eq)] +enum LineRead { + /// A line, now in the buffer (newline and a trailing `\r` stripped). + Line, + /// End of stream. + End, + /// The line is longer than the limit; at most `max + 1` bytes of it were read. + TooLong, +} + +/// Read one `\n`-terminated line of at most `max` bytes into `buf`. +fn read_line_limited(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> std::io::Result { + use std::io::Read as _; + buf.clear(); + // `take` bounds what is buffered for one line: never more than `max + 1` bytes. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + if reader.take(limit).read_until(b'\n', buf)? == 0 { + return Ok(LineRead::End); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + return Ok(LineRead::Line); + } + // No newline: either the limit was passed, or this is a last line before end of stream. + Ok(if buf.len() > max { LineRead::TooLong } else { LineRead::Line }) +} + +/// Discard the rest of an overlong line up to `\n` or end of stream without buffering it. +fn drain_line(reader: &mut impl BufRead) -> std::io::Result<()> { + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + return Ok(()); + } + if let Some(pos) = chunk.iter().position(|&b| b == b'\n') { + reader.consume(pos + 1); + return Ok(()); + } + let len = chunk.len(); + reader.consume(len); + } +} + /// Last-resort boundary shared by every tools/call route, including compact dispatch. fn guarded_tool(f: impl FnOnce() -> Result) -> Result { pdfcraft_engine::guard(f).unwrap_or_else(|message| Ok(tool_error(&format!("internal error: {message}")))) } +/// Last-resort boundary for every JSON-RPC method: tool calls report panics in the tool result +/// (`isError: true`), while resource and protocol methods report a JSON-RPC internal error. +fn guarded_dispatch(method: &str, f: impl FnOnce() -> Result) -> Result { + if method == "tools/call" { + return guarded_tool(f); + } + pdfcraft_engine::guard(f).unwrap_or_else(|message| Err((INTERNAL_ERROR, format!("internal error: {message}")))) +} + fn tool_content(name: &str, content: Vec) -> Value { let failed = matches!(name, "command_batch" | "doc_batch") && content.iter().any(|c| match c { @@ -509,7 +587,12 @@ fn call_result(content: Vec) -> Value { #[cfg(test)] mod tests { - use super::{Resource, first_sentence, parse_uri}; + use std::io::{BufReader, Read as _}; + + use serde_json::{Value, json}; + + use super::{McpServer, Resource, first_sentence, parse_uri}; + use crate::Automation; #[test] fn conventions_panic_is_a_tool_error() { @@ -520,6 +603,59 @@ mod tests { assert_eq!(next["ok"], true); } + #[test] + fn guarded_dispatch_catches_resource_and_protocol_panics() { + for method in ["resources/read", "resources/list", "resources/templates/list", "initialize"] { + let (code, msg) = super::guarded_dispatch(method, || panic!("synthetic {method} failure")).unwrap_err(); + assert_eq!(code, -32603, "{method}"); + assert!(msg.contains(&format!("synthetic {method} failure")), "{msg}"); + } + let ok = super::guarded_dispatch("resources/list", || Ok(json!({"resources": []}))).unwrap(); + assert_eq!(ok["resources"], json!([])); + } + + #[test] + fn read_line_limited_and_drain_line_respect_boundaries() { + use super::{LineRead, drain_line, read_line_limited}; + let mut buf = Vec::new(); + let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc"; + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"bb", "CRLF is stripped"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"cc", "a last line without a newline is still read"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::End); + + let mut overlong: &[u8] = b"0123456789abcdef\nafter\n"; + assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::TooLong); + assert_eq!(buf.len(), 11, "at most max + 1 bytes are buffered"); + drain_line(&mut overlong).unwrap(); + assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"after", "the next line after an overlong line is read cleanly"); + } + + #[test] + fn serve_answers_non_utf8_and_overlong_lines_and_keeps_serving() { + let init = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } } + }) + .to_string(); + let tail = format!("\n{init}\n"); + let stream = (&b"\xff\n"[..]).chain(std::io::repeat(b'a').take(64 * 1024 * 1024)).chain(tail.as_bytes()); + let mut out = Vec::new(); + McpServer::new(Automation::new()).serve(BufReader::new(stream), &mut out).unwrap(); + let replies: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 3, "{replies:?}"); + assert_eq!(replies[0]["error"]["code"], -32700); + assert_eq!(replies[1]["error"]["code"], -32600); + assert_eq!(replies[2]["id"], 1); + assert_eq!(replies[2]["result"]["serverInfo"]["name"], "pdfcraft"); + } + #[test] fn first_sentence_skips_abbreviations() { assert_eq!(first_sentence("Rotate pages. Undoable."), "Rotate pages."); diff --git a/crates/automation/src/measure.rs b/crates/automation/src/measure.rs index 13500ff97d..ac3d2d95f1 100644 --- a/crates/automation/src/measure.rs +++ b/crates/automation/src/measure.rs @@ -2,7 +2,7 @@ use crate::{Args, Automation, Result, ToolError, failed}; use pdfcraft_engine::{ Edit, Style, - measure::{self, Kind, NewMeasurement, Scale}, + measure::{self, Kind, NewMeasurement, PageUnit, Scale}, }; use serde_json::{Value, json}; @@ -78,16 +78,30 @@ impl Automation { let info = self.doc(a)?.info.pages.get(page).ok_or_else(|| failed("no such page"))?.clone(); let precision = u8::try_from(a.opt_int("precision")?.unwrap_or(2)).map_err(|_| failed("precision must be between 0 and 6"))?; let unit = a.opt_str("unit")?.unwrap_or("in"); + if ["points", "units_per_point", "page_distance"].iter().filter(|k| a.get(k).is_some()).count() > 1 { + return Err(failed("pass one of calibration points, units_per_point or page_distance")); + } + if a.get("page_unit").is_some() && a.get("page_distance").is_none() { + return Err(failed("page_unit is the unit of page_distance; pass page_distance too")); + } let scale = if a.get("points").is_some() { - if a.get("units_per_point").is_some() { - return Err(failed("pass calibration points or units_per_point, not both")); - } let (_, points) = self.measurement_points(a)?; let [from, to] = points.as_slice() else { return Err(failed("scale calibration needs exactly two points")); }; let length = a.opt_num("distance")?.ok_or_else(|| failed("calibration needs a real-world distance"))?; Scale::calibrate(*from, *to, length, unit, precision).map_err(failed)? + } else if let Some(page_distance) = a.opt_num("page_distance")? { + // A ratio, "page_distance page_unit on the page = distance unit"; the page unit + // defaults to the real-world one, as "1:50" means 1 mm = 50 mm. + let page_unit = match a.opt_str("page_unit")? { + Some(u) => PageUnit::from_label(u).ok_or_else(|| failed("page_unit must be pt, in, mm or cm"))?, + None => PageUnit::from_label(unit).ok_or_else(|| failed("pass page_unit (pt, in, mm or cm) when unit is not one of them"))?, + }; + let length = a.opt_num("distance")?.ok_or_else(|| failed("a scale ratio needs the real-world distance"))?; + let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?; + let to = self.doc(a)?.measurement_to_user(page, [1.0, 0.0]).map_err(failed)?; + Scale::ratio(from, to, page_distance, page_unit, length, unit, precision).map_err(failed)? } else if let Some(factor) = a.opt_num("units_per_point")? { { let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?; diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs new file mode 100644 index 0000000000..8c793be153 --- /dev/null +++ b/crates/automation/src/pages.rs @@ -0,0 +1,387 @@ +use super::dispatch::Content; +use super::error::{Result, ToolError, failed}; +use super::json::{Args, one_based}; +use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools}; + +/// Default and maximum resolution for `page_render`. +pub(crate) const DEFAULT_DPI: f64 = 96.0; + +pub(crate) const MAX_DPI: f64 = 600.0; + +/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a +/// match near the start returns without touching the rest of a long document (#971). +pub(crate) const FIND_FIRST_BATCH: usize = 8; + +const FIND_MAX_BATCH: usize = 128; + +type PageTexts = Vec<(usize, std::result::Result, String>)>; + +pub(crate) const FIND_MAX_WARNINGS: usize = 100; + +pub(crate) const FIND_MAX_WARNING_BYTES: usize = 1024; + +impl Automation { + // ---- pages and text ---------------------------------------------------------------------- + + /// 1-based page list → validated 0-based indices. + pub(crate) fn pages(&self, a: &Args, key: &str) -> Result> { + let n = self.doc(a)?.info.pages.len(); + let pages = one_based(&a.ints(key)?)?; + if pages.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must list at least one page"))); + } + if let Some(p) = pages.iter().find(|p| **p >= n) { + return Err(ToolError::InvalidArgs(format!("page {} is out of range: the document has {n} pages", p + 1))); + } + Ok(pages) + } + + /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. + pub(crate) fn position(&self, a: &Args, key: &str) -> Result { + self.opt_position(a, key)?.ok_or_else(|| Args::missing(key)) + } + + /// `position`, but `None` when the key is absent. + pub(crate) fn opt_position(&self, a: &Args, key: &str) -> Result> { + let Some(at) = a.opt_int(key)? else { return Ok(None) }; + let n = self.doc(a)?.info.pages.len(); + if at < 1 || at as usize > n + 1 { + return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1))); + } + Ok(Some(at as usize - 1)) + } + + pub(crate) fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> { + let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + // The document's own view settings: layer visibility, hidden comments, reading colours + // (tools that change one drop this cache so the next render reads it again). + let config = + self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }); + let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone())); + let entry = self.renderers.entry(id).or_insert_with(fresh); + if !Arc::ptr_eq(&entry.0, &doc.display) { + *entry = fresh(); + } + Ok(&mut entry.1) + } + + /// The page as a PNG, followed by a JSON part listing the render warnings when there are any, + /// so a caller can tell that the picture is incomplete, as `pdfcraft-cli render` does (#4340). + pub(crate) fn page_render(&mut self, a: &Args) -> Result> { + let id = self.doc(a)?.id; + let page = self.page(a)?; + let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI); + if !(1.0..=MAX_DPI).contains(&dpi) { + return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}"))); + } + let scale = (dpi / 72.0) as f32; + let block = a.opt_int("hide_text_block")?; + let added = a.opt_int("hide_added_text")?; + if block.is_some() && added.is_some() { + return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into())); + } + let out = if let Some(block) = block { + if block < 1 { + return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into())); + } + // The number names the paragraph text_paragraphs listed, as it does for text_edit. + let doc = self.doc(a)?; + let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?; + doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else if let Some(added) = added { + let index = usize::try_from(added) + .ok() + .and_then(|n| n.checked_sub(1)) + .ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?; + self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)? + } else { + self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }) + }; + if let Some(e) = out.error { + return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); + } + rendered_content(&out, page) + } + + /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels + /// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas: + /// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted + /// work on every scroll. It uses the same renderer, and the same per-document cache, as + /// `page_render`. + pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> { + if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) { + return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0))); + } + let id = DocId(doc); + let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len(); + if page < 1 || page > n { + return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages"))); + } + let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); + if let Some(e) = out.error { + return Err(failed(format!("page {page} could not be rendered: {e}"))); + } + Ok((out.width, out.height, out.rgba)) + } + + pub(crate) fn page(&self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let p = a.int("page")?; + if p < 1 || p as usize > n { + return Err(ToolError::InvalidArgs(format!("page {p} is out of range: the document has {n} pages"))); + } + Ok(p as usize - 1) + } + + /// The text of `pages` (0-based), from the cache or extracted in parallel. + pub(crate) fn page_texts(&mut self, id: DocId, pages: &[usize]) -> Result>> { + self.page_text_results(id, pages, &mut extract_parallel)? + .into_iter() + .map(|(p, text)| text.map_err(|e| failed(format!("page {}: {e}", p + 1)))) + .collect() + } + + /// Keep per-page failures until the caller chooses strict or partial-result semantics. + pub(crate) fn page_text_results( + &mut self, + id: DocId, + pages: &[usize], + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { + let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?; + let (bytes, n) = (doc.bytes.clone(), doc.info.pages.len()); + let password: Option> = doc.password.as_deref().map(Arc::from); + self.texts.prepare(id, &bytes); + let mut results = HashMap::new(); + let mut missing = Vec::new(); + for &p in pages { + if p >= n { + results.insert(p, Err("page is out of range".to_owned())); + } else if let Some(text) = self.texts.get(id, p) { + results.insert(p, Ok(text)); + } else { + missing.push(p); + } + } + missing.sort_unstable(); + missing.dedup(); + if !missing.is_empty() { + for (p, text) in extract(&bytes, password, &missing) { + if let Ok(text) = &text { + self.texts.insert(id, p, &bytes, text.clone()); + } + results.insert(p, text); + } + } + // The batch owns its Arcs: evicting a cache entry cannot lose a requested result. A + // worker that fails to return a page is an explicit failure, never an empty success. + Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect()) + } + + pub(crate) fn text_word_count(&self, a: &Args) -> Result { + use pdfcraft_engine::word_count::{self, Counts, Options}; + let doc = self.doc_to_copy(a)?; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + let options = Options { + exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false), + exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false), + exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false), + }; + let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?; + let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces }); + Ok(json!({ + "total": counts(report.total), + "pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::>(), + "pages_without_text": report.pages_without_text, + "unclassified_structure": report.unclassified_structure, + })) + } + + pub(crate) fn text_extract(&mut self, a: &Args) -> Result { + let doc = self.doc_to_copy(a)?; + let id = doc.id; + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..doc.info.pages.len()).collect(), + }; + // Column select (#740): only the text inside the rectangle, row by row. + let rect = a.nums::<4>("rect")?.map(|r| r.map(|v| v as f32)); + let texts = self.page_texts(id, &pages)?; + let text = |t: &PageText| match rect { + Some(r) => t.column_text(&t.glyphs_in(r)), + None => t.plain_text(), + }; + let out: Vec = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect(); + Ok(json!({ "pages": out })) + } + + /// Matches in document order, extracting page text in growing batches and stopping as soon as + /// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document + /// (#971). One match past the limit supplies `next`. Reached extraction failures are reported + /// as bounded warnings; `complete` requires both no continuation and no failed pages. + pub(crate) fn text_find(&mut self, a: &Args) -> Result { + self.text_find_with(a, &mut extract_parallel) + } + + pub(crate) fn text_find_with( + &mut self, + a: &Args, + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { + let doc = self.doc(a)?; + let (id, n) = (doc.id, doc.info.pages.len()); + let query = a.str("query")?; + let limit = usize::try_from(a.opt_int("limit")?.unwrap_or(500).max(1)).unwrap_or(usize::MAX); + let start = match a.opt_int("start_page")? { + None => 0, + Some(p) if p >= 1 && usize::try_from(p).is_ok_and(|p| p <= n) => usize::try_from(p - 1).unwrap_or(0), + Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))), + }; + let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX); + let mut matches = Vec::new(); + let mut next = None; + let mut warnings = Vec::new(); + let mut warning_count = 0usize; + let (mut first, mut batch) = (start, FIND_FIRST_BATCH); + 'search: while first < n { + let end = first.saturating_add(batch).min(n); + let pages: Vec = (first..end).collect(); + let texts = self.page_text_results(id, &pages, extract)?; + for (p, text) in texts { + let text = match text { + Ok(text) => text, + Err(mut error) => { + warning_count += 1; + if warnings.len() < FIND_MAX_WARNINGS { + let end = error.floor_char_boundary(FIND_MAX_WARNING_BYTES.min(error.len())); + let truncated = end < error.len(); + error.truncate(end); + warnings.push(json!({ "page": p + 1, "error": error, "error_truncated": truncated })); + } + continue; + } + }; + let skip_here = if p == start { skip } else { 0 }; + // One extra match proves that the caller needs a continuation. Include skipped + // matches on the starting page when bounding the temporary per-page result. + let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1); + for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) { + if matches.len() == limit { + next = Some(json!({ "start_page": p + 1, "skip": k })); + break 'search; + } + matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); + } + } + first = end; + batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); + } + let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches, + "warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings }); + if let (Some(next), Some(o)) = (next, out.as_object_mut()) { + o.insert("next".into(), next); + } + Ok(out) + } + + pub(crate) fn command_list(&self, a: &Args) -> Result { + let active = match a.opt_int("doc")? { + Some(_) => Some(self.doc(a)?.id), + None => None, + }; + let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase(); + let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false); + let list: Vec = commands::COMMANDS + .iter() + .map(|c| { + json!({ + "id": c.id, + "label": commands::current_label(c, &self.session, active), + "menu": c.menu, + "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))), + "enabled": commands::is_enabled(c, &self.session, active), + "tool": tools::tool_for_command(c.id), + "params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema), + }) + }) + .collect(); + let list: Vec = list + .into_iter() + .filter(|c| { + (!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true)) + && (filter.is_empty() + || ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter)))) + }) + .collect(); + Ok(json!({ "commands": list })) + } +} + +/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same). +pub(crate) fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { + pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) +} + +/// The output of a page-rendering tool: the PNG of the 0-based `page`, followed by a JSON part +/// listing its render warnings when there are any. Without warnings it is the PNG alone, as it +/// always was. +pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize) -> Result> { + let data = encode_png(out.width, out.height, &out.rgba)?; + let mut content = vec![Content::Png { data, width: out.width, height: out.height }]; + if !out.warnings.is_empty() { + // Sentences, like the other tools' `warnings`, so command_run can add its own to the list. + let warnings: Vec<&str> = out.warnings.iter().map(|&w| render_warning_text(w)).collect(); + content.push(Content::Json(json!({ "page": page.saturating_add(1), "warnings": warnings }))); + } + Ok(content) +} + +/// What a render warning means, in the words `pdfcraft-cli render` prints for it. +fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str { + use pdfcraft_render::RenderWarning; + match warning { + RenderWarning::ContentTruncated => "content past the page's safety budget was skipped", + RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour", + } +} + +/// Extract the text of `pages` (0-based) with one renderer per worker thread. +pub(crate) fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { + let extract = |r: &mut PageRenderer, p: usize| { + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + match out.error { + Some(e) => Err(e), + None => Ok(out.text.unwrap_or_default()), + } + }; + let config = RenderConfig { password, ..Default::default() }; + let workers = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).min(8) }; + // Small jobs aren't worth a second parse of the document. + if workers == 1 || pages.len() < 8 { + let mut r = PageRenderer::new(bytes.clone(), config); + return pages.iter().map(|p| (*p, extract(&mut r, *p))).collect(); + } + let next = std::sync::atomic::AtomicUsize::new(0); + let mut out: PageTexts = std::thread::scope(|s| { + let handles: Vec<_> = (0..workers) + .map(|_| { + s.spawn(|| { + let mut r = PageRenderer::new(bytes.clone(), config.clone()); + let mut done = Vec::new(); + loop { + let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let Some(p) = pages.get(i) else { break }; + done.push((*p, extract(&mut r, *p))); + } + done + }) + }) + .collect(); + handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect() + }); + out.sort_by_key(|(p, _)| *p); + out +} diff --git a/crates/automation/src/paths.rs b/crates/automation/src/paths.rs new file mode 100644 index 0000000000..e13d0a1452 --- /dev/null +++ b/crates/automation/src/paths.rs @@ -0,0 +1,180 @@ +use super::error::{Result, failed}; +use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes}; + +impl Automation { + // ---- paths ------------------------------------------------------------------------------- + + /// Resolve a user-supplied path, enforcing the root (if any). `for_write` allows a file that + /// does not exist yet (its nearest existing ancestor must be inside the root). + /// + /// Every path outside the root gets the same refusal, so a confined client can't learn what + /// exists out there (#136): `..` is resolved by name first, another network share or device + /// namespace is refused without touching it, and the deepest existing ancestor (links + /// followed) must be inside the root before anything about the rest is reported. + pub(crate) fn resolve(&self, path: &str, for_write: bool) -> Result { + let access = if for_write { file_access::Access::Write } else { file_access::Access::Read }; + self.resolve_for(path, access, for_write) + } + + /// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that + /// is only read may still be missing (a path checked up front, whose absence is reported per + /// file later). The automation roots, if any, get the last word. + pub(crate) fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result { + let target = self.resolve_in_root(path, may_be_missing)?; + self.check_roots(path, &target, access)?; + Ok(target) + } + + /// Hold `target`, which `path` named, to the automation read or write root. Always passes + /// without roots. + pub(crate) fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> { + match &self.roots { + Some(roots) => roots.check_path(path, target, access).map_err(failed), + None => Ok(()), + } + } + + fn resolve_in_root(&self, path: &str, for_write: bool) -> Result { + let p = Path::new(path); + let Some(root) = &self.root else { return Ok(p.to_path_buf()) }; + let outside = || failed(format!("{path} is outside the allowed directory {}", root.display())); + let joined = lexical(&root.join(p)).ok_or_else(outside)?; + if foreign_share(&joined, root) { + return Err(outside()); + } + let mut existing = joined.as_path(); + let mut rest = Vec::new(); + while !existing.exists() { + if existing.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) { + return Err(outside()); // a broken link: going on would tell whether its target exists + } + match (existing.file_name(), existing.parent()) { + (Some(name), Some(parent)) => { + rest.push(name); + existing = parent; + } + _ => return Err(outside()), // not even a drive or share that exists + } + } + let mut real = existing.canonicalize().map_err(|_| outside())?; + if !real.starts_with(root) { + return Err(outside()); + } + if !rest.is_empty() && !for_write { + // Missing, below a folder inside the root: say where it was looked for, so a path + // written for another working directory can be corrected (#1890), and otherwise + // why, as the system reports it. `joined` is under the root, so naming it reveals + // nothing outside. + real = joined.canonicalize().map_err(|e| { + let (looked, root) = (joined.display(), root.display()); + match e.kind() { + std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")), + _ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")), + } + })?; + if !real.starts_with(root) { + return Err(outside()); // it appeared, as a link out, since the check above + } + return Ok(real); + } + real.extend(rest.iter().rev()); + Ok(real) + } +} + +/// `path` with `.` and `..` resolved by name, before the filesystem is consulted (Windows does +/// the same, and so does joining onto a canonical root there). `None` if a `..` would climb +/// above the start of the path. +pub(crate) fn lexical(path: &Path) -> Option { + use std::path::Component; + let mut parts: Vec = Vec::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => match parts.last() { + Some(Component::Normal(_)) => { + parts.pop(); + } + _ => return None, + }, + other => parts.push(other), + } + } + Some(parts.iter().collect()) +} + +/// The file `name` inside `dir`, where `name` comes from a document or an argument: separators, +/// colons (a Windows drive or stream) and control characters become `_`, and a name of only dots +/// gets a leading `_`, so it is one plain file name and can't lead out of `dir`. +pub(crate) fn child(dir: &Path, name: &str) -> PathBuf { + let mut safe: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':') || c.is_control() { '_' } else { c }).collect(); + if safe.chars().all(|c| c == '.') { + safe.insert(0, '_'); + } + dir.join(safe) +} + +/// `.pdf` in `dir`, or ` (2).pdf` and so on when that name is taken. +pub(crate) fn unused(dir: &Path, stem: &str) -> PathBuf { + let first = child(dir, &format!("{stem}.pdf")); + if !first.exists() { + return first; + } + (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) +} + +/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`). +pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; + +/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap. +pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result> { + use std::io::Read as _; + let file = std::fs::File::open(path)?; + if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + let mut bytes = Vec::new(); + file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_INPUT { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + Ok(bytes) +} + +pub(crate) fn read_input(path: &Path) -> Result> { + read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display()))) +} + +/// Write and sync a temporary file in the same directory, then rename over the target. +/// This flushes staged contents; it does not promise parent-directory durability after a +/// power loss. Filesystems that refuse sync cause the save to fail before publication. +pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + write_atomic_with(path, bytes, staging_suffixes()) +} + +/// [`write_atomic`], trying the staging names that `suffixes` give. +pub(crate) fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator) -> Result<()> { + write_atomic_synced(path, bytes, suffixes, std::fs::File::sync_all) +} + +pub(crate) fn write_atomic_synced( + path: &Path, + bytes: &[u8], + suffixes: impl IntoIterator, + sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, +) -> Result<()> { + // A folder can't be replaced, and staging beside it could land outside the root: "." names + // the root itself, whose parent isn't ours to write in. + if path.is_dir() { + return Err(failed(format!("{}: is a folder, not a file", path.display()))); + } + let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); + path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; + std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; + // The writer itself is the platform crate's, shared with the desktop app and the CLI + // (#2166): the staging file is written, flushed (delayed writeback failures surface before + // the destination is replaced or a document marked clean), closed and renamed; a failure at + // any step removes it and leaves the destination as it was. + pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync) + .map_err(|e| failed(format!("{}: {e}", path.display()))) +} diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 19cd48ef43..9a1c1a6b7c 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -6,11 +6,88 @@ use serde_json::{Value, json}; use crate::{Args, Automation, Result, ToolError, failed, write_atomic}; +/// The job's two-sided setting for `doc_print`'s `duplex`: `printer` sends none, so the printer's +/// own default applies (as the Print dialog's Printer default, #2422), except that cut and stack +/// needs single-sided sheets. +fn duplex(value: &str, cut_stack: bool) -> Result> { + Ok(match value { + "printer" if cut_stack => Some(spool::Duplex::Off), + "printer" => None, + "off" => Some(spool::Duplex::Off), + "long-edge" if cut_stack => return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)")), + "short-edge" if cut_stack => return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)")), + "long-edge" => Some(spool::Duplex::LongEdge), + "short-edge" => Some(spool::Duplex::ShortEdge), + d => return Err(bad(format!("unknown duplex {d:?}"))), + }) +} + +/// [`duplex`] for a job laid out as `layout`: the name is checked either way, and a booklet +/// decides two-sided itself (#2707), as the Print dialog does (`print::booklet_duplex`). +fn job_duplex(value: &str, layout: &Layout) -> Result> { + let chosen = duplex(value, matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?; + Ok(match layout { + Layout::Booklet { subset, .. } => Some(print::booklet_duplex(*subset)), + _ => chosen, + }) +} + +/// An invalid-arguments error saying `m`. fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } +/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS` +/// index and size; US Letter when none is given. +fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { + let mut papers = PAPERS.iter().enumerate(); + let found = match name { + None => papers.next(), + Some(p) => papers.find(|(_, (n, _))| { + n.eq_ignore_ascii_case(p) + || n.replace("US ", "").eq_ignore_ascii_case(p) + || n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p)) + }), + }; + found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { + bad(format!( + "unknown paper {:?}; choose one of {}", + name.unwrap_or_default(), + PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") + )) + }) +} + +/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on +/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default +/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752). +fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize) -> Result { + let options = match a.get("options") { + None => Vec::new(), + Some(Value::Object(m)) => m + .iter() + .map(|(k, v)| match v.as_str() { + Some(v) => Ok((k.clone(), v.to_string())), + None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))), + }) + .collect::>>()?, + Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")), + }; + Ok(spool::Job { + printer: if printer == "default" { None } else { Some(printer.to_string()) }, + copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, + collate: a.opt_bool("collate")?.unwrap_or(true), + duplex, + scaling: spool::Scaling::Exact, + grayscale: a.opt_bool("grayscale")?.unwrap_or(false), + title, + options: options.into_iter().chain(print::media_option(paper)).collect(), + }) +} + impl Automation { + /// `printers`: the system's printers, the default marked, and each one's two-sided support + /// where the spooler reports it. pub(crate) fn printers(&self) -> Result { let list: Vec = spool::printers() .into_iter() @@ -37,6 +114,8 @@ impl Automation { Ok(json!({ "printer": printer, "count": list.len(), "options": list })) } + /// `doc_print`: lay the document out with the Print dialog's options, then save the + /// print-ready PDF to `path` or send it to `printer` with the chosen paper. pub(crate) fn doc_print(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, count) = (doc.id, doc.info.pages.len()); @@ -92,9 +171,7 @@ impl Automation { }, l => return Err(bad(format!("unknown layout {l:?}"))), }; - if matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }) && a.opt_str("duplex")?.unwrap_or("off") != "off" { - return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)")); - } + let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) { return Err(bad("cut-stack order needs layout multiple")); } @@ -111,20 +188,7 @@ impl Automation { "form-fields-only" => Content::FormFieldsOnly, c => return Err(bad(format!("unknown comments_forms {c:?}"))), }; - let paper = match a.opt_str("paper")? { - None => PAPERS[0].1, - Some(p) => PAPERS - .iter() - .find(|(n, _)| { - n.eq_ignore_ascii_case(p) - || n.replace("US ", "").eq_ignore_ascii_case(p) - || n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p)) - }) - .map(|x| x.1) - .ok_or_else(|| { - bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::>().join(", "))) - })?, - }; + let (paper_index, paper) = paper_named(a.opt_str("paper")?)?; // Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when // `grayscale` is also asked for. let raster = if a.opt_bool("as_image")?.unwrap_or(false) { @@ -148,32 +212,7 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let name = self.doc(a)?.name.clone(); - let job = spool::Job { - printer: if printer == "default" { None } else { Some(printer.to_string()) }, - copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, - collate: a.opt_bool("collate")?.unwrap_or(true), - duplex: Some(match a.opt_str("duplex")?.unwrap_or("off") { - "off" => spool::Duplex::Off, - "long-edge" => spool::Duplex::LongEdge, - "short-edge" => spool::Duplex::ShortEdge, - d => return Err(bad(format!("unknown duplex {d:?}"))), - }), - scaling: spool::Scaling::Exact, - grayscale: a.opt_bool("grayscale")?.unwrap_or(false), - title: name, - options: match a.get("options") { - None => Vec::new(), - Some(Value::Object(m)) => m - .iter() - .map(|(k, v)| match v.as_str() { - Some(v) => Ok((k.clone(), v.to_string())), - None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))), - }) - .collect::>>()?, - Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")), - }, - }; + let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -181,3 +220,60 @@ impl Automation { Ok(out) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error. + #[test] + fn a_booklet_decides_two_sided_itself() { + let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left }; + for asked in ["printer", "off", "long-edge", "short-edge"] { + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + } + assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides)).is_err()); + assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit)).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it"); + } + + /// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks + /// for single-sided sheets, and refuses an explicit edge. + #[test] + fn duplex_printer_leaves_two_sided_to_the_printer() { + assert_eq!(duplex("printer", false).ok(), Some(None)); + assert_eq!(duplex("printer", true).ok(), Some(Some(spool::Duplex::Off))); + assert_eq!(duplex("off", false).ok(), Some(Some(spool::Duplex::Off))); + assert_eq!(duplex("long-edge", false).ok(), Some(Some(spool::Duplex::LongEdge))); + assert_eq!(duplex("short-edge", false).ok(), Some(Some(spool::Duplex::ShortEdge))); + assert!(duplex("long-edge", true).is_err()); + assert!(duplex("sideways", false).is_err()); + } + + /// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for + /// the one the sheets were laid out on (A4 under Letter sheets on the report's queue). + #[test] + fn printing_to_a_printer_feeds_the_laid_out_paper() { + let job = |args: Value, paper_name: Option<&str>| { + let (index, _) = paper_named(paper_name).unwrap(); + spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap() + }; + let media = |m: &str| ("media".to_string(), m.to_string()); + // US Letter when no paper is asked for, after the driver's own options. + let letter = job(json!({ "options": { "InputSlot": "Tray3" } }), None); + assert_eq!(letter.options, [("InputSlot".to_string(), "Tray3".to_string()), media("Letter")]); + assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf")); + // Every paper, by its full name, goes out as its own media name. + for (i, (name, size)) in PAPERS.iter().enumerate() { + assert_eq!(paper_named(Some(name)).ok(), Some((i, *size)), "{name}"); + assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::>(), "{name}"); + } + // Short forms find the same paper. + assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); + assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); + assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); + } +} diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 1b6283c0ad..e662bcac19 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -14,9 +14,19 @@ impl Automation { pub(crate) fn redact_mark(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, n) = (doc.id, doc.info.pages.len()); - let pages: Vec = match a.opt_ints("pages")? { - Some(_) => self.pages(a, "pages")?, - None => (0..n).collect(), + if a.get("page").is_some() && a.get("pages").is_some() { + return Err(ToolError::InvalidArgs("pass page or pages, not both".into())); + } + if a.get("rect").is_some() && a.get("pages").is_some() { + return Err(ToolError::InvalidArgs("rect requires page, not pages".into())); + } + let pages: Vec = if a.get("page").is_some() { + vec![self.page(a)?] + } else { + match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + } }; let overlay = match (a.opt_str("overlay")?, a.opt_str("code_set")?) { (Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass overlay or code_set with codes, not both".into())), diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 94fc4bdec8..743a36ff56 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -189,7 +189,7 @@ impl Automation { return Err(failed(format!("windows:{r}: Windows certificate store identities are only available on Windows"))); } let path = self.resolve(a.str("id")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(|e| match e { sign::SignError::WrongPassword => bad("the digital ID password is incorrect"), e => failed(e), @@ -208,7 +208,7 @@ impl Automation { }; let rect = match a.nums::<4>("rect")? { Some(r) => { - let p = &doc.info.pages[page]; + let p = doc.info.pages.get(page).ok_or_else(|| bad("the document has no page to put the signature on"))?; let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32)); let u = [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]; if u[2] - u[0] < 4.0 || u[3] - u[1] < 4.0 { @@ -320,7 +320,7 @@ impl Automation { }; for p in paths { let path = self.resolve(&p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let lower = p.to_ascii_lowercase(); let found = if lower.ends_with(".p12") || lower.ends_with(".pfx") { let id = sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(failed)?; diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs new file mode 100644 index 0000000000..fd4efa79da --- /dev/null +++ b/crates/automation/src/tests.rs @@ -0,0 +1,552 @@ +use super::*; +use pdfcraft_platform::staging::STAGING_ATTEMPTS; + +/// `n` pages, page i showing "Page i". +fn numbered_pages(n: usize) -> Vec { + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" "))); + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + for i in 0..n { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); + let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1); + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn json(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args).map(|mut c| c.remove(0)) { + Ok(Content::Json(v)) => v, + other => panic!("{tool} {args}: {other:?}"), + } +} + +fn search_document(a: &mut Automation, pages: usize) -> DocId { + a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap() +} + +#[test] +fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() { + let mut a = Automation::new(); + a.texts = TextCache::new(16 * 1024); + let first = search_document(&mut a, 200); + let second = search_document(&mut a, 200); + for id in [first, second] { + let result = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent" })); + assert_eq!(result["complete"], true); + assert!(a.texts.bytes() <= 16 * 1024); + assert!(a.texts.len() < 200, "an exhaustive search must not retain every page"); + let found = json(&mut a, "text_find", json!({ "doc": id.0, "query": "Page", "limit": 1000 })); + assert_eq!(found["count"], 200, "eviction inside a batch must not discard its matches"); + eprintln!("200 pages searched: {} resident pages, {} estimated bytes (budget 16384)", a.texts.len(), a.texts.bytes()); + } + assert!(a.texts.get(first, 0).is_none()); + let text = a.page_texts(first, &[0]).unwrap().remove(0); + assert_eq!(text.plain_text(), "Page 1"); + let again = a.page_texts(first, &[0]).unwrap().remove(0); + assert!(Arc::ptr_eq(&text, &again), "cache hits reuse glyph storage"); + assert!(a.texts.bytes() <= 16 * 1024); + json(&mut a, "page_delete", json!({ "doc": first.0, "pages": [1] })); + let edited = a.page_texts(first, &[0]).unwrap().remove(0); + assert_eq!(edited.plain_text(), "Page 2"); + assert!(!Arc::ptr_eq(&text, &edited)); + json(&mut a, "doc_close", json!({ "doc": first.0, "discard_changes": true })); + json(&mut a, "doc_close", json!({ "doc": second.0 })); + assert_eq!((a.texts.bytes(), a.texts.len()), (0, 0)); +} + +#[test] +fn strict_text_collection_refuses_partial_results_but_keeps_successful_pages() { + let mut a = Automation::new(); + let id = search_document(&mut a, 3); + let error = a.page_texts(id, &[0, 3, 1]).unwrap_err(); + assert!(error.to_string().contains("page 4: page is out of range")); + assert!(a.texts.get(id, 0).is_some()); + assert!(a.texts.get(id, 1).is_some(), "successful extraction after the failed entry is retained"); + let results = a.page_text_results(id, &[2], &mut |_, _, _| vec![]).unwrap(); + assert!(results[0].1.is_err(), "a missing worker result must not become an empty page"); +} + +#[test] +fn search_keeps_matches_around_failed_pages_and_continuations() { + let mut a = Automation::new(); + let id = search_document(&mut a, 16); + let mut extract = |bytes: &Arc>, password, pages: &[usize]| { + extract_parallel(bytes, password, pages) + .into_iter() + .map(|(p, result)| (p, if [2, 6].contains(&p) { Err(format!("synthetic page {} failure", p + 1)) } else { result })) + .collect() + }; + let all = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut extract).unwrap(); + assert_eq!(all["count"], 14); + assert_eq!(all["complete"], false); + assert_eq!(all["warning_count"], 2); + assert_eq!(all["warnings"][0]["page"], 3); + assert_eq!(all["warnings"][1]["page"], 7); + assert!(all.get("next").is_none()); + assert_eq!(all["matches"][13]["page"], 16); + assert!(a.texts.get(id, 7).is_some(), "success after a failure in the same batch is cached"); + let mut args = json!({ "doc": id.0, "query": "Page", "limit": 4 }); + let mut matches = Vec::new(); + let mut warned = Vec::new(); + loop { + let part = a.text_find_with(&Args(&args), &mut extract).unwrap(); + matches.extend(part["matches"].as_array().unwrap().iter().cloned()); + warned.extend(part["warnings"].as_array().unwrap().iter().map(|w| w["page"].clone())); + if !part["warnings"].as_array().unwrap().is_empty() { + assert_eq!(part["complete"], false); + } + let Some(next) = part.get("next") else { break }; + args["start_page"] = next["start_page"].clone(); + args["skip"] = next["skip"].clone(); + } + assert_eq!(matches, *all["matches"].as_array().unwrap(), "no readable match is lost or repeated"); + assert_eq!(warned, vec![json!(3), json!(7)]); +} + +#[test] +fn search_bounds_warning_volume_and_does_not_reach_distant_failures() { + let mut a = Automation::new(); + let id = search_document(&mut a, 200); + let first = a + .text_find_with(&Args(&json!({ "doc": id.0, "query": "Page", "limit": 1 })), &mut |bytes, password, pages| { + assert!(pages.iter().all(|p| *p < FIND_FIRST_BATCH), "a bounded search must not reach the bad page at the end"); + extract_parallel(bytes, password, pages) + }) + .unwrap(); + assert_eq!(first["count"], 1); + assert_eq!(first["warning_count"], 0); + assert_eq!(first["next"], json!({ "start_page": 2, "skip": 0 })); + a.texts.remove(&id); + let failures = a + .text_find_with(&Args(&json!({ "doc": id.0, "query": "Page" })), &mut |_, _, pages| { + pages.iter().map(|p| (*p, Err("界".repeat(2000)))).collect() + }) + .unwrap(); + assert_eq!(failures["count"], 0); + assert_eq!(failures["complete"], false); + assert!(failures.get("next").is_none()); + assert_eq!(failures["warning_count"], 200); + assert_eq!(failures["warnings_truncated"], true); + let warnings = failures["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), FIND_MAX_WARNINGS); + assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true)); +} + +#[test] +fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() { + let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("long.pdf"), numbered_pages(200)).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = json(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].clone(); + let extracted = |a: &Automation| a.texts.len(); + + // #971: the first match is on page 1 and the next on page 10, so the rest of the 200 pages + // aren't extracted or cached. + let first = json(&mut a, "text_find", json!({ "doc": doc, "query": "Page 1", "limit": 1 })); + assert_eq!(first["count"], 1); + assert_eq!(first["matches"][0]["page"], 1); + assert_eq!(first["complete"], false, "{first}"); + assert_eq!(first["next"], json!({ "start_page": 10, "skip": 0 })); + assert!(extracted(&a) <= 3 * FIND_FIRST_BATCH, "{} pages extracted for one match", extracted(&a)); + + // Following `next` returns every match exactly once, in order, and then says it's complete. + // "1" appears twice on pages like 11 and 101, so continuing has to skip within a page. + let all = json(&mut a, "text_find", json!({ "doc": doc, "query": "1", "limit": 10_000 })); + assert_eq!(all["complete"], true); + assert!(all.get("next").is_none()); + let mut pieced = Vec::new(); + let mut args = json!({ "doc": doc, "query": "1", "limit": 3 }); + loop { + let found = json(&mut a, "text_find", args.clone()); + pieced.extend(found["matches"].as_array().unwrap().iter().map(|m| (m["page"].clone(), m["rects"].clone()))); + if found["complete"] == true { + break; + } + args["start_page"] = found["next"]["start_page"].clone(); + args["skip"] = found["next"]["skip"].clone(); + } + let expected: Vec<_> = all["matches"].as_array().unwrap().iter().map(|m| (m["page"].clone(), m["rects"].clone())).collect(); + assert_eq!(all["count"], expected.len()); + assert_eq!(pieced, expected); + + // A search that runs to the end below its limit is complete; a start page past the end is refused. + let few = json(&mut a, "text_find", json!({ "doc": doc, "query": "Page 200", "start_page": 150 })); + assert_eq!((few["count"].clone(), few["complete"].clone()), (json!(1), json!(true))); + assert!(a.call("text_find", &json!({ "doc": doc, "query": "x", "start_page": 201 })).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() { + let base = std::env::temp_dir(); + let l = |rel: &str| lexical(&base.join(rel)); + assert_eq!(l("a/./b/../c"), Some(base.join("a").join("c"))); + assert_eq!(l("missing/../../x"), lexical(&base.join("..").join("x"))); + assert_eq!(l("a/.."), Some(lexical(&base).unwrap())); + let deep = "../".repeat(base.components().count() + 1); + assert_eq!(lexical(&base.join(&deep)), None); + assert_eq!(lexical(Path::new("..")), None); +} + +#[test] +fn names_from_documents_become_one_plain_file_name() { + let dir = std::env::temp_dir().join("out"); + for name in ["../../x.png", "/abs/x.png", r"..\..\x.png", "C:x.png", r"\\host\share\x.png", "a:stream", "..", ".", "", "tab\there", "ok.png"] { + let p = child(&dir, name); + assert_eq!(p.parent(), Some(dir.as_path()), "{name:?} -> {}", p.display()); + assert_eq!(p.components().count(), dir.components().count() + 1, "{name:?} -> {}", p.display()); + } + assert_eq!(child(&dir, "ok.png"), dir.join("ok.png")); + assert_eq!(child(&dir, "../x.png"), dir.join(".._x.png")); +} + +#[cfg(windows)] +#[test] +fn other_shares_and_device_paths_are_refused_by_name() { + let local = Path::new(r"\\?\C:\work\root"); + for p in [ + r"\\host\share\x.pdf", + "//host/share/x.pdf", + r"\/host/share/x.pdf", + r"\\?\UNC\host\share\x.pdf", + r"\\.\pipe\x", + r"\\.\C:\work\root\x.pdf", + r"\\?\GLOBALROOT\Device\x", + ] { + assert!(foreign_share(Path::new(p), local), "{p}"); + } + for p in [r"C:\work\root\x.pdf", r"c:\elsewhere\x.pdf", r"\\?\C:\work\root\x.pdf", r"D:\x.pdf", r"C:x.pdf"] { + assert!(!foreign_share(Path::new(p), local), "{p}"); + } + // A root on a share accepts that share, however it is spelled, and nothing else. + let shared = Path::new(r"\\?\UNC\Server\Docs\root"); + assert!(!foreign_share(Path::new(r"\\server\docs\root\x.pdf"), shared)); + assert!(!foreign_share(Path::new(r"\\?\UNC\SERVER\DOCS\x.pdf"), shared)); + assert!(foreign_share(Path::new(r"\\server\other\x.pdf"), shared)); + assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared)); +} + +/// A fresh, empty folder for one staging test. +fn staging_dir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-staging-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn staged(dir: &Path, suffix: u64) -> PathBuf { + dir.join(format!(".out.pdf.{suffix:016x}.pdfcraft-tmp")) +} + +fn read(p: &Path) -> String { + std::fs::read_to_string(p).unwrap() +} + +/// A symbolic link to a file, where the system allows one (Windows needs Developer Mode or an +/// administrator for it). +fn file_symlink(target: &Path, link: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(target, link) + } + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(target, link) + } + #[cfg(not(any(unix, windows)))] + { + Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display()))) + } +} + +#[test] +fn staging_never_writes_through_a_file_planted_at_its_name() { + let dir = staging_dir("planted"); + // A file elsewhere that a planted link points at. + let outside = dir.join("outside.txt"); + std::fs::write(&outside, "PRECIOUS").unwrap(); + let target = dir.join("out.pdf"); + // A hard link needs no privileges on any system, and writing to it writes to `outside`. + std::fs::hard_link(&outside, staged(&dir, 1)).unwrap(); + std::fs::write(staged(&dir, 2), "PLANTED").unwrap(); + write_atomic_with(&target, b"NEW", [1, 2, 3]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&outside), "PRECIOUS"); + assert_eq!(read(&staged(&dir, 1)), "PRECIOUS"); + assert_eq!(read(&staged(&dir, 2)), "PLANTED"); + assert!(!staged(&dir, 3).exists(), "the staging file was renamed into place"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn staging_never_writes_through_a_symbolic_link_planted_at_its_name() { + let dir = staging_dir("symlink"); + let outside = dir.join("outside.txt"); + std::fs::write(&outside, "PRECIOUS").unwrap(); + let target = dir.join("out.pdf"); + if let Err(e) = file_symlink(&outside, &staged(&dir, 1)) { + eprintln!("symbolic links not checked: {e}"); + return; + } + write_atomic_with(&target, b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&outside), "PRECIOUS"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn staging_never_creates_a_file_through_a_dangling_link_at_its_name() { + let dir = staging_dir("dangling"); + let unborn = dir.join("created-through-a-link.txt"); + let target = dir.join("out.pdf"); + if let Err(e) = file_symlink(&unborn, &staged(&dir, 1)) { + eprintln!("symbolic links not checked: {e}"); + return; + } + write_atomic_with(&target, b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert!(!unborn.exists(), "nothing was created through the dangling link"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn staging_gives_up_rather_than_reuse_a_taken_name() { + let dir = staging_dir("taken"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + for s in 1..=STAGING_ATTEMPTS as u64 { + std::fs::write(staged(&dir, s), "PLANTED").unwrap(); + } + assert!(write_atomic_with(&target, b"NEW", 1..).is_err()); + assert_eq!(read(&target), "OLD"); + for s in 1..=STAGING_ATTEMPTS as u64 { + assert_eq!(read(&staged(&dir, s)), "PLANTED"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_failed_staging_sync_keeps_the_destination_and_cleans_up() { + for exists in [false, true] { + let dir = staging_dir(if exists { "sync-existing" } else { "sync-new" }); + let target = dir.join("out.pdf"); + if exists { + std::fs::write(&target, "OLD").unwrap(); + } + let result = write_atomic_synced(&target, b"NEW", [1], |_| { + assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW", "sync follows writing"); + if exists { + assert_eq!(read(&target), "OLD", "sync precedes publication"); + } + Err(std::io::Error::other("injected storage sync failure")) + }); + assert!(result.is_err(), "sync failure must refuse publication"); + assert!(result.unwrap_err().to_string().contains("injected storage sync failure")); + if exists { + assert_eq!(read(&target), "OLD"); + assert_eq!(listing(&dir), ["out.pdf"]); + } else { + assert!(!target.exists()); + assert!(listing(&dir).is_empty()); + } + std::fs::remove_dir_all(dir).unwrap(); + } +} + +#[test] +fn invalid_save_bytes_never_reach_publication_or_mark_saved() { + let dir = staging_dir("invalid-save-bytes"); + let target = dir.join("out.pdf"); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = search_document(&mut a, 2); + json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"})); + let old = std::fs::read(&target).unwrap(); + json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); + let before = summary(a.session.get(id).unwrap()); + // Exercise both parse failure and a valid PDF that loses a page. This does not + // depend on a malformed input remaining unrepaired by future reader improvements. + for bytes in [b"not a PDF".to_vec(), numbered_pages(1)] { + let mut published = false; + let result = a.publish_save(id, &target, Arc::new(bytes), false, |_, _| { + published = true; + Ok(()) + }); + assert!(result.is_err(), "invalid output must be refused"); + assert!(result.unwrap_err().to_string().contains("could not be reopened")); + assert!(!published, "validation must precede the writer"); + assert_eq!(summary(a.session.get(id).unwrap()), before, "failed validation must preserve document state"); + assert_eq!(std::fs::read(&target).unwrap(), old); + assert_eq!(listing(&dir), ["out.pdf"]); + } + json(&mut a, "doc_save", json!({"doc": id.0})); + assert!(!a.session.get(id).unwrap().dirty); + assert_ne!(std::fs::read(&target).unwrap(), old); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() { + let dir = staging_dir("sync-dirty"); + let target = dir.join("out.pdf"); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = search_document(&mut a, 2); + json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"})); + let old = std::fs::read(&target).unwrap(); + json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); + let args = json!({"doc": id.0}); + let before = summary(a.doc(&Args(&args)).unwrap()); + let result = a.doc_save_with(&Args(&args), |path, bytes| { + write_atomic_synced(path, bytes, [1], |_| Err(std::io::Error::other("injected storage sync failure"))) + }); + assert!(result.is_err()); + let doc = a.doc(&Args(&args)).unwrap(); + assert!(doc.dirty); + assert_eq!(summary(doc), before, "failed save must not reset history or saved state"); + assert_eq!(std::fs::read(&target).unwrap(), old); + assert_eq!(listing(&dir), ["out.pdf"]); + json(&mut a, "doc_save", args.clone()); + assert!(!a.doc(&Args(&args)).unwrap().dirty); + assert_ne!(std::fs::read(&target).unwrap(), old); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn successful_staging_sync_precedes_publication() { + let dir = staging_dir("sync-success"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut called = false; + write_atomic_synced(&target, b"NEW", [1], |file| { + called = true; + assert_eq!(read(&target), "OLD"); + assert_eq!(std::fs::read(staged(&dir, 1)).unwrap(), b"NEW"); + file.sync_all() + }) + .unwrap(); + assert!(called); + assert_eq!(read(&target), "NEW"); + assert_eq!(listing(&dir), ["out.pdf"]); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The names in a folder: what a test can see was left behind. +fn listing(dir: &Path) -> Vec { + let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names +} + +#[test] +fn staging_names_differ_between_saves_and_fit_long_names() { + let suffixes: std::collections::HashSet = staging_suffixes().take(64).collect(); + assert_eq!(suffixes.len(), 64); + assert_ne!(staging_suffixes().next(), staging_suffixes().next(), "each save draws new names"); + // 60 four-byte characters: a 244-byte name, within every system's limit. Its staging name + // must be too (on Linux the whole name in it would be 275 bytes). + let dir = staging_dir("long"); + let name = format!("{}.pdf", "\u{1F600}".repeat(60)); + write_atomic(&dir.join(&name), b"NEW").unwrap(); + assert_eq!(read(&dir.join(&name)), "NEW"); + assert_eq!(listing(&dir), [name], "no staging file is left behind"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_folder_at_the_staging_name_is_left_alone() { + let dir = staging_dir("folder"); + std::fs::create_dir(staged(&dir, 1)).unwrap(); + std::fs::write(staged(&dir, 1).join("inside.txt"), "PLANTED").unwrap(); + let target = dir.join("out.pdf"); + write_atomic_with(&target, b"NEW", [1, 2]).unwrap(); + assert_eq!(read(&target), "NEW"); + assert_eq!(read(&staged(&dir, 1).join("inside.txt")), "PLANTED"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Windows refuses to replace a read-only file, so the rename fails: the staging file must not +/// be left behind. +#[cfg(windows)] +#[test] +fn a_failed_rename_removes_the_staging_file() { + let dir = staging_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let e = write_atomic_with(&target, b"NEW", [7]).unwrap_err(); + assert!(e.to_string().contains(&target.display().to_string()), "the rename failed, not the staging: {e}"); + assert_eq!(read(&target), "OLD"); + assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A recovered document, or a new one, rotated and then undone still has never been saved: +/// `doc_close` refuses it unless told to discard it (#2346 review). +#[test] +fn closing_a_recovered_or_new_document_after_undo_is_refused() { + let mut a = Automation::new(); + let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap(); + a.session.mark_recovered(recovered, Some("Report.pdf".into())); + let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap(); + for id in [recovered, new] { + json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); + json(&mut a, "edit_undo", json!({"doc": id.0})); + let refused = a.call("doc_close", &json!({"doc": id.0})); + assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}"); + assert!(a.session.get(id).is_some(), "still open"); + json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true})); + assert!(a.session.get(id).is_none()); + } +} + +#[test] +fn tool_file_reads_refuse_inputs_exceeding_512_mib() { + let dir = staging_dir("capped-read"); + let huge = dir.join("huge.bin"); + let file = std::fs::File::create(&huge).unwrap(); + file.set_len(MAX_INPUT + 1).unwrap(); + drop(file); + let huge_str = huge.to_string_lossy().into_owned(); + + let mut a = Automation::new(); + let id = a.session.open("a.pdf", None, Arc::new(numbered_pages(2)), None).unwrap().0; + for (tool, args) in [ + ("doc_open", json!({ "path": huge_str })), + ("page_insert_file", json!({ "doc": id, "at": 1, "path": huge_str })), + ("page_replace", json!({ "doc": id, "pages": [1], "path": huge_str })), + ("page_add_image", json!({ "doc": id, "page": 1, "path": huge_str })), + ("doc_import_data", json!({ "doc": id, "path": huge_str })), + ("doc_create", json!({ "from": "text", "path": huge_str })), + ("doc_create", json!({ "from": "images", "paths": [huge_str] })), + ("doc_combine", json!({ "paths": [huge_str, huge_str] })), + ("form_merge_data", json!({ "paths": [huge_str], "path": dir.join("out.csv").to_string_lossy() })), + ("comment_add", json!({ "doc": id, "page": 1, "type": "attachment", "at": [72.0, 72.0], "path": huge_str })), + ("stamp_custom", json!({ "doc": id, "page": 1, "at": [72.0, 72.0], "path": huge_str })), + ("sign_trust", json!({ "paths": [huge_str] })), + ("attachment_add", json!({ "doc": id, "path": huge_str })), + ] { + let err = a.call(tool, &args).unwrap_err().to_string(); + assert!(err.contains("input exceeds 512 MiB"), "{tool}: {err}"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 91cd489c0c..c6c200cece 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -60,7 +60,10 @@ fn color() -> Value { fn comment_ref(mut extra: Value) -> Value { if let Some(props) = extra.as_object_mut() { props.insert("doc".into(), doc()); - props.insert("id".into(), json!({ "type": "string", "description": "The comment's id (from comment_list)." })); + props.insert( + "id".into(), + json!({ "type": "string", "description": "The comment's id (from comment_list). An id several comments share is refused: pass page and index." }), + ); props.insert("page".into(), json!({ "type": "integer", "minimum": 1, "description": "With `index`, instead of `id`." })); props.insert( "index".into(), @@ -118,7 +121,7 @@ pub fn tools() -> Vec { let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." }); let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." }); vec![ - t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") + t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") .with(schema(json!({ "paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000}, "out_dir": {"type":"string"}, @@ -129,11 +132,17 @@ pub fn tools() -> Vec { "dpi": {"type":"number","minimum":72,"maximum":600}, "language": {"type":"string"}, "skip_text_pages": {"type":"boolean"} }), &["paths","out_dir","steps"])), + t("attachment_add", "Attach a file", "Attach the file at path to the document (document level, the Attachments panel; not a paper-clip comment on a page). name defaults to the file's name; the attachment's key, unique in the document, is returned. Undoable.") + .cmd("edit.attach") + .with(schema(json!({"doc": doc(), "path": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "path"])), + t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.") + .destructive() + .with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])), t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.") .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, attachments, fonts, security and repair notes.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security and repair notes.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), @@ -158,10 +167,10 @@ pub fn tools() -> Vec { )), t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), - t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") + t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 }, - "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." }, + "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, "hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." } }), &["doc", "page"])), t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.") @@ -278,7 +287,7 @@ pub fn tools() -> Vec { t( "bookmark_list", "List bookmarks", - "The bookmarks in outline order, one page at a time: each with its path (1-based, as the other bookmark tools take it), title, target page and open state, and its children on the same page. next is the offset of the following page, or null at the end. Bookmarks nested deeper than 33 levels are not listed.", + "The bookmarks in outline order, one page at a time: each with its path (1-based, as the other bookmark tools take it), title, target page and open state, its colour ([r, g, b], 0-1) and bold or italic style when it has them, and its children on the same page. next is the offset of the following page, or null at the end. Bookmarks nested deeper than 33 levels are not listed.", ) .ro() .with(schema( @@ -289,8 +298,21 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("bookmark_add", "Add a bookmark", "Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. Undoable.").with(schema( - json!({ "doc": doc(), "title": { "type": "string", "minLength": 1 }, "page": { "type": "integer", "minimum": 1 }, "parent": path("Parent bookmark (omit for the top level)"), "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." } }), + t( + "bookmark_add", + "Add a bookmark", + "Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. It goes to the top of the page, or to a place on it: `top` and `left` are points from the top-left of the displayed page (like the destination bookmark_list reports), each optional, and a coordinate left out keeps the reader's position that way. The zoom is kept. Undoable.", + ) + .with(schema( + json!({ + "doc": doc(), + "title": { "type": "string", "minLength": 1 }, + "page": { "type": "integer", "minimum": 1 }, + "parent": path("Parent bookmark (omit for the top level)"), + "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." }, + "top": { "type": "number", "minimum": -1000000, "maximum": 1000000, "description": "Points from the top of the displayed page." }, + "left": { "type": "number", "minimum": -1000000, "maximum": 1000000, "description": "Points from the left of the displayed page." } + }), &["doc", "title", "page"], )), t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.") @@ -304,12 +326,60 @@ pub fn tools() -> Vec { )), t("bookmark_set_page", "Set a bookmark's page", "Point a bookmark at another page. Undoable.") .with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "page": { "type": "integer", "minimum": 1 } }), &["doc", "path", "page"])), + t( + "bookmark_set_style", + "Set a bookmark's style and colour", + "Colour a bookmark's title and make it bold or italic (ISO 32000-2 §12.3.3: /C and /F), as bookmark_list reports them. Give any of color ([r, g, b], each 0-1; [0, 0, 0] is the default black), bold and italic; what is left out stays as it is. Undoable.", + ) + .with(schema( + json!({ + "doc": doc(), + "path": path("The bookmark"), + "color": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 3, "maxItems": 3 }, + "bold": { "type": "boolean" }, + "italic": { "type": "boolean" } + }), + &["doc", "path"], + )), t( "bookmark_from_structure", "New bookmarks from structure", "Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.", ) .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "text_styles", + "List text styles", + "The styles the document's text is set in, the largest first: font (without its subset tag), the size it is shown at in points, bold, italic, how many lines and pages use it, and the first line in it as a sample. For choosing the styles bookmark_from_text_styles makes bookmarks from (headings are usually the larger, rarer styles). Reads the first 5000 pages.", + ) + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "bookmark_from_text_styles", + "New bookmarks from text styles", + "Make bookmarks from the lines set in chosen text styles, for documents whose headings are not tagged: levels lists one style per bookmark level ({font, size, bold, italic} as text_styles gives them; sizes within a quarter point match), the first level 1, the next nested under the bookmark before it, and so on. A heading set on several lines is one bookmark, and each goes to the top of its heading. They go under a new first bookmark titled \"Untitled\". Fails if no text is set in the styles. Returns the first page of the bookmark tree (see bookmark_list). Undoable.", + ) + .with(schema( + json!({ + "doc": doc(), + "levels": { + "type": "array", + "minItems": 1, + "maxItems": 9, + "items": { + "type": "object", + "properties": { + "font": { "type": "string", "minLength": 1 }, + "size": { "type": "number", "exclusiveMinimum": 0 }, + "bold": { "type": "boolean" }, + "italic": { "type": "boolean" } + }, + "required": ["font", "size"] + } + } + }), + &["doc", "levels"], + )), t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema( json!({ "doc": doc(), @@ -321,7 +391,7 @@ pub fn tools() -> Vec { }), &["doc", "from", "to"], )), - t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).") + t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t( @@ -336,7 +406,7 @@ pub fn tools() -> Vec { t( "form_add_field", "Prepare form: add a field", - "Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1). Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.", + "Add a form field on a page. type: text, date, checkbox, radio, combo, list, button, image (a picture placeholder; fill it with form_set_image), signature. rect in points from the top-left of the displayed page [x0, y0, x1, y1]. name defaults to Acrobat's next free name (Text1, Check Box1, Group1, Dropdown1, List Box1, Button1, Image1, Signature1, Date1); a dotted name such as MASTER.first makes the field first under the parent field MASTER. Radio buttons join the radio group named by group (a new group otherwise) with the export value export. Returns the field's name. Undoable.", ) .with(schema( json!({ @@ -355,7 +425,28 @@ pub fn tools() -> Vec { }), &["doc", "page", "type", "rect"], )), - t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1) or an image file, centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt. name labels it (default: the file name). Undoable.") + t( + "form_add_field_table", + "Prepare form: add a table of text fields", + "Divide rect into rows and columns of text fields, one per cell, as one undo step. rect in points from the top-left of the displayed page [x0, y0, x1, y1]; rows 1-100 from the top; columns 1-50, or as many as names or widths list. Each column names its cells from its pattern in names: an N on its own after _ - / or : (price_N), # or {row} is the row number, {col} the column number, {seq} counts every cell along the rows, starting at first_row; with none of them the row number goes at the end. Rows count from first_row (default 1). Without names the columns are Table1_N, Table1_c2_N, … using the first free TableK. widths are relative (default equal); row_gap and column_gap are points between cells (default 0). Nothing is added if a cell would be under 4 points or a name would contain a period, repeat, or already exist; the error says which. Returns the fields' names. Undoable.", + ) + .cmd("form.add.text_table") + .with(schema( + json!({ + "doc": doc(), + "page": { "type": "integer", "minimum": 1 }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "rows": { "type": "integer", "minimum": 1, "maximum": 100 }, + "columns": { "type": "integer", "minimum": 1, "maximum": 50 }, + "names": { "type": "array", "items": { "type": "string" }, "description": "One name pattern per column." }, + "widths": { "type": "array", "items": { "type": "number", "exclusiveMinimum": 0 }, "description": "Relative column widths, one per column." }, + "row_gap": { "type": "number", "minimum": 0 }, + "column_gap": { "type": "number", "minimum": 0 }, + "first_row": { "type": "integer", "minimum": 0, "maximum": 99999 }, + }), + &["doc", "page", "rect", "rows"], + )), + t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). Undoable.") .cmd("comment.stamp") .with(schema( json!({ @@ -363,18 +454,19 @@ pub fn tools() -> Vec { "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "file_page": { "type": "integer", "minimum": 1 }, "name": { "type": "string" }, "author": { "type": "string" }, }), - &["doc", "page", "path", "at"], + &["doc", "page", "path"], )), t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.") .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])), t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) .with(schema( json!({ @@ -383,8 +475,9 @@ pub fn tools() -> Vec { "fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." }, "name": { "type": "string" }, "tooltip": { "type": "string" }, + "export_value": { "type": "string", "description": "Checkbox/radio export value; preserves appearance states and selection. Requires a single field." }, "caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." }, - "widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." }, + "widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption or export value changes (1-based, default 1). Requires caption or export_value. See form_fields.captions and export_values." }, "read_only": { "type": "boolean" }, "required": { "type": "boolean" }, "multiline": { "type": "boolean" }, @@ -400,33 +493,39 @@ pub fn tools() -> Vec { "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." }, "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." }, "format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." }, - "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." }, + "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." }, "calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." }, }), &["doc"], )), - t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.") + t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right; right to left in a document bound on the right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Without `order` or `field` it only reads. Returns the resulting order of fields (`tab_order`) and each page's tab order (`page_tabs`, as Page properties ▸ Tab Order shows it). Undoable.") .with(schema( json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), &["doc"], )), + t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with its `formula` (as form_fields shows calculate), the fields it reads (`reads`; a script's names built at run time aren't known) and its `issues`: missing_field (it reads `field`, which the form doesn't have, so it gets no value there), runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it) and built_names (its script also reads fields by names it builds at run time, which aren't checked). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.") + .cmd("form.calculation_order") + .with(schema( + json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }), + &["doc"], + )), t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.") .destructive() .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])), - t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.") + t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content), or the comments of another PDF, each onto the page with the same number (comments on pages past this document's last page are left out). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), - t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.") + t("form_delete_field", "Delete a field", "Delete a form field and all its widgets, or with widget only that one: one radio button of a group (widget n is the nth of the radio group's options in form_fields), or one copy of a duplicated field. A field's last widget takes the field with it. Undoable.") .destructive() - .with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])), + .with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "widget": { "type": "integer", "minimum": 1, "description": "Delete only this widget (1-based)." } }), &["doc", "field"])), t( "redact_mark", "Mark for redaction", - "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", + "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.", ) .with(schema( json!({ "doc": doc(), - "page": { "type": "integer", "minimum": 1 }, + "page": { "type": "integer", "minimum": 1, "description": "Single page to mark; mutually exclusive with pages." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "find": { "type": "string", "minLength": 1 }, "words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 }, @@ -434,7 +533,7 @@ pub fn tools() -> Vec { "whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." }, "pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] }, "whole_pages": { "type": "boolean" }, - "pages": pages("to search or mark (default: all)"), + "pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"), "overlay": { "type": "string" }, "code_set": { "type": "string", "enum": ["foia", "privacy-act"] }, "codes": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "Redaction codes from code_set, e.g. [\"(b)(6)\"]." }, @@ -487,7 +586,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, or A0/A1/A2/A3/A4/A5/A6 (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ @@ -512,7 +611,7 @@ pub fn tools() -> Vec { "printer": { "type": "string" }, "copies": { "type": "integer", "minimum": 1, "maximum": 999 }, "collate": { "type": "boolean" }, - "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge"] }, + "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." }, "grayscale": { "type": "boolean" }, "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." }, "dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." }, @@ -526,7 +625,7 @@ pub fn tools() -> Vec { t( "page_add_text", "Add text to a page", - "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.", + "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right, char_spacing (points after each character). Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.", ) .cmd("edit.text") .with(schema( @@ -538,6 +637,8 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, }), &["doc", "page", "text"], )), @@ -551,7 +652,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 } }), &["doc", "page", "path"], )), - t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.") + t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align, char_spacing in points) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, @@ -559,12 +660,28 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, + "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, "rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" }, - "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 }, + "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." }, "image": { "type": "string", "description": "Replace the picture with this file." }, }), &["doc", "page", "index"], )), + t( + "content_copy", + "Copy and paste added content", + "Copy an added item (page, index from content_list) and paste it, as Edit ▸ Copy and Paste do: onto to_page of to_doc (by default its own page and document) as it is: a text box's text, font, size, colour, alignment and box; an image's picture, box, turns, flips and crop (within its document the same image object is drawn again; into another one the picture goes as its JPEG or JPEG 2000 file, or else a PNG, without a colour profile and with CMYK colours turned to RGB). It goes at at [x, y] (its top-left, points from the top-left of the page), else where it was, 18 points down and to the right past each copy of it already there; kept on the page. Refused when the source document doesn't allow copying. Returns the new item's page, index and rect. Undoable in the target document.", + ) + .with(schema( + json!({ + "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, + "to_doc": { "type": "integer", "minimum": 1, "description": "The document to paste into (default doc)." }, + "to_page": { "type": "integer", "minimum": 1, "description": "The page to paste onto (default page)." }, + "at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }, + }), + &["doc", "page", "index"], + )), t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), @@ -616,13 +733,13 @@ pub fn tools() -> Vec { .ro().cmd("measure.info").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"points":measure_points(),"type":{"type":"string","enum":["distance","perimeter","area"]}}), &["doc","page","points"])), t("measure_list", "List measurements", "Saved measurement annotations with calculated values, scale and vertices in display coordinates. Measurements with unsupported imported formats (compound or fractional units, non-rectilinear scales) or invalid geometry are listed under unsupported with a reason.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc"])), - t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.") - .cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])), + t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using a ratio (page_distance in page_unit on the page = distance in unit, e.g. 1 mm = 50 mm for a 1:50 plan; page_unit defaults to unit), units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.") + .cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"page_distance":{"type":"number","exclusiveMinimum":0},"page_unit":{"type":"string","enum":["pt","in","mm","cm"]},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])), t("measure_snap", "Snap a measurement vertex", "Snap a point to vector paths, endpoints, midpoints or intersections. Coordinates and tolerance are in display points. Bounded extraction reports truncated geometry.") .ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])), t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.") .cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])), - t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.") + t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark).") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), t( @@ -653,7 +770,7 @@ pub fn tools() -> Vec { "stamp": { "type": "string" }, "dynamic": { "type": "boolean" }, "strokes": { "type": "array", "items": { "type": "array", "items": point() } }, - "icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph; attachment: PushPin, Paperclip, Graph, Tag." }, + "icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph (the standard names), Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow; attachment: PushPin, Paperclip, Graph, Tag." }, "color": color(), "fill": color(), "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, @@ -750,7 +867,7 @@ pub fn tools() -> Vec { }), &["doc", "id", "out"], )), - t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema( + t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, a .p7b/.p7c bundle, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema( json!({ "paths": { "type": "array", "items": { "type": "string" } }, "password": { "type": "string" }, "clear": { "type": "boolean" } }), &[], )), @@ -762,6 +879,11 @@ pub fn tools() -> Vec { comment_ref(json!({ "locked": { "type": "boolean", "description": "Default true." } })), &["doc"], )), + t("layer_set", "Show or hide a layer", "Show or hide a layer (optional content group), as the Layers panel's eye icon does. layer is its name as doc_info lists it (every layer with that name) or its 1-based position in that list. A view setting: page_render draws with it, and the file doesn't change. Returns every layer with its visibility.") + .with(schema( + json!({ "doc": doc(), "layer": { "type": ["string", "integer"], "description": "A layer name, or a 1-based position in doc_info's layers." }, "visible": { "type": "boolean" } }), + &["doc", "layer", "visible"], + )), t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.") .cmd("comment.hide_all") .with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])), @@ -784,22 +906,23 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "width": { "type": "number", "minimum": 0 }, - "fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." }, + "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." }, "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), + "points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." }, })), &["doc"], )), t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), - t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), t( "doc_protect", "Protect with passwords", @@ -869,7 +992,7 @@ pub fn tools() -> Vec { t( "doc_header_footer", "Add header and footer", - "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> (and more), Bates <>. replace: true swaps out existing ones (Update). Undoable.", + "Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> <> (and more), Bates <>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.", ) .cmd("edit.header_footer") .with(schema( @@ -878,10 +1001,12 @@ pub fn tools() -> Vec { "pages": pages("to mark (default: all)"), "header_left": { "type": "string" }, "header_center": { "type": "string" }, "header_right": { "type": "string" }, "footer_left": { "type": "string" }, "footer_center": { "type": "string" }, "footer_right": { "type": "string" }, + "font": { "type": "string", "enum": ["Helvetica", "Times", "Courier"] }, "font_size": { "type": "number", "exclusiveMinimum": 0 }, "color": { "type": "string", "description": "#RRGGBB or a colour name." }, "margins": { "type": "array", "items": { "type": "number", "minimum": 0 }, "minItems": 4, "maxItems": 4, "description": "Top, bottom, left, right in points (default 36, 36, 72, 72)." }, "start_number": { "type": "integer", "minimum": 1 }, + "shrink": { "type": "boolean", "description": "Shrink the page content so the header and footer don't cover it (default false)." }, "replace": { "type": "boolean" }, }), &["doc"], @@ -907,12 +1032,16 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. Undoable.") + t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. A page that paints its own opaque white backdrop would hide it, so that backdrop is replaced unless replace_backdrop is false (removing the background turns it back on where that is still safe, and doc_remove_marks warns about the pages where it isn't); a scanned page's picture still hides it (doc_backdrops lists both). Undoable.") .cmd("edit.background") .with(schema( - json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" } }), + json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }), &["doc"], )), + t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.") + .ro() + .cmd("edit.background") + .with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])), t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])), @@ -1005,7 +1134,7 @@ pub fn tools() -> Vec { t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.") .cmd("a11y.alt_text") .with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])), - t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language, one page at a time: count is how many tags match, next the offset of the following page (null at the end). types, pages and max_depth keep only some tags. Inline tag dictionaries have no stable object reference and are not listed.") + t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text, language, a table's summary and a header cell's scope, one page at a time: count is how many tags match, next the offset of the following page (null at the end). types, pages and max_depth keep only some tags. Inline tag dictionaries have no stable object reference and are not listed.") .ro() .cmd("a11y.tags") .with(schema( @@ -1023,11 +1152,11 @@ pub fn tools() -> Vec { .ro() .cmd("a11y.tags") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 } }), &["doc", "object", "generation"])), - t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.") + t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language, summary (Table tags: the table's Summary attribute), scope (TH tags: Row, Column or Both). Summary and scope are written in the tag's Table attribute object (/A, /O /Table); the role map decides a tag's type. Empty or omitted value clears text properties, summary and scope; type requires a nonempty PDF name. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, - "property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] }, + "property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language", "summary", "scope"] }, "value": { "type": "string" }, }), &["doc", "object", "generation", "property"])), t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.") @@ -1046,7 +1175,7 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts. Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit) and error.") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), @@ -1065,7 +1194,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), - t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.") + t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.") .cmd("export.docx") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") @@ -1078,6 +1207,8 @@ pub fn tools() -> Vec { t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), + t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") + .with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])), t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).") .ro() .cmd("actions.wizard") @@ -1122,7 +1253,7 @@ pub fn tools() -> Vec { )), t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.") .with(schema(json!({ "enabled": { "type": "boolean" } }), &[])), - t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") + t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text (left out when the document's security doesn't allow copying), word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") .cmd("ocr.recognize") .with(schema( json!({ @@ -1134,7 +1265,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone unless skip_text_pages is false, as in ocr_recognize). Returns, per file, the output path, word count and skipped pages, or the error.") + t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone unless skip_text_pages is false, as in ocr_recognize). Two inputs that would write the same output file are refused before anything is read or written. Returns, per file, the output path, word count and skipped pages, or the error.") .cmd("ocr.recognize_batch") .with(schema( json!({ @@ -1150,7 +1281,7 @@ pub fn tools() -> Vec { .ro() .cmd("ocr.recognize") .with(schema(json!({}), &[])), - t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.") + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2 inside other filters, separations) are listed under skipped. Includes unsaved edits.") .cmd("export.all_images") .with(schema( json!({ @@ -1167,7 +1298,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", ) .cmd("sign.fill.text") .with(schema( @@ -1178,6 +1309,7 @@ pub fn tools() -> Vec { "at": point(), "text": { "type": "string", "minLength": 1 }, "font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." }, + "color": color(), "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." }, "scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." }, "sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." }, @@ -1208,7 +1340,7 @@ pub fn tools() -> Vec { }), &["from"], )), - t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.") + t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.") .cmd("optimize.reduce") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema( @@ -1246,16 +1378,17 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") + t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, - "action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] }, + "action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "quarters": { "type": "integer" }, + "degrees": { "type": "number" }, "path": { "type": "string" }, }), &["doc", "page", "image", "action"], @@ -1273,7 +1406,7 @@ pub fn tools() -> Vec { t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.") .ro().cmd("edit.edit_text") .with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])), - t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.") + t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph: until you list the page again, a number names the paragraph listed here, even after edits regroup the page.") .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), @@ -1281,7 +1414,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.") + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used, and for a paragraph its text unless the document's security doesn't allow copying. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, @@ -1295,6 +1428,7 @@ pub fn tools() -> Vec { "size": { "type": "number", "exclusiveMinimum": 0 }, "color": { "type": "string", "description": "#rrggbb" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, "underline": { "type": "boolean" }, "line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." }, "dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." }, @@ -1308,21 +1442,25 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ "doc": doc(), "path": { "type": "string" }, "preset": { "type": "string", "enum": ["standard", "mobile"] }, + "compatibility": { "type": "string", "enum": ["retain", "1.4", "1.5", "1.6", "1.7", "2.0"] }, "merge_fragments": { "type": "boolean" }, "color": { "type": "object" }, "gray": { "type": "object" }, + "mono": { "type": "object" }, "discard_thumbnails": { "type": "boolean" }, "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, + "object_streams": { "type": "boolean" }, + "lzw_to_flate": { "type": "boolean" }, "remove_invalid_links": { "type": "boolean" }, "remove_unreferenced_dests": { "type": "boolean" }, "discard": { "type": "array", "items": { "type": "string" } }, @@ -1350,7 +1488,7 @@ pub fn tools() -> Vec { .destructive().with(schema(json!({"steps":{"type":"array","items":schema(json!({"id":{"type":"string"},"params":{"type":"object"}}), &["id"])},"stop_on_error":{"type":"boolean"}}), &["steps"])), t("doc_inspect", "Inspect open documents", "With doc, return doc_info; otherwise return information for every open document (empty documents array when none are open).") .ro().with(schema(json!({"doc":doc()}), &[])), - t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open.") + t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open. Render warnings follow the image as page_render gives them.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[])), ] } diff --git a/crates/automation/tests/it/attachment_key.rs b/crates/automation/tests/it/attachment_key.rs new file mode 100644 index 0000000000..25b0d6c602 --- /dev/null +++ b/crates/automation/tests/it/attachment_key.rs @@ -0,0 +1,34 @@ +//! `attachment_add` reports the name-tree key of the file it added, even when a file attached to +//! a page as a comment already has the same name. + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::{AttachIcon, Edit, NewAnnotation, Session, Shape, Style}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +#[test] +fn attachment_add_reports_its_own_key_when_a_page_comment_has_the_same_file_name() { + let dir = tempfile::tempdir().unwrap(); + let mut session = Session::new(); + let bytes = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open("a.pdf", None, bytes, None).unwrap(); + let shape = Shape::Attachment { at: [20.0, 300.0], icon: AttachIcon::Paperclip, file: "notes.txt".into(), data: b"on the page".to_vec() }; + session + .apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, shape, style: Style::default(), contents: String::new(), author: "Ada".into() })) + .unwrap(); + std::fs::write(dir.path().join("a.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap(); + std::fs::write(dir.path().join("notes.txt"), b"for the document").unwrap(); + + let mut auto = Automation::new().with_root(dir.path()).unwrap(); + let doc = tool(&mut auto, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let added = tool(&mut auto, "attachment_add", json!({ "doc": doc, "path": "notes.txt" })); + assert_eq!(added["name"], "notes.txt", "{added}"); + let again = tool(&mut auto, "attachment_add", json!({ "doc": doc, "path": "notes.txt" })); + assert_eq!(again["name"], "notes.txt (2)", "{again}"); +} diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/it/automation.rs similarity index 63% rename from crates/automation/tests/automation.rs rename to crates/automation/tests/it/automation.rs index f3bcfd21a4..6a5765b911 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -64,6 +64,65 @@ fn remote_link_pdf(file: &str, destination: Option<&str>) -> Vec { bytes } +#[cfg(target_os = "macos")] +#[test] +fn saving_a_pdf_keeps_the_destinations_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = workdir("save-xattrs"); + let target = dir.join("a.pdf"); + let tags = b"PdfCraft\n6"; + let binary = b"\0\xffpdfcraft\0"; + let attributes: &[(&str, &[u8])] = &[("com.apple.metadata:_kMDItemUserTags", tags), ("com.pdfcraft.test", binary)]; + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + for full in [false, true] { + ok(&mut a, "page_rotate", json!({"doc": doc, "pages": [1], "degrees": 90})); + let saved = ok(&mut a, "doc_save", json!({"doc": doc, "full": full})); + assert_eq!(saved["incremental"], !full); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = getxattr(&target, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "full={full}, {name}"); + } + } + // Save As preserves the destination's metadata, not attributes from the open input file. + let other = dir.join("other.pdf"); + std::fs::write(&other, fixture(1)).unwrap(); + setxattr(&other, "com.pdfcraft.test", b"destination", XattrFlags::empty()).unwrap(); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "other.pdf"})); + let mut bytes = [0u8; 256]; + let len = getxattr(&other, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"destination"); + assert_eq!(getxattr(&other, "com.apple.metadata:_kMDItemUserTags", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit() { + use rustix::fs::{XattrFlags, setxattr}; + let dir = workdir("save-xattrs-error"); + let target = dir.join("a.pdf"); + let original = std::fs::read(&target).unwrap(); + setxattr(&target, "com.apple.ResourceFork", &vec![42u8; 16 * 1024 * 1024 + 1], XattrFlags::empty()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_rotate", json!({"doc": doc, "pages": [1], "degrees": 90})); + let failure = err(&mut a, "doc_save", json!({"doc": doc})); + assert!(failure.to_string().contains("extended attributes"), "{failure}"); + assert!(failure.to_string().contains("Save As"), "{failure}"); + assert_eq!(std::fs::read(&target).unwrap(), original); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], true); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "only the two original PDFs remain"); + // A fresh name remains usable: metadata refusal doesn't discard the accepted edit. + ok(&mut a, "doc_save", json!({"doc": doc, "path": "copy.pdf"})); + assert_ne!(std::fs::read(dir.join("copy.pdf")).unwrap(), original); + std::fs::remove_dir_all(dir).unwrap(); +} + fn protected_fixture(password: &str) -> Vec { use pdfcraft_engine::{Edit, Protection, Session}; let mut session = Session::new(); @@ -154,6 +213,62 @@ fn doc_info_lists_page_transitions_and_display_times() { ); } +/// `doc_info` says where the document opens: the `/OpenAction` destination, a named one +/// resolved (ISO 32000-2 §12.3.2.4), as a 1-based page and a destination in view coordinates. +#[test] +fn doc_info_reports_the_open_destination() { + let dir = workdir("open-destination"); + let write = |name: &str, catalog: &str| { + let objects: Vec = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + std::fs::write(dir.join(name), bytes).unwrap(); + }; + write("named.pdf", "/OpenAction << /S /GoTo /D /there >> /Dests << /there [4 0 R /XYZ 100 500 2] >>"); + write("script.pdf", "/OpenAction << /S /JavaScript /JS (1) >>"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "named.pdf" }))["doc"].as_u64().unwrap(); + let open = ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].clone(); + assert_eq!(open["page"], 2, "{open}"); + // y 500 is 292 points below the top of the 792-point page. + assert_eq!( + (open["destination"]["fit"].as_str(), open["destination"]["left"].as_f64(), open["destination"]["top"].as_f64()), + (Some("XYZ"), Some(100.0), Some(292.0)), + "{open}" + ); + assert_eq!(open["destination"]["zoom"].as_f64(), Some(2.0)); + // The initial view reads the named destination's page, and choosing page 1 replaces it (ISO + // 32000-2 §12.3.2.4): one undo step, kept by the save. + let page = |a: &mut Automation, doc: u64| ok(a, "doc_info", json!({ "doc": doc }))["open_destination"]["page"].clone(); + assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc }))["initial_view"]["page"], 2); + assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc, "page": 1 }))["initial_view"]["page"], 1); + assert_eq!(page(&mut a, doc), 1); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(page(&mut a, doc), 2); + ok(&mut a, "edit_redo", json!({ "doc": doc })); + assert_eq!(page(&mut a, doc), 1); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "named-page1.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "named-page1.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(page(&mut a, saved), 1); + let doc = ok(&mut a, "doc_open", json!({ "path": "script.pdf" }))["doc"].as_u64().unwrap(); + assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].is_null()); +} + /// Page transitions through the tools: set on chosen pages, written to the file as `/Trans` and /// `/Dur`, removed again, bad values refused, and one undo step. (Read through `pdfcraft_cos`, /// so this does not lean on `doc_info` listing them.) @@ -401,6 +516,78 @@ fn autotag_form_fields_writes_reviewable_widget_tags() { assert_eq!(result["status"], "passed"); } +/// A two-page PDF with headings set apart only by their style: chapters in Helvetica-Bold 18, +/// a section in Helvetica-Bold 14, text in Helvetica 11 (#1693). +fn styled_headings_pdf() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET \ + BT /FB 14 Tf 72 640 Td (Background) Tj ET"; + let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(), + format!("<< /Length {} >>\nstream\n{page1}\nendstream", page1.len()), + format!("<< /Length {} >>\nstream\n{page2}\nendstream", page2.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + +/// #1693: text_styles lists the styles, largest first; bookmark_from_text_styles makes the +/// bookmarks from the ones given per level, returns the tree, undoes in one step and says why +/// when it can't. +#[test] +fn bookmarks_from_text_styles_through_tools() { + let dir = workdir("text-style-bookmarks"); + std::fs::write(dir.join("styled.pdf"), styled_headings_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap(); + let styles = ok(&mut a, "text_styles", json!({ "doc": doc }))["styles"].clone(); + assert_eq!( + styles, + json!([ + { "font": "Helvetica-Bold", "size": 18.0, "bold": true, "italic": false, "lines": 2, "pages": 2, "sample": "Introduction" }, + { "font": "Helvetica-Bold", "size": 14.0, "bold": true, "italic": false, "lines": 1, "pages": 1, "sample": "Background" }, + { "font": "Helvetica", "size": 11.0, "bold": false, "italic": false, "lines": 2, "pages": 2, "sample": "Some text" }, + ]) + ); + // The styles as text_styles gave them, the chapters first. + let levels = json!([styles[0].clone(), { "font": "Helvetica-Bold", "size": 14, "bold": true }]); + let r = ok(&mut a, "bookmark_from_text_styles", json!({ "doc": doc, "levels": levels })); + let top = &r["bookmarks"][0]; + assert_eq!(top["title"], "Untitled"); + let titles: Vec<(&str, u64)> = + top["children"].as_array().unwrap().iter().map(|c| (c["title"].as_str().unwrap(), c["page"].as_u64().unwrap())).collect(); + assert_eq!(titles, [("Introduction", 1), ("Results", 2)]); + assert_eq!(top["children"][0]["children"][0]["title"], "Background"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "New bookmarks from text styles"); + // What can't be done says why. + let call = |a: &mut pdfcraft_automation::Automation, levels: Value| a.call("bookmark_from_text_styles", &json!({ "doc": doc, "levels": levels })); + assert!(matches!(call(&mut a, json!([])), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(call(&mut a, json!([{ "font": "Helvetica-Bold" }])), Err(ToolError::InvalidArgs(m)) if m.contains("size"))); + assert!( + matches!(call(&mut a, json!([{ "font": "Helvetica-Bold", "size": 18, "bold": "yes" }])), Err(ToolError::InvalidArgs(m)) if m.contains("bold")) + ); + let unused = call(&mut a, json!([{ "font": "Times-Roman", "size": 30 }])); + assert!(matches!(&unused, Err(ToolError::Failed(m)) if m.contains("no text is set in the chosen styles")), "{unused:?}"); + let too_many: Vec = (0..10).map(|_| styles[0].clone()).collect(); + assert!(matches!(call(&mut a, json!(too_many)), Err(ToolError::InvalidArgs(_)))); +} + /// A one-page PDF whose form holds one text field named `name`. fn form_pdf(name: &str) -> Vec { let objs: Vec = vec![ @@ -480,7 +667,7 @@ fn tool_table_is_well_formed() { #[test] fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() { let dir = workdir("font-ranges"); - std::fs::write(dir.join("inter.ttf"), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + std::fs::write(dir.join("inter.ttf"), include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")).unwrap(); let mut a = auto(&dir); std::fs::write(dir.join("broken.otf"), b"not a font").unwrap(); let found = ok(&mut a, "text_font_files", json!({})); @@ -779,6 +966,117 @@ fn edit_undo_redo_and_save_round_trip() { assert_eq!(after.windows(5).filter(|w| w == b"%%EOF").count(), 2); } +/// A shallow-object destination tree: `depth` single-child nodes above `count` names. +fn destination_graph_fixture(depth: usize, count: usize) -> Vec { + use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions}; + let mut doc = Document::open(std::sync::Arc::new(fixture(3))).unwrap(); + let pages = pdfcraft_organize::pages(&doc).unwrap(); + let pairs = (0..count) + .flat_map(|i| { + let page = if i % 2 == 0 { pages[0].obj } else { pages[2].obj }; + [Object::String(PdfString::text(&format!("dest-{i:05}"))), Object::Array(vec![Object::Ref(page), Object::name("Fit")])] + }) + .collect(); + let mut leaf = Dict::new(); + leaf.set(b"Names".to_vec(), Object::Array(pairs)); + let mut tree = doc.add(leaf); + for _ in 0..depth { + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)])); + tree = doc.add(node); + } + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + pdfcraft_cos::write_incremental(&doc, &SaveOptions::default()).unwrap() +} + +/// Inspect the actual working bytes, not just page counts or history labels. The fixture has +/// exactly `depth` single-child nodes, so this checks its leaf without a second general walker. +fn assert_destination_state(a: &Automation, doc: u64, depth: usize, count: usize, deleted: bool) { + use pdfcraft_cos::{Document, Object}; + let bytes = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone(); + let parsed = Document::open(bytes).unwrap(); + let pages = pdfcraft_organize::pages(&parsed).unwrap(); + assert_eq!(pages.len(), if deleted { 2 } else { 3 }); + let catalog = parsed.get(parsed.root().unwrap()); + let names = parsed.resolve(catalog.as_dict().unwrap().get(b"Names").unwrap()); + let mut node = parsed.resolve(names.as_dict().unwrap().get(b"Dests").unwrap()); + for _ in 0..depth { + let kids = node.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + assert_eq!(kids.len(), 1); + let child = kids[0].clone(); + node = parsed.resolve(&child); + } + let pairs = node.as_dict().unwrap().get(b"Names").and_then(Object::as_array).unwrap(); + assert_eq!(pairs.len(), if deleted { count / 2 * 2 } else { count * 2 }); + let expected = (0..count).filter(|i| !deleted || i % 2 == 1); + for (pair, i) in pairs.as_chunks::<2>().0.iter().zip(expected) { + assert_eq!(pair[0].as_string().unwrap().to_text(), format!("dest-{i:05}")); + let target = if i % 2 == 0 { pages[0].obj } else { pages.last().unwrap().obj }; + assert_eq!(pair[1].as_array().unwrap()[0].as_ref(), Some(target), "destination {i}"); + } +} + +/// Exercise the real tools, including named destinations after undo and redo, and check both +/// kinds of save explicitly: a full copy, followed by an incremental update of that copy. +fn delete_destination_tree_through_tools(depth: usize, count: usize, label: &str) { + let dir = workdir(label); + let original = destination_graph_fixture(depth, count); + std::fs::write(dir.join("source.pdf"), &original).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap(); + assert_destination_state(&a, doc, depth, count, false); + let render = a.call("page_render", &json!({"doc":doc,"page":2,"dpi":72})).unwrap(); + ok(&mut a, "doc_set_info", json!({"doc":doc,"key":"Title","value":"Unsaved work"})); + ok(&mut a, "page_delete", json!({"doc":doc,"pages":[1]})); + assert_destination_state(&a, doc, depth, count, true); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3"]); + assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), render); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_destination_state(&a, doc, depth, count, false); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["title"], "Unsaved work"); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert_destination_state(&a, doc, depth, count, true); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3"]); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["title"], "Unsaved work"); + + let saved = ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf","full":true})); + assert_eq!(saved["incremental"], false); + let full = std::fs::read(dir.join("saved.pdf")).unwrap(); + let mut b = auto(&dir); + let reopened = ok(&mut b, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_destination_state(&b, reopened, depth, count, true); + assert_eq!(b.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), render); + + assert_eq!(ok(&mut b, "doc_info", json!({"doc":reopened}))["title"], "Unsaved work"); + ok(&mut b, "doc_set_info", json!({"doc":reopened,"key":"Author","value":"Incremental probe"})); + let saved = ok(&mut b, "doc_save", json!({"doc":reopened})); + assert_eq!(saved["incremental"], true); + let incremental = std::fs::read(dir.join("saved.pdf")).unwrap(); + assert!(incremental.len() > full.len() && incremental.starts_with(&full)); + let mut b = auto(&dir); + let reopened = ok(&mut b, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_destination_state(&b, reopened, depth, count, true); + assert_eq!(page_text(&mut b, reopened), ["Page 2", "Page 3"]); + assert_eq!(b.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), render); + let info = ok(&mut b, "doc_info", json!({"doc":reopened})); + assert_eq!(info["title"], "Unsaved work"); + assert_eq!(info["author"], "Incremental probe"); + assert_eq!(std::fs::read(dir.join("source.pdf")).unwrap(), original); +} + +#[test] +fn page_delete_handles_deep_destination_trees_with_history_and_reopen() { + delete_destination_tree_through_tools(16_384, 2, "deep-destinations"); +} + +#[test] +fn page_delete_handles_sixty_thousand_destinations_with_history_and_reopen() { + delete_destination_tree_through_tools(0, 60_000, "many-destinations"); +} + /// Issue #1329: an in-place save says it was incremental only when it kept the file it was /// saving over: protecting or sanitizing rewrites the file, and the result says so. #[test] @@ -1804,21 +2102,33 @@ fn comments_through_tools() { let dir = workdir("comments"); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = add_comment_examples(&mut a, doc); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let rect = verify_comment_listing(&list, &id); + edit_rectangle_comment(&mut a, doc, &rect); + edit_text_comment(&mut a, doc, &list); + verify_comment_errors(&mut a, doc, &id); + save_and_reopen_comments(&dir, &mut a, doc); +} + +fn add_comment_examples(a: &mut Automation, doc: u64) -> String { // "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page. - let hl = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); + let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); assert_eq!(hl["comment"]["lines"], 1); let id = hl["comment"]["id"].as_str().unwrap().to_string(); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] })); - ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" })); - ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" })); + ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] })); + ok(a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] })); + ok(a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" })); + ok(a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" })); + id +} - let list = ok(&mut a, "comment_list", json!({ "doc": doc })); +fn verify_comment_listing(list: &Value, id: &str) -> Value { assert_eq!(list["count"], 6, "{list}"); - let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id.as_str()).unwrap().clone(); + let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone(); assert_eq!((c["type"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("Highlight"), Some("Ada"), Some("check"))); assert_eq!(c["status"], "Accepted"); assert_eq!(c["replies"].as_array().unwrap().len(), 1); @@ -1831,33 +2141,38 @@ fn comments_through_tools() { assert_eq!(rect["rect"], json!([10.0, 10.0, 60.0, 40.0])); let note = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Text").unwrap().clone(); assert_eq!(note["rect"], json!([150.0, 20.0, 170.0, 40.0]), "note icon hangs from its top-left point"); + rect +} +fn edit_rectangle_comment(a: &mut Automation, doc: u64, original: &Value) { // Edit by page + index; several changes are one undo step. - let (page, index) = (rect["page"].as_u64().unwrap(), rect["index"].as_u64().unwrap()); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" })); - let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + let (page, index) = (original["page"].as_u64().unwrap(), original["index"].as_u64().unwrap()); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" })); + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); let rect = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Square").unwrap().clone(); assert_eq!((rect["color"].as_str(), rect["contents"].as_str()), (Some("#FF0000"), Some("moved"))); assert_eq!(rect["rect"], json!([15.0, 15.0, 65.0, 45.0])); - let undo = ok(&mut a, "edit_undo", json!({ "doc": doc })); + let undo = ok(a, "edit_undo", json!({ "doc": doc })); assert_eq!(undo["undone"], "Edit comment"); - ok(&mut a, "edit_redo", json!({ "doc": doc })); + ok(a, "edit_redo", json!({ "doc": doc })); // Rectangles, ovals and polygons take a fill (#686); "none" removes it. Lines have none. - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" })); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" })); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" })); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" })); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "fill": "mauve" })), Err(ToolError::InvalidArgs(_)))); - let arrow = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone(); + let arrow = ok(a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone(); assert!(matches!( a.call("comment_edit", &json!({ "doc": doc, "page": arrow["page"], "index": arrow["index"], "fill": "red" })), Err(ToolError::Failed(_)) )); +} +fn edit_text_comment(a: &mut Automation, doc: u64, original_list: &Value) { // Editing a text box re-fits its rectangle to the new text: the wrap width and top edge // stay, the height follows the wrapped lines. - let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); + let tb = original_list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); let long = "the quick brown fox jumps over the lazy dog ".repeat(3); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() })); - let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + ok(a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() })); + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); let r: Vec = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}"); @@ -1866,9 +2181,11 @@ fn comments_through_tools() { // The rendered page shows the rectangle's border. let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); assert!(matches!(png[0], Content::Png { .. })); +} - ok(&mut a, "comment_delete", json!({ "doc": doc, "id": id })); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 5); +fn verify_comment_errors(a: &mut Automation, doc: u64, id: &str) { + ok(a, "comment_delete", json!({ "doc": doc, "id": id })); + assert_eq!(ok(a, "comment_list", json!({ "doc": doc }))["count"], 5); assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "highlight", "find": "nowhere" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle" })), Err(ToolError::InvalidArgs(_)))); @@ -1877,10 +2194,11 @@ fn comments_through_tools() { Err(ToolError::InvalidArgs(_)) )); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1 })), Err(ToolError::InvalidArgs(_)))); +} - // Comments survive a save and reopen. - ok(&mut a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" })); - let mut b = auto(&dir); +fn save_and_reopen_comments(dir: &Path, a: &mut Automation, doc: u64) { + ok(a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" })); + let mut b = auto(dir); let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5); } @@ -1908,6 +2226,112 @@ fn nested_replies_stay_in_the_listing() { assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}"); } +/// comment_delete on a reply deletes that reply and the replies to it, as the Comments panel's +/// reply menu does: the comment and its other replies stay, and one undo brings it back. +#[test] +fn deleting_a_reply_keeps_its_comment_and_the_other_replies() { + let dir = workdir("delete-reply"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let root = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "ROOT_NOTE", "author": "A" })); + let root_id = root["comment"]["id"].as_str().unwrap().to_string(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "WRONG_REPLY", "author": "B" })); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "RIGHT_REPLY", "author": "C" })); + let replies = |a: &mut Automation| -> Vec { + let list = ok(a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "{list}"); + list["comments"][0]["replies"].as_array().unwrap().clone() + }; + let wrong = replies(&mut a).into_iter().find(|r| r["contents"] == "WRONG_REPLY").unwrap(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": wrong["id"], "text": "ANSWER_TO_WRONG", "author": "D" })); + let texts = |a: &mut Automation| -> Vec { replies(a).iter().filter_map(|r| r["contents"].as_str().map(str::to_owned)).collect() }; + assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]); + + ok(&mut a, "comment_delete", json!({ "doc": doc, "page": wrong["page"], "index": wrong["index"] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["comments"][0]["contents"], "ROOT_NOTE", "{list}"); + assert_eq!(texts(&mut a), ["RIGHT_REPLY"], "the reply took the answer to it along"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Delete comment"); + assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]); +} + +/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every +/// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment. +#[test] +fn replies_thread_by_reference_when_ids_repeat_across_pages() { + let dir = workdir("repeated-ids"); + // Contributor-original PDF, shaped like PyMuPDF output. + std::fs::write( + dir.join("fitz.pdf"), + b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj +trailer << /Root 1 0 R >> +%%EOF", + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "fitz.pdf" }))["doc"].as_u64().unwrap(); + let replies = |a: &mut Automation| -> Vec<(String, Vec)> { + let list = ok(a, "comment_list", json!({ "doc": doc })); + list["comments"] + .as_array() + .unwrap() + .iter() + .map(|c| { + let mut texts: Vec = + c["replies"].as_array().unwrap().iter().filter_map(|r| r["contents"].as_str().map(String::from)).collect(); + texts.sort(); + (c["contents"].as_str().unwrap().to_string(), texts) + }) + .collect() + }; + assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string()])]); + let refused = err(&mut a, "comment_reply", json!({ "doc": doc, "id": "fitz-A0", "text": "LATER" })); + assert!(refused.to_string().contains("several comments"), "{refused}"); + ok(&mut a, "comment_reply", json!({ "doc": doc, "page": 2, "index": 1, "text": "LATER" })); + assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string(), "LATER".to_string()])]); +} + +/// A status or checkmark belongs to the annotation its `/IRT` names: one set on a reply is not +/// its comment's, as the Comments panel shows it, and is reported on the reply. One whose +/// annotation can't be found is not a comment of its own. +#[test] +fn a_checkmark_on_a_reply_is_not_its_comments() { + let dir = workdir("reply-checkmark"); + // Contributor-original PDF: a comment, a reply, a checkmark and a status on the reply, and a + // status whose annotation (object 9) doesn't exist. + std::fs::write( + dir.join("checked.pdf"), + b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 120 120] /T (Alpha) /Contents (COMMENT) >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 4 0 R /T (Beta) /Contents (REPLY) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Marked /State /Marked >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Review /State /Accepted >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [200 20 220 40] /F 30 /IRT 9 0 R /T (Gamma) /StateModel /Review /State /Rejected >> endobj +trailer << /Root 1 0 R >> +%%EOF", + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "checked.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "{list}"); + let c = &list["comments"][0]; + assert_eq!((&c["marked"], &c["status"]), (&json!(false), &Value::Null), "{c}"); + assert_eq!(c["replies"].as_array().map(Vec::len), Some(1), "the reply, not its states: {c}"); + let reply = &c["replies"][0]; + assert_eq!((&reply["marked"], &reply["status"]), (&json!(true), &json!("Accepted")), "{reply}"); +} + #[test] fn protecting_through_tools() { let dir = workdir("protect"); @@ -1960,6 +2384,44 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +/// A document that allows changes but not copying: the tools that return a page's text refuse, +/// or leave the text out where the tool also changes the page (a restyled paragraph, recognized +/// words). +#[test] +fn copy_restricted_text_stays_in_the_document() { + let dir = workdir("copy-restricted-text"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let locked = json!({ "open_password": "open", "permissions_password": "boss", "changes": "any-except-extract" }); + let mut protect = locked.clone(); + protect["doc"] = json!(doc); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" })); + let re = ok(&mut a, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + match a.call("text_lines", &json!({ "doc": re, "page": 1 })) { + Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{m}"), + other => panic!("text_lines is refused: {other:?}"), + } + let restyled = ok(&mut a, "text_edit", json!({ "doc": re, "page": 1, "paragraph": 1, "size": 30 })); + assert!(!restyled.to_string().contains("Page"), "{restyled}"); + if ok(&mut a, "ocr_status", json!({}))["available"] != true { + eprintln!("skipped the OCR part: OCR models not installed"); + return; + } + let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Recognised words stay private" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] })); + let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path(); + let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap(); + let mut protect = locked; + protect["doc"] = json!(scan); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": scan, "path": "scan.pdf" })); + let scan = ok(&mut a, "doc_open", json!({ "path": "scan.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "ocr_recognize", json!({ "doc": scan })); + assert!(r["words"].as_u64().unwrap() >= 3, "{r}"); + assert!(r["pages"][0].get("text").is_none() && !r.to_string().to_lowercase().contains("private"), "{r}"); +} + /// A form secured with "fill-sign" changes and no open password (how secured forms are usually /// distributed): its existing signature field can be signed without the permissions password, /// the update keeps the encryption, and the signature validates after reopening. A new field @@ -2123,6 +2585,51 @@ fn forms_through_tools() { assert_ne!(v, "Filled by an agent"); } +/// A PDF of the given objects (numbered from 1). +fn pdf_of(objs: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #4018: `form_fill` warns about a value its box cuts off at the field's fixed font size (the +/// value is saved whole), and not about one that fits. +#[test] +fn form_fill_warns_when_a_value_does_not_fit() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", + "<< /Fields [5 0 R 6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (narrow) /Rect [10 250 150 268] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (wide) /Rect [10 200 310 218] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("form-overflow"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let long = "Doe, Jane (represented by John Doe)"; + let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "narrow": long, "wide": long } })); + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + assert_eq!(warnings.len(), 1, "{r}"); + assert!(warnings[0].starts_with("form field \"narrow\" (page 1): the value does not fit"), "{}", warnings[0]); + let after = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!(after["fields"][0]["value"], long, "saved whole"); + let r = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "narrow": "Ada" } })); + assert!(r.get("warnings").is_none(), "{r}"); +} + /// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and /// resets through the tools, and the leniency shows up as a repair note. #[test] @@ -2138,19 +2645,7 @@ fn orphan_form_fields_through_tools() { "<< /Type /Annot /Subtype /Widget /FT /Tx /T (beta) /Rect [10 150 90 170] /P 3 0 R >>", // 6 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", // 7 ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); - out + pdf_of(&objs) } let dir = workdir("orphan-forms"); std::fs::write(dir.join("orphan.pdf"), orphan_form()).unwrap(); @@ -2174,6 +2669,29 @@ fn orphan_form_fields_through_tools() { assert_ne!(v, "Filled by an agent"); } +/// An auto-sized (`0 Tf`) single-line field fills to the size its height gives, as Acrobat +/// draws it, not a 12 pt cap: 30 pt tall, less border and padding, is about 21 pt text. +#[test] +fn auto_sized_text_follows_a_tall_field() { + let objs: Vec<&str> = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>", // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", // 3 + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>", // 4 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [10 200 290 230] /P 3 0 R >>", // 5 + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", // 6 + ]; + let dir = workdir("auto-size-tall"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "tall": "Hi" } })); + let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Hi" })); + let r = &found["matches"][0]["rects"][0]; + let height = r[3].as_f64().unwrap() - r[1].as_f64().unwrap(); + assert!(height > 16.0, "12 pt text would be about 12 tall, ~21 pt about 21: {height} ({found})"); +} + #[test] fn reversing_pages_through_tools() { let dir = workdir("reverse"); @@ -2239,6 +2757,57 @@ fn headers_watermarks_and_backgrounds_through_tools() { assert!(matches!(a.call("doc_remove_marks", &json!({ "doc": doc, "kind": "watermark" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("doc_header_footer", &json!({ "doc": doc })), Err(ToolError::Failed(_))), "no text"); ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); + // A standard face other than Helvetica (#3754), kept through save and reopen; one it doesn't + // offer is refused with the faces it does. + let mono = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_header_footer", json!({ "doc": mono, "header_left": "MONO", "font": "Courier" })); + assert!(page_text(&mut a, mono)[0].contains("MONO")); + ok(&mut a, "doc_save", json!({ "doc": mono, "path": "courier.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("courier.pdf")).unwrap())).unwrap(); + let page = &pdfcraft_model::pages(&saved)[0]; + let res = saved.resolve(page.dict.get(b"Resources").unwrap()); + let fonts = saved.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap(); + let face = saved.resolve(fonts.get(b"PCCour").expect("Courier is added")).as_dict().cloned().unwrap(); + assert_eq!(face.name(b"BaseFont"), Some(&b"Courier"[..])); + let refused = a.call("doc_header_footer", &json!({ "doc": mono, "header_left": "X", "font": "Arial" })); + assert!(matches!(refused, Err(ToolError::InvalidArgs(e)) if e.contains("Courier"))); +} + +#[test] +fn a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools() { + let dir = workdir("hf-shrink"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // The box of "Page 1" (left, top, width, height in points), read back from a saved copy. + let mut saved = 0; + let mut text_box = |a: &mut Automation| -> [f64; 4] { + saved += 1; + let path = format!("shrunk{saved}.pdf"); + ok(a, "doc_save", json!({ "doc": doc, "path": path })); + let copy = ok(a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let found = ok(a, "text_find", json!({ "doc": copy, "query": "Page 1" })); + let r = found["matches"][0]["rects"][0].as_array().unwrap_or_else(|| panic!("Page 1 is on the page: {found}")); + let v: Vec = r.iter().map(|x| x.as_f64().unwrap()).collect(); + [v[0], v[1], v[2] - v[0], v[3] - v[1]] + }; + let before = text_box(&mut a); + ok( + &mut a, + "doc_header_footer", + json!({ "doc": doc, "pages": [1], "header_center": "ACME", "footer_right": "<>", "shrink": true }), + ); + let after = text_box(&mut a); + // 200 × 300 page, 8 pt text and 36 pt margins: the header takes 48 pt, the footer 46.4 pt. + let k = (300.0 - 48.0 - 46.4) / 300.0; + assert!((after[2] / before[2] - k).abs() < 0.02 && (after[3] / before[3] - k).abs() < 0.02, "scaled by {k}: {before:?} → {after:?}"); + assert!((after[0] - (20.0 * k + (200.0 - 200.0 * k) / 2.0)).abs() < 1.0, "centred across: {before:?} → {after:?}"); + assert!(page_text(&mut a, doc)[0].contains("000001"), "the Bates number is drawn"); + // Margins that leave no room are refused; Remove brings the content back to full size. + let full = json!({ "doc": doc, "header_center": "X", "margins": [295, 36, 72, 72], "shrink": true, "replace": true }); + assert!(matches!(a.call("doc_header_footer", &full), Err(ToolError::Failed(_))), "no room"); + ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "header_footer" })); + let restored = text_box(&mut a); + assert!(restored.iter().zip(before).all(|(r, b)| (r - b).abs() < 0.01), "{before:?} → {restored:?}"); } #[test] @@ -2294,6 +2863,63 @@ fn exporting_images_and_text_through_tools() { assert!(a.call("doc_export_text", &json!({ "doc": doc, "path": "/etc/x.txt" })).is_err(), "confined to the root"); } +#[test] +fn accessibility_character_encoding_accepts_identity_cjk_through_tools() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, SaveOptions, Stream, write_full}; + use std::sync::Arc; + + let dir = workdir("a11y-identity-cjk"); + let mut a = auto(&dir); + for encoding in ["Identity-H", "Identity-V"] { + let mut source = Document::open(Arc::new(fixture(1))).unwrap(); + let mut system = Dict::new(); + system.set(b"Registry".to_vec(), Object::String(PdfString::literal(b"Adobe".to_vec()))); + system.set(b"Ordering".to_vec(), Object::String(PdfString::literal(b"Japan1".to_vec()))); + system.set(b"Supplement".to_vec(), Object::Int(0)); + let system = source.add(Object::Dict(system)); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::Name(b"FontDescriptor".to_vec())); + descriptor.set(b"FontName".to_vec(), Object::Name(b"SyntheticCID".to_vec())); + descriptor.set(b"Flags".to_vec(), Object::Int(6)); + descriptor.set(b"FontBBox".to_vec(), Object::Array([0, -200, 1000, 800].into_iter().map(Object::Int).collect())); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + descriptor.set(b"Ascent".to_vec(), Object::Int(800)); + descriptor.set(b"Descent".to_vec(), Object::Int(-200)); + descriptor.set(b"CapHeight".to_vec(), Object::Int(700)); + descriptor.set(b"StemV".to_vec(), Object::Int(80)); + let descriptor = source.add(Object::Dict(descriptor)); + let mut cid = Dict::new(); + cid.set(b"Type".to_vec(), Object::Name(b"Font".to_vec())); + cid.set(b"Subtype".to_vec(), Object::Name(b"CIDFontType0".to_vec())); + cid.set(b"BaseFont".to_vec(), Object::Name(b"SyntheticCID".to_vec())); + cid.set(b"CIDSystemInfo".to_vec(), Object::Ref(system)); + cid.set(b"FontDescriptor".to_vec(), Object::Ref(descriptor)); + cid.set(b"DW".to_vec(), Object::Int(1000)); + let cid = source.add(Object::Dict(cid)); + source + .update_dict(ObjRef::new(3, 0), |font| { + font.set(b"Subtype".to_vec(), Object::Name(b"Type0".to_vec())); + font.set(b"BaseFont".to_vec(), Object::Name(b"SyntheticCID".to_vec())); + font.set(b"Encoding".to_vec(), Object::Name(encoding.as_bytes().to_vec())); + font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Ref(cid)])); + }) + .unwrap(); + source.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), b"BT /F1 24 Tf 20 150 Td <002200230024> Tj ET"))); + let name = format!("{encoding}.pdf"); + let bytes = write_full(&source, &SaveOptions::default()).unwrap(); + std::fs::write(dir.join(&name), &bytes).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": name }))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, doc), ["ABC"], "the collection maps the CIDs to Unicode without ToUnicode"); + let report = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["character-encoding"] })); + let result = report["results"].as_array().unwrap().iter().find(|r| r["rule"] == "character-encoding").unwrap(); + assert_eq!(result["status"], "passed", "{encoding}: {result}"); + assert_eq!(result["findings"], json!([])); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], false); + assert_eq!(std::fs::read(dir.join(name)).unwrap(), bytes, "checking is read-only"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn accessibility_check_report_and_fixes_through_tools() { let dir = workdir("a11y"); @@ -2327,6 +2953,12 @@ fn accessibility_check_report_and_fixes_through_tools() { let manual_html = std::fs::read_to_string(dir.join("manual-report.html")).unwrap(); assert!(manual_html.contains("

Needs manual check: 1

")); assert!(!manual_html.contains("no problems")); +} + +#[test] +fn accessibility_figure_tags_and_alternative_text_through_tools() { + let dir = workdir("a11y-figures"); + let mut a = auto(&dir); // Figures: list, describe, mark decorative. std::fs::write( dir.join("figures.pdf"), @@ -2414,6 +3046,66 @@ trailer << /Root 1 0 R >> assert!(matches!(a.call("accessibility_set_alt", &json!({ "doc": fd, "figure": 6, "alt": "x" })), Err(ToolError::Failed(_)))); } +/// A table's summary and a header cell's scope are set through `accessibility_set_tag_property` +/// (#4103, #4104): the summary clears the table-summary finding, both are listed with the tags, +/// undone, and saved. +#[test] +fn table_summary_and_header_scope_through_automation() { + let dir = workdir("a11y-table"); + let content = "/TH << /MCID 0 >> BDC 10 150 40 20 re f EMC /TD << /MCID 1 >> BDC 60 150 40 20 re f EMC"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 11 0 R >> endobj +6 0 obj << /S /Document /P 5 0 R /K [7 0 R] >> endobj +7 0 obj << /S /Table /P 6 0 R /K [8 0 R] >> endobj +8 0 obj << /S /TR /P 7 0 R /K [9 0 R 10 0 R] >> endobj +9 0 obj << /S /TH /P 8 0 R /Pg 3 0 R /K 0 >> endobj +10 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 1 >> endobj +11 0 obj << /Nums [0 [9 0 R 10 0 R]] >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + std::fs::write(dir.join("table.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let fd = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap(); + let summary_status = |a: &mut Automation| { + let r = ok(a, "accessibility_check", json!({ "doc": fd, "rules": ["table-summary"] })); + r["results"].as_array().unwrap().iter().find(|x| x["rule"] == "table-summary").unwrap()["status"].as_str().unwrap().to_owned() + }; + assert_eq!(summary_status(&mut a), "failed"); + let args = + |object: u32, property: &str, value: &str| json!({ "doc": fd, "object": object, "generation": 0, "property": property, "value": value }); + let tags = ok(&mut a, "accessibility_set_tag_property", args(7, "summary", "Opening hours by day")); + let tag = |tags: &Value, object: u64| tags["tags"].as_array().unwrap().iter().find(|t| t["object"] == object).unwrap().clone(); + assert_eq!(tag(&tags, 7)["summary"], "Opening hours by day"); + assert_eq!(summary_status(&mut a), "passed"); + let tags = ok(&mut a, "accessibility_set_tag_property", args(9, "scope", "Column")); + assert_eq!(tag(&tags, 9)["scope"], "Column"); + // A scope is Row, Column or Both; only a table has a summary and only a header cell a scope. + for (object, property, value) in [(9, "scope", "column"), (10, "scope", "Row"), (9, "summary", "No")] { + assert!( + matches!(err(&mut a, "accessibility_set_tag_property", args(object, property, value)), ToolError::Failed(_)), + "{property} {value} on {object}" + ); + } + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Change tag properties"); + assert_eq!(tag(&ok(&mut a, "accessibility_tags", json!({ "doc": fd })), 9)["scope"], Value::Null); + ok(&mut a, "edit_redo", json!({ "doc": fd })); + ok(&mut a, "doc_save", json!({ "doc": fd, "path": "tagged-table.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("tagged-table.pdf")).unwrap())).unwrap(); + // Saving renumbers the objects: the tags are found by type. + let saved = pdfcraft_engine::a11y::tags(&saved); + assert_eq!(saved.iter().find(|t| t.kind == "Table").and_then(|t| t.summary.as_deref()), Some("Opening hours by day")); + assert_eq!(saved.iter().find(|t| t.kind == "TH").and_then(|t| t.scope.as_deref()), Some("Column")); +} + #[test] fn marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved() { let dir = workdir("a11y-artifact"); @@ -2594,6 +3286,96 @@ fn editing_existing_text_through_tools() { assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}"); } +#[test] +fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping: built without craft-fonts"); + return; + } + let dir = workdir("styled-fallback"); + std::fs::write( + dir.join("mixed.pdf"), + helvetica_page( + "BT /F1 12 Tf 14.4 TL 72 700 Td (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET", + ), + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "mixed.pdf"}))["doc"].as_u64().unwrap(); + let replacement = "The quick brown fox jumps over the lazy dog шалом\nand keeps running through the long field."; + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":replacement,"width":520})); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"})); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("saved.pdf")).unwrap())).unwrap(); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let resources = saved.dict(page.dict.get(b"Resources").unwrap()).unwrap(); + let fonts = saved.dict(resources.get(b"Font").unwrap()).unwrap(); + let fallback_count = fonts.iter().filter(|(name, _)| name.starts_with(b"PCJp")).count(); + assert_eq!(fallback_count, 1, "one shared fallback font for the new word"); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert!(!page_text(&mut a, doc)[0].contains("шалом")); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert!(page_text(&mut a, doc)[0].contains("шалом")); + let reopened = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + let lines = ok(&mut a, "text_lines", json!({"doc":reopened,"page":1})); + let runs = lines["lines"].as_array().unwrap(); + assert!(runs.iter().any(|l| l["text"].as_str().is_some_and(|t| t.starts_with("The quick")) && l["font"] == "Helvetica"), "{lines}"); + assert_eq!(runs.iter().filter(|l| l["text"] == "шалом").count(), 1, "{lines}"); + assert!(page_text(&mut a, reopened)[0].contains("шалом")); +} + +/// #2593: a paragraph whose own font cannot show a character is written from PdfCraft's fallback +/// faces. The Cyrillic the craft-fonts faces lack (Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ) comes from the +/// bundled Inter face, so the edit is not refused even without `CRAFT_FONTS_DIR`, and the words +/// survive a save and a reopen. (`crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face` +/// pins which face drew them.) +#[test] +fn editing_uzbek_and_kazakh_cyrillic_through_tools() { + let dir = workdir("cyrillic-fallback"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET"; + std::fs::write(dir.join("cy.pdf"), win_ansi_paragraph_pdf(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "cy.pdf" }))["doc"].as_u64().unwrap(); + for word in ["қизил", "әже"] { + let text = format!("The quick brown fox jumps over the lazy dog {word}\nand keeps running through the long field."); + let edited = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": text })); + let paragraph = edited["paragraph"]["text"].as_str().unwrap(); + assert!(paragraph.starts_with("The quick brown fox") && paragraph.ends_with("long field."), "{word}: {edited}"); + assert!(paragraph.contains(word), "{word}: {edited}"); + assert!(page_text(&mut a, doc)[0].contains(word), "{word}: the edited page reads it back"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text"); + } + // Keep the Kazakh edit, save it, and read it back from the file on disk. + let text = "The quick brown fox jumps over the lazy dog әже\nand keeps running through the long field."; + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": text })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "cy-out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "cy-out.pdf" }))["doc"].as_u64().unwrap(); + assert!(page_text(&mut a, saved)[0].contains("әже"), "the saved file reads it back"); +} + +/// One Letter page whose Helvetica is not embedded (WinAnsi), with `body` as its content stream. +fn win_ansi_paragraph_pdf(body: &str) -> Vec { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + /// One page whose content is one stream in three pieces (#155): a `TJ` array ends the middle /// piece and its operator starts the last one. fn split_streams_pdf() -> Vec { @@ -2671,6 +3453,15 @@ fn editing_page_images_through_tools() { assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0])); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" })); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" })); + // Any angle: the box grows to the turned picture's bounds, and it undoes like a quarter turn. + let before = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate_by", "degrees": 30.5 })); + assert_eq!(r["undo"], "Rotate image"); + let turned = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); + assert_ne!(turned, before); + ok(&mut a, "edit_undo", json!({ "doc": made })); + assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"], before); + assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "rotate_by" })), Err(ToolError::InvalidArgs(_)))); let saved = ok(&mut a, "image_save", json!({ "doc": made, "page": 1, "image": 1, "path": "out/picture" })); assert_eq!(saved["format"], "png"); assert!(std::fs::read(dir.join("out/picture.png")).unwrap().starts_with(b"\x89PNG")); @@ -2905,6 +3696,38 @@ fn fill_sign_text_font_size_is_saved_and_validated() { )); } +#[test] +fn fill_sign_text_and_date_colours_are_saved_and_validated() { + let dir = workdir("fill-sign-text-colour"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (kind, y) in [("text", 40), ("date", 80)] { + ok( + &mut a, + "fill_sign_add", + json!({ "doc": doc, "page": 1, "type": kind, "at": [20, y], "text": "Blue", "color": "#3366CC", "font_size": 6.5 }), + ); + } + for (kind, color) in [("text", "not-a-colour"), ("signature", "#3366CC"), ("check", "#3366CC")] { + assert!(matches!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": kind, "at": [20, 100], "text": "No", "color": color })), + Err(ToolError::InvalidArgs(_)) + )); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "coloured.pdf" })); + let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("coloured.pdf")).unwrap())).unwrap(); + let page = pdfcraft_model::pages(&pdf).remove(0); + let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap()); + let annotations = annotations.as_array().unwrap(); + assert_eq!(annotations.len(), 2, "invalid requests add nothing"); + for reference in annotations { + let annotation = pdf.resolve(reference); + // FreeText stores its text colour in DA; C is the annotation background. + let da = annotation.as_dict().unwrap().get(b"DA").unwrap().as_string().unwrap(); + assert_eq!(String::from_utf8(da.bytes.to_vec()).unwrap(), "0.2 0.4 0.8 rg /Helv 6.5 Tf"); + } +} + #[test] fn image_signatures_through_tools_preserve_transparency_and_survive_save() { let dir = workdir("image-signatures"); @@ -3363,6 +4186,29 @@ fn preparing_a_form_through_tools() { assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_)))); } +#[test] +fn renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools() { + // #4949: two buttons placed separately are two groups; naming one after the other makes + // them one group, and a clashing export value is renamed instead of shared. + let dir = workdir("radio-join"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" })); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "other", "export": "S" })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "S" } })); + let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "other", "name": "size" })); + assert_eq!(r["field"], "size"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "joined.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "joined.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let f = fields["fields"].as_array().unwrap(); + assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::>(), ["size"], "one group"); + assert_eq!(f[0]["options"], json!(["S", "Choice1"]), "the joining button takes a free export value"); + assert_eq!(f[0]["value"], "S", "the group keeps its selection"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "Choice1" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "Choice1"); +} + #[test] fn rotating_a_field_through_tools() { let dir = workdir("rotate-field"); @@ -3391,6 +4237,95 @@ fn rotating_a_field_through_tools() { assert_eq!((rot, back), (0, before)); } +/// A table of text fields through the tool: laid out from the top of the page as displayed, named +/// column by column, one undo step, and refused whole, with the reason, when any of it can't go in. +#[test] +fn adding_a_field_table_through_tools() { + let dir = workdir("field-table"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let table = + json!({ "doc": doc, "page": 1, "rect": [20, 20, 220, 120], "rows": 2, "names": ["desc_N", "price_N"], "widths": [3, 1], "row_gap": 10 }); + assert_eq!(ok(&mut a, "form_add_field_table", table.clone())["fields"], json!(["desc_1", "price_1", "desc_2", "price_2"])); + let rects = |a: &mut Automation| { + let fields = ok(a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().clone(); + fields.iter().map(|f| (f["name"].as_str().unwrap().to_owned(), f["rect"].clone())).collect::>() + }; + // 150 and 50 points wide, (100 - 10) / 2 = 45 high, the first row at the top. + let near = |v: &Value, want: [f64; 4]| v.as_array().unwrap().iter().zip(want).all(|(g, w)| (g.as_f64().unwrap() - w).abs() < 0.01); + let got = rects(&mut a); + for (name, want) in [ + ("desc_1", [20.0, 20.0, 170.0, 65.0]), + ("price_1", [170.0, 20.0, 220.0, 65.0]), + ("desc_2", [20.0, 75.0, 170.0, 120.0]), + ("price_2", [170.0, 75.0, 220.0, 120.0]), + ] { + let rect = got.iter().find(|(n, _)| n == name).map(|(_, r)| r).unwrap(); + assert!(near(rect, want), "{name}: {rect} != {want:?}"); + } + // Without names, each table takes the first free base. + let plain = json!({ "doc": doc, "page": 2, "rect": [20, 20, 220, 120], "rows": 2 }); + assert_eq!(ok(&mut a, "form_add_field_table", plain.clone())["fields"], json!(["Table1_1", "Table1_2"])); + assert_eq!(ok(&mut a, "form_add_field_table", plain)["fields"], json!(["Table2_1", "Table2_2"])); + let count = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(); + assert_eq!(count(&mut a), 8); + // Refused whole, saying why: nothing is added. + let refused = |a: &mut Automation, args: Value| match a.call("form_add_field_table", &args) { + Err(ToolError::InvalidArgs(m)) => m, + other => panic!("refused, not {other:?}"), + }; + assert_eq!(refused(&mut a, table), "a field named \"desc_1\" already exists"); + let small = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 30 }); + assert!(refused(&mut a, small).starts_with("the rows are too short")); + let dotted = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "names": ["a.{n}"] }); + assert_eq!(refused(&mut a, dotted), "field names can't contain a period: \"a.1\""); + let disagree = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "columns": 3, "names": ["a_N", "b_N"] }); + assert!(refused(&mut a, disagree).contains("must agree")); + let wide = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "columns": 51 }); + assert_eq!(refused(&mut a, wide), "a table has 1 to 50 columns"); + let none = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": -1 }); + assert_eq!(refused(&mut a, none), "a table has 1 to 100 rows"); + let huge = json!({ "doc": doc, "page": 1, "rect": [20, 200, 1e39, 300], "rows": 2 }); + assert!(refused(&mut a, huge).starts_with("the area, the spacing and the column widths must be numbers")); + assert_eq!(count(&mut a), 8); + // The table tool's command runs it too. + let by_command = json!({ "id": "form.add.text_table", "params": { "doc": doc, "page": 3, "rect": [20, 20, 220, 120], "rows": 2 } }); + assert_eq!(ok(&mut a, "command_run", by_command)["fields"], json!(["Table3_1", "Table3_2"])); + assert_eq!(count(&mut a), 10); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add text field table"); + assert_eq!(count(&mut a), 8, "one undo takes back the whole last table"); +} + +#[test] +fn redact_mark_respects_single_page_and_rejects_conflicting_selectors() { + let dir = workdir("redact-single-page"); + std::fs::write(dir.join("memo.txt"), "Private 123-45-6789").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_duplicate", json!({ "doc": doc, "pages": [1] })); + for selector in [json!({ "find": "Private" }), json!({ "words": ["Private"] }), json!({ "pattern": "ssn" }), json!({ "whole_pages": true })] { + let mut args = selector; + args["doc"] = json!(doc); + args["page"] = json!(1); + let result = ok(&mut a, "redact_mark", args); + assert_eq!(result["marked"], 1); + ok(&mut a, "redact_clear", json!({ "doc": doc })); + } + for args in [ + json!({ "doc": doc, "find": "Private", "page": 1, "pages": [2] }), + json!({ "doc": doc, "rect": [1, 1, 10, 10], "pages": [2] }), + json!({ "doc": doc, "find": "Private", "page": 3 }), + ] { + assert!(matches!(a.call("redact_mark", &args), Err(ToolError::InvalidArgs(_)))); + } + let result = ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "Private", "page": 1 })); + assert_eq!(result["marks_pending"], 1, "invalid selections must not add marks"); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + let text = page_text(&mut a, doc); + assert!(!text[0].contains("Private")); + assert!(text[1].contains("Private")); +} + #[test] fn redacting_through_tools() { let dir = workdir("redact"); @@ -3415,6 +4350,70 @@ fn redacting_through_tools() { assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); } +#[test] +/// Mark, apply and save remove the complete international number while retaining labels and controls. +fn redacting_italian_international_slash_numbers_through_tools() { + let dir = workdir("redact-italian-international-slash"); + std::fs::write(dir.join("phones.txt"), "A tel. +39 0426/123456\nB tel. 0426/123456\nC protocol 2024/123456\nD on 01/10/2026").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "phones.txt" }))["doc"].as_u64().unwrap(); + let marked = ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "phone" })); + assert_eq!(marked["marked"], 2, "both complete phone numbers need marks"); + assert!(page_text(&mut a, doc)[0].contains("+39 0426/123456"), "marks alone remove nothing"); + assert_eq!(ok(&mut a, "redact_apply", json!({ "doc": doc }))["applied"], 2); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "redacted.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "redacted.pdf" }))["doc"].as_u64().unwrap(); + let text = &page_text(&mut a, reopened)[0]; + assert!(!text.contains("+39") && !text.contains("0426"), "{text}"); + for label in ["A tel.", "B tel."] { + assert!(text.lines().any(|line| line.trim() == label), "only the surrounding label remains: {text}"); + } + assert!(text.contains("protocol 2024/123456") && text.contains("on 01/10/2026"), "unrelated digit runs remain: {text}"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// One page with the text "Secret 1234" and a Redact annotation that has neither a usable +/// QuadPoints array nor a Rect, so there is no area to redact. +fn redact_pdf_with_unreadable_mark() -> Vec { + let body = "BT /F1 24 Tf 20 150 Td (Secret 1234) Tj ET"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>".into(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /Annot /Subtype /Redact /QuadPoints /oops >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + +/// A mark that can't be read is refused by `redact_apply`, with the reason in the reply, and the +/// page keeps its text. (Before, the mark was skipped and the reply said there were no marks.) +#[test] +fn an_unreadable_redaction_mark_is_refused_with_the_reason() { + let dir = workdir("redact-unreadable"); + std::fs::write(dir.join("broken.pdf"), redact_pdf_with_unreadable_mark()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "broken.pdf" }))["doc"].as_u64().unwrap(); + let Err(ToolError::Failed(reason)) = a.call("redact_apply", &json!({ "doc": doc })) else { + panic!("redact_apply must refuse a mark with no readable area"); + }; + assert!(reason.contains("1 redaction mark(s) have no readable area") && reason.contains("nothing was redacted"), "{reason}"); + assert!(page_text(&mut a, doc)[0].contains("Secret 1234"), "the page is unchanged"); +} + #[test] fn redacting_with_codes_through_tools() { let dir = workdir("redact-codes"); @@ -3690,6 +4689,12 @@ fn adding_content_through_tools() { assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}"); // Image tools: rotate, flip, crop, replace. ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true, "crop": [0.1, 0, 0.1, 0] })); + // One side may trim most of the picture; together an axis keeps at least 5 %. + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.7, 0, 0, 0] })); + assert!(matches!( + a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.6, 0, 0.4, 0] })), + Err(ToolError::InvalidArgs(_)) + )); ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "image": "logo.png" })); assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], true); assert!(matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "rotate": 45 })), Err(ToolError::InvalidArgs(_)))); @@ -3702,6 +4707,91 @@ fn adding_content_through_tools() { assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); } +/// Edit ▸ Copy and Paste headlessly (#2636): an added text box or image, as it is, onto its own +/// page beside it, onto a point, and into another document. +#[test] +fn copying_added_content_through_tools() { + let dir = workdir("content-copy"); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 40, 20); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[90u8; 2400]).unwrap(); + } + std::fs::write(dir.join("logo.png"), png).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let other = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + ok( + &mut a, + "page_add_text", + json!({ "doc": doc, "page": 1, "text": "Approved", "at": [20, 20], "width": 100, "size": 18, "bold": true, "font": "times", "color": "red", "align": "center" }), + ); + ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "logo.png", "rect": [100, 200, 180, 240] })); + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true })); + let original = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone(); + // Beside itself on its own page: 18 points down and to the right, with the style content_list + // shows (the engine test checks colour, alignment and the image transforms). + let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 1 })); + assert_eq!((r["page"].as_u64(), r["index"].as_u64()), (Some(1), Some(3))); + let items = ok(&mut a, "content_list", json!({ "doc": doc }))["items"].clone(); + let (from, to) = (&original[0], &items[2]); + for key in ["type", "text", "font", "size", "bold", "italic"] { + assert_eq!(from[key], to[key], "{key}"); + } + let shifted: Vec = from["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() + 18.0).collect(); + assert_eq!(to["rect"], json!(shifted)); + assert_eq!(r["rect"], to["rect"]); + // An image onto another page at a point, and into another document where it was. + let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 2, "to_page": 2, "at": [10, 30] })); + assert_eq!((r["page"].as_u64(), r["index"].as_u64()), (Some(2), Some(1))); + assert_eq!(r["rect"][0].as_f64().zip(r["rect"][1].as_f64()), Some((10.0, 30.0)), "its top-left at the point"); + let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 2, "to_doc": other })); + assert_eq!(r["rect"], original[1]["rect"], "in the same place"); + let theirs = ok(&mut a, "content_list", json!({ "doc": other })); + assert_eq!((theirs["count"].as_u64(), theirs["items"][0]["type"].as_str()), (Some(1), Some("image"))); + // Copied there again: it steps past the first copy, whose picture that document stores itself. + let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 2, "to_doc": other })); + let stepped: Vec = original[1]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() + 18.0).collect(); + assert_eq!(r["rect"], json!(stepped), "beside the first copy, not on it"); + // Each paste is one undo step in the document it went into. + ok(&mut a, "edit_undo", json!({ "doc": other })); + ok(&mut a, "edit_undo", json!({ "doc": other })); + assert_eq!(ok(&mut a, "content_list", json!({ "doc": other }))["count"], 0); + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 4); + // Out of range: a readable failure. + assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 9 })), Err(ToolError::Failed(_)))); + assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 1, "to_page": 9 })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 1, "to_doc": 999 })), Err(ToolError::Failed(_)))); + for at in [json!([1]), json!([10, "x", 30]), json!("10, 30")] { + assert!(matches!(a.call("content_copy", &json!({ "doc": doc, "page": 1, "index": 1, "at": at })), Err(ToolError::InvalidArgs(_))), "{at}"); + } + // A second copy onto its own page steps past the first. + let r = ok(&mut a, "content_copy", json!({ "doc": doc, "page": 1, "index": 1 })); + let twice: Vec = from["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() + 36.0).collect(); + assert_eq!(r["rect"], json!(twice)); + + // A document that doesn't allow copying keeps its content in, even with changes allowed. + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + cos.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + std::fs::write(dir.join("no-copy.pdf"), pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()).unwrap(); + let locked = ok(&mut a, "doc_open", json!({ "path": "no-copy.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": locked, "page": 1, "text": "Secret", "at": [20, 20], "width": 100 })); + for to in [locked, other] { + let refused = a.call("content_copy", &json!({ "doc": locked, "page": 1, "index": 1, "to_doc": to })); + assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("copying")), "{refused:?}"); + } + assert_eq!(ok(&mut a, "content_list", json!({ "doc": locked }))["count"], 1); +} + /// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable /// failure for `content_update` and `content_delete`, not a panic. #[test] @@ -3720,6 +4810,121 @@ fn content_index_out_of_range_is_an_error_not_a_panic() { assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); } +/// Character spacing on added text (#4245): set when adding or restyling, listed back, and refused +/// out of range or on images. +#[test] +fn added_text_takes_character_spacing() { + let dir = workdir("content-char-spacing"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Spaced", "at": [20, 20], "char_spacing": 2 })); + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], 2.0); + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "char_spacing": -0.5 })); + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], -0.5); + for bad in [json!(1000), json!(-1000), json!("wide")] { + let args = json!({ "doc": doc, "page": 1, "index": 1, "char_spacing": bad }); + assert!(matches!(a.call("content_update", &args), Err(ToolError::InvalidArgs(_))), "{bad}"); + } + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], -0.5); +} + +fn added_rotation_image(dir: &Path) { + let mut png = Vec::new(); + let mut enc = png::Encoder::new(&mut png, 2, 1); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255, 0, 0, 0, 0, 255]).unwrap(); + std::fs::write(dir.join("rotation.png"), png).unwrap(); +} + +#[test] +fn large_added_image_rotations_keep_orientation_and_undo() { + let dir = workdir("large-added-rotation"); + added_rotation_image(&dir); + let mut a = auto(&dir); + let image = |a: &Automation, doc| { + let d = a.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + let pdfcraft_engine::AddedContent::Image(image) = &d.added()[0].content else { panic!("added image") }; + image.clone() + }; + let largest = i64::MAX / 90 * 90; + let smallest = i64::MIN / 90 * 90; + for initial in [1, 0, 2, 3] { + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "rotation.png", "rect": [20, 20, 120, 70] })); + ok( + &mut a, + "content_update", + json!({ "doc": doc, "page": 1, "index": 1, "rotate": initial * 90, + "flip_h": true, "flip_v": true, "crop": [0.1, 0.05, 0.2, 0.1] }), + ); + let before = image(&a, doc); + for degrees in [largest, largest - 90, largest - 180, smallest, smallest + 90, smallest + 180, -450, -90, 0, 90, 450] { + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "rotate": degrees })); + let expected = pdfcraft_engine::AddedImage { rotation: (initial + (degrees / 90).rem_euclid(4)).rem_euclid(4) as u8, ..before.clone() }; + assert_eq!(image(&a, doc), expected, "initial quarter turn {initial}, degrees {degrees}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(image(&a, doc), before, "Undo restores the image and its properties"); + ok(&mut a, "edit_redo", json!({ "doc": doc })); + assert_eq!(image(&a, doc), expected, "Redo restores the normalized orientation"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": format!("initial-{initial}.pdf") })); + let state = |a: &Automation| { + let d = a.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + (d.bytes.clone(), d.dirty, d.edit_generation(), d.can_undo().map(str::to_owned), d.can_redo().map(str::to_owned)) + }; + let saved = state(&a); + assert!(!saved.1 && saved.4.is_some(), "a clean document with a redo step"); + for degrees in [i64::MIN, i64::MAX, -1, 1, 45] { + assert!(matches!( + a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 1, "rotate": degrees })), + Err(ToolError::InvalidArgs(_)) + )); + assert_eq!(state(&a), saved, "invalid degrees {degrees} leave bytes and history alone"); + } + } +} + +#[cfg(feature = "mcp")] +#[test] +fn large_added_image_rotations_round_trip_through_mcp() { + let dir = workdir("large-added-rotation-mcp"); + added_rotation_image(&dir); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "rotation.png", "rect": [20, 20, 120, 70] })); + ok( + &mut a, + "content_update", + json!({ "doc": doc, "page": 1, "index": 1, "rotate": 90, + "flip_h": true, "flip_v": true, "crop": [0.1, 0.05, 0.2, 0.1] }), + ); + let mut server = McpServer::new(a); + let render = json!({ "name": "page_render", "arguments": { "doc": doc, "page": 1, "dpi": 72 } }); + let before = rpc(&mut server, 1, "tools/call", render.clone()); + let update = rpc( + &mut server, + 2, + "tools/call", + json!({ "name": "content_update", "arguments": { + "doc": doc, "page": 1, "index": 1, "rotate": i64::MAX / 360 * 360 + } }), + ); + assert_eq!(update["result"]["isError"], false, "{update}"); + let after = rpc(&mut server, 3, "tools/call", render.clone()); + assert_eq!(after["result"]["content"], before["result"]["content"], "full turns retain the rendered orientation"); + let saved = rpc(&mut server, 4, "tools/call", json!({ "name": "doc_save", "arguments": { "doc": doc, "path": "rotated.pdf" } })); + assert_eq!(saved["result"]["isError"], false, "{saved}"); + let mut reopened = auto(&dir); + let doc = ok(&mut reopened, "doc_open", json!({ "path": "rotated.pdf" }))["doc"].as_u64().unwrap(); + let d = reopened.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + let pdfcraft_engine::AddedContent::Image(image) = &d.added()[0].content else { panic!("saved image") }; + assert_eq!(image.rotation, 1); + let png = reopened.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Content::Png { data, .. } = &png[0] else { panic!("rendered PNG") }; + assert_eq!(after["result"]["content"][0]["data"], base64::Engine::encode(&base64::engine::general_purpose::STANDARD, data)); +} + /// Text styling on an added image is refused (#1331), as image transforms are on text, and the /// refusal leaves the content, the dirty flag and the undo and redo steps as they were. #[test] @@ -3799,6 +5004,283 @@ fn form_formats_and_calculations_through_tools() { )); } +/// Renaming a field renames it in the formulas that read it: a sum dropped the field, notation +/// read it as 0 and a script kept a stale value. +#[test] +fn renaming_a_field_keeps_the_formulas_that_read_it() { + let dir = workdir("formrename"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["a", "b", "c", "d", "e", "x", "sum", "product", "double", "total"].into_iter().enumerate() { + let y = 20 + 30 * i; + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sum", "calculate": { "op": "sum", "fields": ["a", "b"] } })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "product", "calculate": { "notation": "c * d" } })); + ok( + &mut a, + "form_set_script", + json!({ "doc": doc, "field": "double", "event": "calculate", "script": "event.value = getField(\"e\").value * 2;" }), + ); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["x"] } })); + let renamed = |a: &mut Automation, from: &str, to: &str| ok(a, "form_set_props", json!({ "doc": doc, "field": from, "name": to })); + assert_eq!(renamed(&mut a, "a", "qty")["formulas_updated"], json!(["sum"])); + assert_eq!(renamed(&mut a, "c", "price")["formulas_updated"], json!(["product"])); + assert_eq!(renamed(&mut a, "e", "base")["formulas_updated"], json!(["double"])); + assert_eq!(renamed(&mut a, "total", "grand")["formulas_updated"], json!([]), "a calculated field's own name is in no formula"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "10", "b": "2", "price": "10", "d": "4", "base": "10", "x": "9" } })); + let values = |a: &mut Automation| { + let f = ok(a, "form_fields", json!({ "doc": doc })); + let value = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).map(|x| x["value"].clone()); + [value("sum"), value("product"), value("double"), value("grand")] + }; + assert_eq!(values(&mut a), [Some(json!("12")), Some(json!("40")), Some(json!("20")), Some(json!("9"))]); + let f = ok(&mut a, "form_fields", json!({ "doc": doc })); + let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).unwrap().clone(); + assert_eq!(field("sum")["calculate"]["fields"], json!(["qty", "b"])); + assert_eq!(field("product")["calculate"]["notation"], "price * d"); + // The rename and the formulas it changed are one undo step: undo the fill, then the last + // three renames. + for _ in 0..4 { + ok(&mut a, "edit_undo", json!({ "doc": doc })); + } + let f = ok(&mut a, "form_fields", json!({ "doc": doc })); + let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned(); + assert!(field("c").is_some() && field("price").is_none()); + assert_eq!(field("product").unwrap()["calculate"]["notation"], "c * d", "undone together with the rename"); + // The renamed field's own formula, changed in the same call, is not one the rename + // rewrote, though the name is stored trimmed. + let r = ok( + &mut a, + "form_set_props", + json!({ "doc": doc, "field": "sum", "name": " added ", "calculate": { "op": "sum", "fields": ["qty", "b", "x"] } }), + ); + assert_eq!(r["formulas_updated"], json!([])); +} + +/// The calculation order through the tool: read with what each formula reads and what is wrong, +/// fixed so stale values come out right, moved, set, and undone. +#[test] +fn the_calculation_order_is_read_fixed_and_set() { + let dir = workdir("calcorder"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["qty", "price", "sub", "total", "tax", "x", "y"].into_iter().enumerate() { + let y = 20 + 30 * i; + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + // Defined backwards: total and tax calculate before the fields they read. + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } })); + ok( + &mut a, + "form_set_script", + json!({ "doc": doc, "field": "tax", "event": "calculate", "script": "event.value = getField(\"sub\").value / 10;" }), + ); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * price" } })); + // x and y read each other. + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "x", "calculate": { "notation": "y + 1" } })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "y", "calculate": { "notation": "x + 1" } })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "3", "price": "10" } })); + let value = |a: &mut Automation, name: &str| { + let f = ok(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).map(|x| x["value"].clone()).unwrap() + }; + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "stale: they calculate too early"); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); + assert_eq!(r["fields"][0]["reads"], json!(["sub", "tax"])); + assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "runs_too_early", "field": "tax" }, { "issue": "runs_too_early", "field": "sub" }])); + assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "runs_too_early", "field": "sub" }]), "a script's reads are checked too"); + assert_eq!(r["fields"][3]["issues"], json!([{ "issue": "cycle", "fields": ["y"] }])); + assert_eq!((r["fixable"].clone(), r["extra"].clone()), (json!(true), json!([]))); + assert!(r.get("undo").is_none(), "reading changes nothing"); + // Prepare a form ▸ Calculation order is this tool headlessly. + let r = ok(&mut a, "command_run", json!({ "id": "form.calculation_order", "params": { "doc": doc } })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "fix": true })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total", "x", "y"]), "only what must move moves; the loop stays"); + assert_eq!(r["undo"], "Set calculation order"); + assert_eq!(r["fixable"], false); + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("33"), json!("3")), "recalculated in the new order"); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "field": "y", "move": "earlier" })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total", "y", "x"])); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "order": ["x"] })); + assert_eq!(r["calculation_order"], json!(["x", "sub", "tax", "total", "y"]), "the fields named first, the others after them"); + for bad in [json!({ "order": ["qty"] }), json!({ "order": ["sub", "sub"] }), json!({ "field": "qty" }), json!({ "fix": true, "field": "x" })] { + let mut args = bad.clone(); + args["doc"] = json!(doc); + assert!(a.call("form_calculation_order", &args).is_err(), "{bad}"); + } + for _ in 0..3 { + ok(&mut a, "edit_undo", json!({ "doc": doc })); + } + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated"); +} + +/// A malformed form with two calculated fields named `dup`, one summing `src`, the other `x`. +fn two_calculations_with_one_name() -> Vec { + let widget = |name: &str, y: u32, reads: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>", + y + 20 + ) + }; + pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>", + &widget("dup", 700, "x"), + &widget("dup", 660, "src"), + &widget("src", 620, "x"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>", + ]) +} + +/// Each row of the calculation order gets its own field's formula, even where two fields share a +/// name (a malformed form): the first dup sums src, the second x. +#[test] +fn the_calculation_order_gives_each_field_its_own_formula() { + let dir = workdir("calcdup"); + std::fs::write(dir.join("dup.pdf"), two_calculations_with_one_name()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["dup", "src", "dup"])); + let formulas: Vec = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect(); + assert_eq!( + formulas, + [json!({ "op": "sum", "fields": ["src"] }), json!({ "op": "sum", "fields": ["x"] }), json!({ "op": "sum", "fields": ["x"] })] + ); +} + +/// The calculation order through the tool shows each formula, in form_set_props's shape or as a +/// script, and flags a read of a field the form doesn't have and a script that builds the names +/// it reads; form_fields shows a custom script too. +#[test] +fn the_calculation_order_shows_formulas_and_what_they_cannot_read() { + let dir = workdir("calcformulas"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["qty", "price", "sub", "tax", "total"].into_iter().enumerate() { + let y = 20 + 30 * i; + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + let script = "event.value = getField(\"sub\").value / 10 + getField(\"row\" + 1).value;"; + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * pric" } })); + ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "tax", "event": "calculate", "script": script })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } })); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total"])); + let formulas: Vec = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect(); + assert_eq!(formulas, [json!({ "notation": "qty * pric" }), json!({ "script": script }), json!({ "op": "sum", "fields": ["sub", "tax"] })]); + assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "missing_field", "field": "pric" }])); + assert_eq!(r["fields"][1]["reads"], json!(["sub"])); + assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "built_names" }])); + assert_eq!(r["fields"][2]["issues"], json!([])); + assert_eq!(r["fixable"], false, "neither is an order to fix"); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let tax = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "tax").unwrap(); + assert_eq!(tax["calculate"], json!({ "script": script })); + // A script is set with form_set_script, and form_set_props says so, also when a notation + // comes with it. + for calculate in [json!({ "script": script }), json!({ "notation": "qty * 2", "script": script })] { + let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "tax", "calculate": calculate })); + assert!(e.to_string().contains("form_set_script"), "{calculate}: {e}"); + } +} + +/// Every operation comes back from form_fields and form_calculation_order as form_set_props takes +/// it, so a formula can be read and given back: product and average came back as prd and avg, +/// which it refused. +#[test] +fn every_calculation_op_can_be_read_and_given_back() { + let dir = workdir("calcops"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (name, y) in [("a", 20), ("b", 60), ("total", 100)] { + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + for op in ["sum", "product", "average", "min", "max"] { + let calculate = json!({ "op": op, "fields": ["a", "b"] }); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate })); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let shown = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap()["calculate"].clone(); + let order = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!((&shown, &order["fields"][0]["formula"]), (&calculate, &calculate), "{op}"); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": shown })); + } +} + +/// form_set_props refuses a calculate or validate it would only partly apply (a key it doesn't +/// know, a value of the wrong type, a notation beside op and fields) as a bad argument, before +/// the edit runs, and changes nothing. A validate read back from form_fields, with null for the +/// bound it doesn't have, is taken back. +#[test] +fn calculate_and_validate_refuse_what_they_would_drop() { + let dir = workdir("calcstrict"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (name, y) in [("a", 20), ("b", 60), ("total", 100)] { + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + let set = json!({ "op": "sum", "fields": ["a", "b"] }); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": set, "validate": { "min": 0, "max": 10 } })); + let total = |a: &mut Automation| { + let fields = ok(a, "form_fields", json!({ "doc": doc })); + fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap().clone() + }; + for (calculate, says) in [ + (json!({ "notation": "a", "op": "sum", "fields": ["b"] }), "not both"), + (json!({ "notation": "a", "op": "sum" }), "not both"), + (json!({ "notation": "a", "fields": ["b"] }), "not both"), + // A notation that wouldn't read back as itself from the script's comment. + (json!({ "notation": "a EVCALC b" }), "can't be stored"), + (json!({ "notation": "a */ x /*" }), "can't be stored"), + (json!({ "notation": " " }), "can't be stored"), + (json!({ "op": "sum", "fields": ["a", 5] }), "field names"), + (json!({ "op": "sum", "fields": "a" }), "list of field names"), + (json!({ "notation": 5 }), "notation must be a string"), + (json!({ "op": 5, "fields": ["a"] }), "op must be"), + (json!({ "fields": ["a"] }), "needs op"), + (json!({ "op": "sum", "feilds": ["a"] }), "unknown key \"feilds\""), + ] { + // InvalidArgs, not Failed: an unstorable notation is refused by the tool itself, not + // only by the edit underneath it. + let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate })); + assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{calculate}: {e:?}"); + } + for (validate, says) in [(json!({ "min": 0, "max": "100" }), "max must be a number"), (json!({ "min": 0, "mxa": 5 }), "unknown key \"mxa\"")] { + let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": validate })); + assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{validate}: {e:?}"); + } + let t = total(&mut a); + assert_eq!((&t["calculate"], &t["validate"]), (&set, &json!({ "min": 0.0, "max": 10.0 })), "nothing changed"); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": { "min": null, "max": 5 } })); + let shown = total(&mut a)["validate"].clone(); + assert_eq!(shown, json!({ "min": null, "max": 5.0 })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": shown })); +} + +#[test] +fn page_tab_orders_are_read_and_set_per_page_through_tools() { + let dir = workdir("pagetabs"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc })); + assert_eq!(r["page_tabs"], json!(["annotations", "annotations", "annotations"])); + assert!(r["undo"].is_null(), "reading changes nothing: {r}"); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column", "pages": [1, 3] })); + assert_eq!(r["page_tabs"], json!(["column", "annotations", "column"])); + assert_eq!((r["undo"].as_str(), r["dirty"].as_bool()), (Some("Set tab order"), Some(true))); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "structure", "pages": [2] })); + assert_eq!(r["page_tabs"], json!(["column", "structure", "column"])); + let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "unspecified", "pages": [1] })); + assert_eq!(r["page_tabs"], json!(["annotations", "structure", "column"])); +} + #[test] fn tab_order_and_field_appearance_through_tools() { let dir = workdir("taborder"); @@ -3880,15 +5362,13 @@ fn natural_image_stamps_through_tools_on_rotated_pages() { let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; let info = reader.next_frame(&mut pixels).unwrap(); - assert_eq!(info.color_type, png::ColorType::Rgba); - for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([ - [240, 20, 20, 255], - [20, 180, 20, 255], - [20, 20, 240, 255], - [230, 180, 20, 255], - ]) { - let offset = ((y * info.width + x) * 4) as usize; - assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}"); + // A rendered page is opaque, so it comes as RGB. + assert_eq!(info.color_type, png::ColorType::Rgb); + for ((x, y), colour) in + [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]]) + { + let offset = ((y * info.width + x) * 3) as usize; + assert_eq!(&pixels[offset..offset + 3], &colour, "page rotation {degrees}"); } } } @@ -3923,6 +5403,64 @@ fn stamps_through_tools() { assert!(matches!(a.call("stamp_custom", &json!({ "doc": doc, "page": 1, "path": "nope.png", "at": [1, 1] })), Err(ToolError::Failed(_)))); } +/// #4077: a PDF page placed at a given width (a text column, 483 pt) through `rect`, fitted +/// inside it keeping its shape and centred, and still vector (its text is text). +#[test] +fn custom_stamps_fit_a_pdf_page_inside_a_rect() { + let dir = workdir("stamp_rect"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 595, "height": 842 }))["doc"].as_u64().unwrap(); + let rect_of = |a: &mut Automation, i: usize| -> Vec { + let list = ok(a, "comment_list", json!({ "doc": doc })); + list["comments"][i]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect() + }; + // Without rect, the page keeps its natural size, at most 200 pt: 200 × 300 is 133⅓ × 200. + ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 2, "at": [100, 150] })); + let natural = rect_of(&mut a, 0); + assert!((natural[2] - natural[0] - 400.0 / 3.0).abs() < 0.01 && (natural[3] - natural[1] - 200.0).abs() < 0.01, "{natural:?}"); + // 483 × 750: the width limits the 200 × 300 page to 483 × 724.5, centred top to bottom. + ok(&mut a, "stamp_custom", json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 2, "rect": [56, 50, 539, 800] })); + assert_eq!(rect_of(&mut a, 1), [56.0, 62.75, 539.0, 787.25]); + assert!(page_text(&mut a, doc)[0].contains("Page 2"), "the stamped page's text is still text"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add stamp"); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 1); + + for (args, why) in [ + (json!({ "rect": [10, 10, 10, 50] }), "zero width"), + (json!({ "rect": [10, 10, 50, 10] }), "zero height"), + (json!({ "rect": [0, 0, 1e-10, 100] }), "thinner than a point"), + (json!({ "rect": [0, 0, 20000, 100] }), "wider than a PDF page can be"), + (json!({ "rect": [0, 0, 100, f64::NAN] }), "not a number"), + (json!({ "rect": [0, 0, 100] }), "three numbers"), + (json!({ "rect": [0, 0, 100, 100], "at": [10, 10] }), "both a box and a point"), + (json!({}), "neither a box nor a point"), + ] { + let mut call = json!({ "doc": doc, "page": 1, "path": "b.pdf" }); + call.as_object_mut().unwrap().extend(args.as_object().unwrap().clone()); + assert!(matches!(a.call("stamp_custom", &call), Err(ToolError::InvalidArgs(_))), "{why}"); + } + let past_the_end = json!({ "doc": doc, "page": 1, "path": "b.pdf", "file_page": 9, "rect": [0, 0, 100, 100] }); + assert!(matches!(a.call("stamp_custom", &past_the_end), Err(ToolError::Failed(_)))); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 1, "a refused stamp adds nothing"); + + // On a quarter-turned page the picture is fitted as displayed: an 80 × 40 picture in a + // 100 × 100 box is 100 × 50 on screen, centred. + let mut png_bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut png_bytes, 80, 40); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + encoder.write_header().unwrap().write_image_data(&[200; 80 * 40 * 3]).unwrap(); + } + std::fs::write(dir.join("wide.png"), png_bytes).unwrap(); + let turned = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_rotate", json!({ "doc": turned, "pages": [1], "degrees": 90 })); + ok(&mut a, "stamp_custom", json!({ "doc": turned, "page": 1, "path": "wide.png", "rect": [50, 60, 150, 160] })); + let list = ok(&mut a, "comment_list", json!({ "doc": turned })); + let rect: Vec = list["comments"][0]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert_eq!(rect, [50.0, 85.0, 150.0, 135.0]); +} + #[test] fn bookmark_split_with_equal_titles_keeps_every_part() { let dir = workdir("organize_dup_titles"); @@ -4032,6 +5570,33 @@ fn links_through_tools() { assert!(matches!(a.call("link_add", &json!({ "doc": doc, "page": 1, "rect": [0, 0, 50, 20] })), Err(ToolError::InvalidArgs(_)))); } +/// #4168: a Launch action whose file is a PDF (here with Windows `\` separators, relative to the +/// source as a remote link is) opens that PDF in PdfCraft through link_open, within the root; one +/// naming a program is listed but never followed. +#[test] +fn a_launch_link_to_a_pdf_opens_it_like_a_remote_link() { + let (_base, root) = sandbox("launch-pdf"); + std::fs::create_dir_all(root.join("ts")).unwrap(); + std::fs::create_dir_all(root.join("se")).unwrap(); + std::fs::write(root.join("se/se_B2.pdf"), fixture(2)).unwrap(); + // The helper writes a GoToR; the action is renamed (the parser rebuilds the shifted offsets). + let launch = |file: &str| String::from_utf8(remote_link_pdf(file, None)).unwrap().replace("/S /GoToR", "/S /Launch").into_bytes(); + std::fs::write(root.join("ts/TS_304.pdf"), launch(r"..\se\se_B2.pdf")).unwrap(); + std::fs::write(root.join("ts/program.pdf"), launch("tool.exe")).unwrap(); + let mut a = auto(&root); + let source = ok(&mut a, "doc_open", json!({ "path": "ts/TS_304.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": source })); + let link = &info["links"][0]["target"]; + assert_eq!((link["action"].as_str(), link["remote_document"].as_str()), (Some("Launch"), Some("../se/se_B2.pdf")), "{info}"); + let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + assert_eq!(opened["path"].as_str().map(|p| p.replace('\\', "/")).as_deref(), Some("se/se_B2.pdf"), "{opened}"); + assert_eq!(opened["document"]["pages"], 2); + let program = ok(&mut a, "doc_open", json!({ "path": "ts/program.pdf" }))["doc"].as_u64().unwrap(); + let before = a.session().docs().len(); + assert!(matches!(err(&mut a, "link_open", json!({ "doc": program, "page": 1, "index": 1 })), ToolError::InvalidArgs(_) | ToolError::Failed(_))); + assert_eq!(a.session().docs().len(), before, "nothing was opened or launched"); +} + #[test] fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { let (_base, root) = sandbox("gotor-not-a-file"); @@ -4062,6 +5627,18 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { #[test] fn local_gotor_link_open_applies_destinations_and_honors_root() { + let root = local_gotor_fixtures(); + let mut a = auto(&root); + assert_relative_gotor_destination(&mut a); + assert_absolute_gotor_policy(&mut a); + assert_unapplied_gotor_destinations(&mut a); + assert_encrypted_gotor_passwords(&mut a); + assert_unsupported_gotor_targets(&mut a); + assert_gotor_root_boundaries(&mut a); + assert_url_links_cannot_open_as_gotor(&mut a); +} + +fn local_gotor_fixtures() -> PathBuf { let (base, root) = sandbox("gotor-open"); std::fs::create_dir_all(root.join("sub")).unwrap(); std::fs::write(root.join("target.pdf"), fixture(3)).unwrap(); @@ -4079,12 +5656,14 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { std::fs::write(root.join("absolute.pdf"), remote_link_pdf(root.join("target.pdf").to_str().unwrap(), Some("[1 /Fit]"))).unwrap(); link_dir(&root, "outside-link", &base.join("outside")); std::fs::write(root.join("symlink.pdf"), remote_link_pdf("outside-link/secret.pdf", Some("[0 /Fit]"))).unwrap(); + root +} - let mut a = auto(&root); - let source = ok(&mut a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap(); - let listed = ok(&mut a, "link_list", json!({ "doc": source })); +fn assert_relative_gotor_destination(a: &mut Automation) { + let source = ok(a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap(); + let listed = ok(a, "link_list", json!({ "doc": source })); assert_eq!(listed["links"][0]["other"], "GoToR"); - let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination_applied"], true); assert_eq!(opened["destination"]["page"], 3); @@ -4092,51 +5671,63 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { assert_eq!(opened["document"]["pages"], 3); let target_id = opened["document"]["doc"].as_u64().unwrap(); assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3); +} - let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); +fn assert_absolute_gotor_policy(a: &mut Automation) { + let absolute = ok(a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); let before = a.session().docs().len(); for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] { - assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); + assert!(matches!(err(a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); assert_eq!(a.session().docs().len(), before); } - let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); + let opened = ok(a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination"]["page"], 2); +} +fn assert_unapplied_gotor_destinations(a: &mut Automation) { for (source_path, warning) in [("named.pdf", "named destination"), ("range.pdf", "out of range")] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); - let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); assert_eq!(opened["destination_applied"], false); assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}"); } +} - let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); +fn assert_encrypted_gotor_passwords(a: &mut Automation) { + let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before); - let opened = ok(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" })); + let opened = ok(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" })); assert_eq!(opened["document"]["encrypted"], true); +} +fn assert_unsupported_gotor_targets(a: &mut Automation) { for source_path in ["url.pdf", "missing-source.pdf", "non-pdf-source.pdf"] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before, "a rejected target is not opened: {source_path}"); } +} +fn assert_gotor_root_boundaries(a: &mut Automation) { for source_path in ["sub/escape.pdf", "symlink.pdf"] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside"))); + assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside"))); assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened"); } +} - let missing_source = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - ok(&mut a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" })); +fn assert_url_links_cannot_open_as_gotor(a: &mut Automation) { + let missing_source = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" })); assert!( - matches!(err(&mut a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR")) + matches!(err(a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR")) ); } @@ -4320,6 +5911,60 @@ fn typed_signatures_stay_upright_on_rotated_pages() { check(&mut a, reopened); } +/// #2392: a Fill & Sign checkmark and line look the same on a turned page as on an unturned one: +/// the checkmark isn't upside down or sideways, and the line runs across as displayed. +#[test] +fn fill_sign_marks_stay_upright_on_rotated_pages() { + let dir = workdir("marks-upright"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap(); + for (page, degrees) in [(2, 90), (3, 180), (4, 270)] { + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees })); + } + for page in 1..=4 { + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "check", "at": [40, 60] })); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "line", "at": [120, 60] })); + } + let check = |a: &mut Automation, doc: u64| { + let mut upright = Vec::new(); + for page in 1..=4 { + // 144 dpi: two pixels a point. + let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 144 })).unwrap(); + let Content::Png { data, .. } = &output[0] else { panic!() }; + let pixels = image::load_from_memory(data).unwrap().to_rgba8(); + let dark: Vec = pixels.pixels().map(|p| p.0[..3].iter().all(|v| *v < 128)).collect(); + let width = pixels.width(); + // The line, 36 × 4 pt displayed, runs across. + let mut b = [u32::MAX, u32::MAX, 0, 0]; + for (x, y, p) in pixels.enumerate_pixels() { + if x.abs_diff(240) < 60 && y.abs_diff(120) < 60 && p.0[..3].iter().all(|v| *v < 128) { + b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)]; + } + } + assert!(b[0] <= b[2], "page {page}: no line drawn"); + assert!(b[2] - b[0] > 60 && b[3] - b[1] < 10, "page {page}: the line should run across: {b:?}"); + if page == 1 { + upright = dark; + continue; + } + // Same pixels as the unturned page where both pages are (pages 2 and 4 are wider). + let (mut differ, mut ink) = (0, 0); + for y in 0..400 { + for x in 0..400 { + let (want, got) = (upright[(y * 400 + x) as usize], dark[(y * width + x) as usize]); + ink += usize::from(want); + differ += usize::from(want != got); + } + } + assert!(ink > 0 && differ * 10 < ink, "page {page}: {differ} of {ink} ink pixels differ from the unturned page"); + } + }; + check(&mut a, doc); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap(); + check(&mut a, reopened); +} + #[test] fn comment_checkmarks_locks_hiding_and_summaries_through_tools() { let dir = workdir("comment-polish"); @@ -4609,12 +6254,40 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { assert!(inked(60, 110), "the strokes are drawn"); } +/// Signing a laid-out XFA form's signature box through the tools signs the field named by its +/// SOM path, which is how another viewer, laying the form out again, finds the signature; the +/// signed file reopens with the signature intact and on that field. +#[test] +fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() { + let dir = workdir("xfa-sign"); + std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(""))).unwrap(); + let mut a = auto(&dir); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let som = "form[0].page1[0].sign[0]"; + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == som), "{fields}"); + ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": som, "out": "signed.pdf" })); + let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "sign_list", json!({ "doc": signed })); + let s = list["signatures"].as_array().unwrap().iter().find(|s| s["signed"] == true).cloned().unwrap_or_else(|| panic!("{list}")); + assert_eq!(s["field"], som, "{s}"); + assert_eq!(s["status"], "unknown", "intact, the signer not trusted: {s}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn digital_ids_signing_and_validation_through_tools() { let dir = workdir("signing"); let mut a = auto(&dir); + create_signing_identity(&mut a); + let doc = sign_document_and_verify_trust(&mut a, &dir); + verify_signature_revisions_and_bundle(&mut a, doc, &dir); +} + +fn create_signing_identity(a: &mut Automation) { let id = ok( - &mut a, + a, "sign_id_create", json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }), ); @@ -4623,13 +6296,15 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); // Eight characters at least (#1847): seven is refused. assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); +} - let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0); +fn sign_document_and_verify_trust(a: &mut Automation, dir: &Path) -> u64 { + let doc = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["count"], 0); // A Keychain identity that doesn't exist (macOS) or Keychains at all (elsewhere). assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "keychain:No Such Signer", "out": "k.pdf" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "windows:No Such Signer", "out": "w.pdf" })), Err(ToolError::Failed(_)))); - let store = ok(&mut a, "sign_windows_ids", json!({})); + let store = ok(a, "sign_windows_ids", json!({})); let ids = store["ids"].as_array().unwrap(); assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64); assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:"))); @@ -4643,7 +6318,7 @@ fn digital_ids_signing_and_validation_through_tools() { Err(ToolError::InvalidArgs(_)) )); let r = ok( - &mut a, + a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), ); @@ -4655,26 +6330,37 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(dir.join("signed.pdf").exists()); // Trust the ID: valid; comment afterwards: still valid, change allowed. - let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); + let t = ok(a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); assert_eq!(t["trusted"].as_array().unwrap().len(), 1); - let list = ok(&mut a, "sign_list", json!({ "doc": doc })); + let list = ok(a, "sign_list", json!({ "doc": doc })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid"))); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); - let s = &ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]; + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); + let s = &ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]; assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed"))); + doc +} + +fn verify_signature_revisions_and_bundle(a: &mut Automation, doc: u64, dir: &Path) { // A full rewrite is refused for a signed document. assert!(matches!(a.call("doc_save", &json!({ "doc": doc, "path": "copy.pdf", "full": true })), Err(ToolError::Failed(_)))); - ok(&mut a, "doc_save", json!({ "doc": doc })); + ok(a, "doc_save", json!({ "doc": doc })); // Three revisions: the original, the signature, the comment; the middle one is signed. - let revs = ok(&mut a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap(); + let revs = ok(a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap(); assert_eq!(revs.len(), 3, "{revs:?}"); assert!(revs[0]["signed_by"].as_array().unwrap().is_empty() && revs[1]["signed_by"].as_array().unwrap().len() == 1, "{revs:?}"); - let old = ok(&mut a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment"); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": old }))["count"], 1); + let old = ok(a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap(); + assert_eq!(ok(a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment"); + assert_eq!(ok(a, "sign_list", json!({ "doc": old }))["count"], 1); assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_)))); - assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); + assert_eq!(ok(a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); + // The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one. + use pdfcraft_engine::sign::{cms, der, pkcs12}; + let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw; + let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]); + std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap(); + assert_eq!(ok(a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); } /// The decoded text of every visible signature appearance (Helvetica forms) in a signed file. @@ -4793,6 +6479,37 @@ fn optimizing_through_tools() { assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap()); assert_eq!(reduced["written"], true); assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap()); + + // Target PDF 1.4 (disables object streams) and PDF 2.0 (with object_streams toggled off). + let v14 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v14.pdf", "compatibility": "1.4" })); + assert_eq!((v14["pdf_version"].as_str(), v14["object_streams"].as_bool()), (Some("1.4"), Some(false)), "{v14}"); + let v14_bytes = std::fs::read(dir.join("v14.pdf")).unwrap(); + assert!(v14_bytes.starts_with(b"%PDF-1.4\n") && !v14_bytes.windows(7).any(|w| w == b"/ObjStm")); + + let v20 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v20.pdf", "compatibility": "2.0", "object_streams": false })); + assert_eq!((v20["pdf_version"].as_str(), v20["object_streams"].as_bool()), (Some("2.0"), Some(false)), "{v20}"); + assert!(std::fs::read(dir.join("v20.pdf")).unwrap().starts_with(b"%PDF-2.0\n")); + + // In retain mode, a catalog /Version override is reported as the effective PDF version. + let mut cos14 = pdfcraft_cos::Document::open(std::sync::Arc::new(v14_bytes)).unwrap(); + let root = cos14.root().unwrap(); + cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name("1.6"))).unwrap(); + let cat16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join("cat16.pdf"), &cat16).unwrap(); + let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap(); + let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false })); + assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}"); + // The same with the catalog's /Version given by reference: read through it. + let v16 = cos14.add(pdfcraft_cos::Object::name("1.6")); + cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::Ref(v16))).unwrap(); + let ref16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join("ref16.pdf"), &ref16).unwrap(); + let ref_doc = ok(&mut a, "doc_open", json!({ "path": "ref16.pdf" }))["doc"].as_u64().unwrap(); + let by_ref = + ok(&mut a, "doc_optimize", json!({ "doc": ref_doc, "path": "retained-ref.pdf", "compatibility": "retain", "object_streams": false })); + assert_eq!(by_ref["pdf_version"].as_str(), Some("1.6"), "{by_ref}"); + + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_)))); assert!(matches!( a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "color": { "compression": "gif" } })), Err(ToolError::InvalidArgs(_)) @@ -4800,6 +6517,310 @@ fn optimizing_through_tools() { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_)))); } +/// #3227 review: retaining the version, a catalog `/Version` below the 1.5 an object stream +/// needs is not what viewers read: the header is raised, and the later of the two is reported. +#[test] +fn retained_pdf_version_reports_the_header_raised_for_object_streams() { + let dir = workdir("optimize-retain-version"); + let mut a = auto(&dir); + let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_optimize", json!({ "doc": blank, "path": "v14.pdf", "compatibility": "1.4" })); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("v14.pdf")).unwrap())).unwrap(); + let root = cos.root().unwrap(); + cos.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name("1.4"))).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join("cat14.pdf"), &bytes).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "cat14.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "out.pdf", "compatibility": "retain", "object_streams": true })); + let out = std::fs::read(dir.join("out.pdf")).unwrap(); + assert!(out.starts_with(b"%PDF-1.5\n") && out.windows(7).any(|w| w == b"/ObjStm"), "{r}"); + assert_eq!(r["pdf_version"].as_str(), Some("1.5"), "{r}"); +} + +/// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for +/// fewer than 250 bytes, before the code width grows. +fn lzw_literals(data: &[u8]) -> Vec { + let codes: Vec = std::iter::once(256).chain(data.iter().map(|&b| u16::from(b))).chain([257]).collect(); + let bits: Vec = codes.iter().flat_map(|c| (0..9).rev().map(move |i| c >> i & 1 == 1)).collect(); + bits.chunks(8).map(|byte| byte.iter().enumerate().fold(0u8, |acc, (i, &b)| acc | (u8::from(b) << (7 - i)))).collect() +} + +#[test] +fn lzw_streams_are_stored_with_flate_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; + let dir = workdir("optimize-lzw"); + let ops = b"0.2 0.4 0.8 rg 72 72 200 100 re f 0 0 1 RG 72 300 m 400 500 l S".to_vec(); + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let lzw = Stream::from_raw(Dict::from_iter([(b"Filter".to_vec(), Object::name("LZWDecode"))]), lzw_literals(&ops)); + assert_eq!(lzw.decoded_strict().unwrap(), ops); + let content = doc.add(Object::Stream(lzw)); + let page = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])), + (b"Contents".to_vec(), Object::Ref(content)), + ]))); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + std::fs::write(dir.join("lzw.pdf"), write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "lzw.pdf" }))["doc"].as_u64().unwrap(); + let filters = |name: &str| { + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).unwrap())).unwrap(); + let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + let pages = saved.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap(); + let page = saved.get(pages.get(b"Kids").and_then(|k| k.as_array()?.first()?.as_ref()).unwrap()).as_dict().cloned().unwrap(); + match &*saved.get(page.reference(b"Contents").unwrap()) { + Object::Stream(s) => (s.dict.get(b"Filter").cloned(), s.decoded_strict().unwrap()), + _ => panic!("the page content is not a stream"), + } + }; + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "flate.pdf" })); + assert_eq!(r["lzw_streams"].as_u64(), Some(1), "{r}"); + assert_eq!(filters("flate.pdf"), (Some(Object::name("FlateDecode")), ops.clone())); + let off = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "lzw_to_flate": false })); + assert_eq!(off["lzw_streams"].as_u64(), Some(0), "{off}"); + assert_eq!(filters("kept.pdf"), (Some(Object::name("LZWDecode")), ops)); +} + +/// A one-page PDF drawing a 1-bit scan of text (glyph strokes with ragged edges, as a scanner +/// leaves them) of 1000 × 400 samples, stored with Flate. +fn scanned_page() -> Vec { + scan_drawn(b"q 500 0 0 200 50 500 cm /Scan Do Q") +} + +/// [`scanned_page`]'s scan, drawn by `content` (which names it `/Scan`). +fn scan_drawn(content: &[u8]) -> Vec { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; + let (w, h) = (1000usize, 400usize); + let stride = w.div_ceil(8); + let mut data = vec![0xFFu8; stride * h]; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for line in (4..h - 20).step_by(24) { + let mut x = 8; + while x + 12 < w { + let wide = 3 + (next() % 5) as usize; + let (top, bottom) = (line + (next() % 4) as usize, line + 14); + for y in top..bottom { + let left = x + usize::from(next() % 8 == 0); + let right = x + wide - usize::from(next() % 8 == 0); + for xx in left..right { + data[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + x += wide + 2 + (next() % 4) as usize; + } + } + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::flate(d, &data))); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + x.set(b"Scan".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// A scan saved as a 1-bit PNG becomes a page whose picture stays one bit per pixel (not the eight +/// bits the PNG decodes to), and Export all images gives the picture back. +#[test] +fn a_one_bit_scan_stays_one_bit_and_exports() { + let dir = workdir("bilevel"); + let (w, h) = (800u32, 200u32); + let stride = (w as usize).div_ceil(8); + let mut bits = vec![0xFFu8; stride * h as usize]; + for y in 0..h as usize { + for x in 0..w as usize { + if y % 25 >= 6 && y % 25 < 18 && (x * 7 + y / 25 * 13) % 11 < 4 { + bits[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let mut png_bytes = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png_bytes, w, h); + enc.set_color(png::ColorType::Grayscale); + enc.set_depth(png::BitDepth::One); + enc.write_header().unwrap().write_image_data(&bits).unwrap(); + } + std::fs::write(dir.join("scan.png"), &png_bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["scan.png"] }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "scan.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("scan.pdf")).unwrap())).unwrap(); + let images: Vec> = saved + .object_numbers() + .into_iter() + .filter_map(|num| match &*saved.get(pdfcraft_cos::ObjRef { num, generation: saved.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some(s.dict.int(b"BitsPerComponent")), + _ => None, + }) + .collect(); + assert_eq!(images, [Some(1)]); + let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": "out" })); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{r}"); + let exported = std::fs::read(files[0]["path"].as_str().unwrap()).unwrap(); + let mut reader = png::Decoder::new(std::io::Cursor::new(exported)).read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut buf).unwrap(); + let white: Vec = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().map(|p| *p == [255, 255, 255]).collect(); + let expected: Vec = + (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| (y, x))).map(|(y, x)| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect(); + assert_eq!(white, expected); +} + +/// With `mono: { compression: jbig2 }` a scan's black-and-white image is stored as lossless JBIG2, +/// smaller than as Group 4, and exports as the same picture. +#[test] +fn doc_optimize_stores_black_and_white_images_as_jbig2() { + let dir = workdir("optimize-jbig2"); + std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); + let g4 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "g4.pdf" })); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "jbig2.pdf", "mono": { "compression": "jbig2" } })); + assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}"); + assert!(r["image_bytes_after"].as_u64() < g4["image_bytes_after"].as_u64(), "JBIG2 {r}, Group 4 {g4}"); + let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("jbig2.pdf")).unwrap())).unwrap(); + let filters: Vec> = written + .object_numbers() + .into_iter() + .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec), + _ => None, + }) + .collect(); + assert_eq!(filters, [b"JBIG2Decode".to_vec()]); + // Export ▸ Images gives the copy's picture back as it gives the scan's. + let copy = ok(&mut a, "doc_open", json!({ "path": "jbig2.pdf" }))["doc"].as_u64().unwrap(); + let mut exported = |doc: u64, folder: &str| { + let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": folder })); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{r}"); + std::fs::read(files[0]["path"].as_str().unwrap()).unwrap() + }; + assert_eq!(exported(copy, "from-jbig2"), exported(doc, "from-scan")); +} + +/// A scan's black-and-white image is stored as CCITT Group 4 by default, and kept as it is with +/// `mono: { compression: retain }`. +#[test] +fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() { + let dir = workdir("optimize-mono"); + std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "compression": "retain" } })); + assert_eq!((kept["images"].as_u64(), kept["images_recompressed"].as_u64()), (Some(1), Some(0)), "{kept}"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" })); + assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}"); + assert!(r["image_bytes_after"].as_u64() < r["image_bytes_before"].as_u64(), "{r}"); + // The copy written stores the image as Group 4. + let small = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("small.pdf")).unwrap())).unwrap(); + let filters: Vec> = small + .object_numbers() + .into_iter() + .filter_map(|num| match &*small.get(pdfcraft_cos::ObjRef { num, generation: small.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec), + _ => None, + }) + .collect(); + assert_eq!(filters, [b"CCITTFaxDecode".to_vec()]); + // An unknown compression, one that isn't a name, a misspelled key, or a mono that isn't an + // object is refused, not read as the default. + for mono in [json!({ "compression": "jpeg" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] { + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}"); + } +} + +/// A scan drawn at 600 ppi is averaged down to 300 ppi by default. `mono: { downsample: false }` +/// keeps its size, `ppi` and `above_ppi` choose others, and values of the wrong type are refused. +#[test] +fn doc_optimize_averages_black_and_white_scans_down() { + let dir = workdir("optimize-mono-downsample"); + // 1000 × 400 samples on 120 × 48 pt: 600 ppi. + std::fs::write(dir.join("scan.pdf"), simple_rule_scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); + let sizes = |name: &str| image_sizes(&dir.join(name)); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" })); + assert_eq!(r["images_resampled"].as_u64(), Some(1), "{r}"); + assert_eq!(sizes("small.pdf"), [(Some(500), Some(200))]); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "downsample": false } })); + assert_eq!(r["images_resampled"].as_u64(), Some(0), "{r}"); + assert_eq!(sizes("kept.pdf"), [(Some(1000), Some(400))]); + ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "200.pdf", "mono": { "ppi": 200, "above_ppi": 250 } })); + assert_eq!(sizes("200.pdf"), [(Some(333), Some(133))]); + for mono in [json!({ "downsample": "yes" }), json!({ "ppi": "300" }), json!({ "above": 450 })] { + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}"); + } +} + +/// The width and height of every image in the PDF at `path`. +fn image_sizes(path: &std::path::Path) -> Vec<(Option, Option)> { + let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + written + .object_numbers() + .into_iter() + .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))), + _ => None, + }) + .collect() +} + +/// The batch optimize step's `ppi` reaches black-and-white images too: they go to the larger of +/// it and Reduce File Size's 300 ppi. Before, a batch run took every scan to 300 ppi whatever +/// `ppi` said, so `ppi: 600` halved 600 ppi scans. +#[test] +fn batch_ppi_bounds_how_far_black_and_white_scans_are_resampled() { + let dir = workdir("batch-mono-ppi"); + // 1000 × 400 samples on 120 × 48 pt: 600 ppi. + std::fs::write(dir.join("scan.pdf"), simple_rule_scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); + let mut a = auto(&dir); + for (ppi, size) in [(None, (500, 200)), (Some(600), (1000, 400)), (Some(96), (500, 200))] { + let out = format!("out-{ppi:?}"); + let mut args = json!({ "paths": ["scan.pdf"], "steps": ["optimize"], "out_dir": out }); + if let Some(ppi) = ppi { + args["ppi"] = json!(ppi); + } + let report = ok(&mut a, "doc_batch", args); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(image_sizes(&dir.join(&out).join("scan.pdf")), [(Some(size.0), Some(size.1))], "ppi {ppi:?}"); + } +} + /// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px /// wide on a 72 pt square (600 ppi), as some PDF printers write it. fn printed_in_strips(rows: usize) -> Vec { @@ -5022,6 +7043,62 @@ fn ocr_recognize_files_writes_searchable_copies() { assert!(found.to_string().contains("\"page\""), "{found}"); } +/// Two inputs that share a file name (from different folders) would both write `out/scan.pdf`: +/// the second output replaces the first while both results report success. The batch is refused +/// up front, naming the file, before the folder is created, anything is read or the OCR engines +/// load, so the outcome is the same with or without the models installed. +#[test] +fn ocr_recognize_files_refuses_inputs_that_share_a_file_name() { + let dir = workdir("ocr-files-same-name"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "one/scan.pdf" })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "two/scan.pdf" })); + let r = a.call("ocr_recognize_files", &json!({ "paths": ["one/scan.pdf", "two/scan.pdf"], "folder": "out" })); + assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains("scan.pdf")), "{r:?}"); + assert!(!dir.join("out").exists(), "nothing was started: the output folder was not created"); +} + +/// File names are compared without regard to case on Windows and macOS (`scan.pdf` and `Scan.PDF` +/// are one file there), and exactly elsewhere, where they are two files and the batch is not +/// refused. +#[test] +fn ocr_recognize_files_compares_names_the_way_the_filesystem_does() { + let dir = workdir("ocr-files-case"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "one/scan.pdf" })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "two/Scan.PDF" })); + let r = a.call("ocr_recognize_files", &json!({ "paths": ["one/scan.pdf", "two/Scan.PDF"], "folder": "out" })); + if cfg!(any(windows, target_os = "macos")) { + assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains("Scan.PDF")), "{r:?}"); + assert!(!dir.join("out").exists(), "nothing was started: the output folder was not created"); + } else { + assert!(!matches!(&r, Err(ToolError::InvalidArgs(_))), "{r:?}"); + } +} + +/// Inputs with distinct names are not refused, and the batch writes one searchable copy per input +/// under its own name. Without the models it stops at the models error, as it did before. +#[test] +fn ocr_recognize_files_with_distinct_names_runs_as_before() { + let dir = workdir("ocr-files-distinct"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "one/scan.pdf" })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "two/other.pdf" })); + if ok(&mut a, "ocr_status", json!({}))["available"] == true { + let r = ok(&mut a, "ocr_recognize_files", json!({ "paths": ["one/scan.pdf", "two/other.pdf"], "folder": "out" })); + assert!(r["files"][0]["error"].is_null() && r["files"][1]["error"].is_null(), "{r}"); + assert!(dir.join("out/scan.pdf").is_file() && dir.join("out/other.pdf").is_file(), "{r}"); + } else { + let r = a.call("ocr_recognize_files", &json!({ "paths": ["one/scan.pdf", "two/other.pdf"], "folder": "out" })); + assert!(matches!(&r, Err(ToolError::Failed(m)) if m.contains("not installed")), "{r:?}"); + } +} + +/// Field scripts set through the tools validate, calculate and format, and `js_run` reports console +/// lines, every message with its details, and requests. #[test] fn javascript_through_tools() { let dir = workdir("js"); @@ -5055,16 +7132,29 @@ fn javascript_through_tools() { let r = ok( &mut a, "js_run", - json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok');" }), + json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok'); app.alert({ cMsg: 'Clear?', cTitle: 'Order', nIcon: 2, nType: 3 });" }), ); assert_eq!(r["console"][0], "2500"); - assert_eq!(r["alerts"][0], "ok"); + // Every message, in order, with its title, icon and buttons. + assert_eq!( + r["alerts"], + json!([ + { "message": "ok", "title": null, "icon": "error", "buttons": "ok" }, + { "message": "Clear?", "title": "Order", "icon": "question", "buttons": "yes_no_cancel" }, + ]) + ); assert_eq!(r["requests"][0]["page"], 2); let f = ok(&mut a, "form_fields", json!({ "doc": doc })); let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone(); assert_eq!(total["value"], "3750"); let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "nope()" })); assert!(r["error"].as_str().unwrap().contains("nope")); + assert_eq!(r["document_errors"], json!([])); + // A document script that throws (#3940) is reported, and the others and the script still run. + ok(&mut a, "js_set_document_script", json!({ "doc": doc, "name": "zz", "script": "getField('Total').buttonSetIcon(null);" })); + let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "money(5)" })); + assert_eq!((r["error"].clone(), r["result"].clone()), (json!(null), json!("EUR 5,00"))); + assert!(r["document_errors"][0].as_str().unwrap().starts_with("document script 2: TypeError"), "{r}"); assert_eq!(ok(&mut a, "js_enabled", json!({ "enabled": false }))["enabled"], false); assert!(a.call("js_run", &json!({ "doc": doc, "script": "1" })).is_err()); } @@ -5126,6 +7216,65 @@ fn detecting_form_fields_through_tools() { assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}"); } +#[test] +fn detecting_form_fields_preserves_content_piece_comments() { + for visible in [false, true] { + let first = format!("BT /F1 18 Tf 40 250 Td (FIRST) Tj ET % trailing comment{}", if visible { "\n" } else { "" }); + let last = b"40 120 300 30 re S\nBT /F1 18 Tf 40 50 Td (LAST) Tj ET\n"; + let mut source = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&source)[0].obj; + let contents = [first.as_bytes(), last] + .iter() + .map(|bytes| pdfcraft_cos::Object::Ref(source.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), bytes))))) + .collect(); + source + .update_dict(page, |d| { + d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Array(contents)); + d.set(b"MediaBox".to_vec(), pdfcraft_cos::Object::Array(vec![0.into(), 0.into(), 400.into(), 300.into()])); + }) + .unwrap(); + let dir = workdir(if visible { "detect-visible-piece" } else { "detect-hidden-piece" }); + std::fs::write(dir.join("pieces.pdf"), pdfcraft_cos::write_full(&source, &Default::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "pieces.pdf"}))["doc"].as_u64().unwrap(); + let candidates = if visible { json!([{"page": 1, "kind": "text", "name": "Text1", "rect": [41.0, 121.0, 339.0, 149.0]}]) } else { json!([]) }; + let inventory = if visible { + json!([{"name": "Text1", "type": "text", "value": null, "page": 1, "rect": [41.0, 151.0, 339.0, 179.0], "rotation": 0, "read_only": false, "required": false, "multiline": false}]) + } else { + json!([]) + }; + let check_text = |a: &mut Automation, doc| { + let text = page_text(a, doc).join(" "); + assert_eq!(text.split_whitespace().collect::>(), ["FIRST", "LAST"]); + }; + let check_render = |a: &mut Automation, doc| { + let (width, height, pixels) = a.render_pixels(doc, 1, 1.0).unwrap(); + assert_eq!((width, height), (400, 300)); + let region: Vec<_> = (145..185).flat_map(|y| (35..345).map(move |x| (y * 400 + x) * 4)).collect(); + if visible { + assert!(region.iter().any(|&i| pixels[i..i + 3].iter().all(|&v| v < 128)), "the visible box has ink"); + } else { + assert!(region.iter().all(|&i| pixels[i..i + 3] == [255, 255, 255]), "the hidden box region stays white"); + } + }; + check_text(&mut a, doc); + check_render(&mut a, doc); + assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], json!([])); + assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc, "add": false}))["fields"], candidates); + assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], json!([])); + assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc}))["fields"], candidates); + assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], inventory); + assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc}))["fields"], json!([])); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "saved.pdf"})); + let mut reopened = auto(&dir); + let again = ok(&mut reopened, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut reopened, "form_fields", json!({"doc": again}))["fields"], inventory); + check_text(&mut reopened, again); + check_render(&mut reopened, again); + std::fs::remove_dir_all(dir).unwrap(); + } +} + #[test] fn detecting_comb_boxes_through_tools() { let mut body = String::from("BT /F1 10 Tf 40 105 Td (Postcode) Tj ET\n"); @@ -5240,6 +7389,10 @@ fn pdfa_through_tools() { assert_eq!(r["declared"]["pdfa"], "PDF/A-3b", "{r}"); assert!(r["issues"].as_array().unwrap().iter().all(|i| i["fixable"] == false), "{r}"); assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err()); + ok(&mut a, "standards_remove_id", json!({ "doc": doc, "standard": "pdfa" })); + let r = ok(&mut a, "pdfa_verify", json!({ "doc": doc })); + assert!(r["declared"]["pdfa"].is_null() && !r["declared"]["output_intents"].as_array().unwrap().is_empty(), "{r}"); + assert!(matches!(a.call("standards_remove_id", &json!({ "doc": doc, "standard": "pdfa" })), Err(ToolError::Failed(_))), "nothing left"); } #[test] @@ -5270,7 +7423,7 @@ fn exporting_to_word_html_and_rtf() { let dir = workdir("office"); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - for ext in ["docx", "pptx", "html", "rtf"] { + for ext in ["docx", "pptx", "html", "rtf", "md"] { let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("a.{ext}") })); assert_eq!(r["format"], ext); } @@ -5279,9 +7432,81 @@ fn exporting_to_word_html_and_rtf() { assert!(std::fs::read(dir.join("a.docx")).unwrap().starts_with(b"PK")); assert!(std::fs::read(dir.join("a.pptx")).unwrap().starts_with(b"PK")); assert!(std::fs::read_to_string(dir.join("a.rtf")).unwrap().contains("Page 2")); + // #1595: Markdown, with a rule between pages. + let md = std::fs::read_to_string(dir.join("a.md")).unwrap(); + assert!(md.contains("Page 1") && md.contains("Page 3") && md.matches("\n---\n").count() == 2, "{md}"); + assert_eq!(ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "b.markdown" }))["format"], "md"); assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); } +/// #3120: a PDF whose pages draw "Head", then a chain of `depths[page]` nested forms whose +/// innermost writes "TAIL". +fn nested_forms_pdf(depths: &[usize]) -> Vec { + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), String::new(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()]; + let mut kids = Vec::new(); + for &depth in depths { + // The object number of D1, D2 and so on. + let first = objs.len() + 1; + let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", first + i - 1)).collect(); + let resources = format!("<< /Font << /F1 3 0 R >> /XObject << {xobjects}>> >>"); + for i in 1..=depth { + let body = if i == depth { "BT /F1 24 Tf 20 150 Td (TAIL) Tj ET".to_string() } else { format!("/D{} Do", i + 1) }; + objs.push(format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 300] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream", + body.len() + )); + } + let body = "BT /F1 24 Tf 20 250 Td (Head) Tj ET /D1 Do"; + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", objs.len())); + kids.push(format!("{} 0 R", objs.len())); + } + objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {} /MediaBox [0 0 200 300] >>", kids.join(" "), kids.len()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #3120: export reads forms as deeply nested as the renderer draws them (13 used to lose their +/// text), and its result says which pages draw forms past that, whose text is missing. +#[test] +fn exporting_office_files_says_which_pages_left_form_text_out() { + use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; + let dir = workdir("office-forms"); + std::fs::write(dir.join("deep.pdf"), nested_forms_pdf(&[13, MAX_FORM_DEPTH + 1, MAX_FORM_DEPTH])).unwrap(); + std::fs::write(dir.join("fine.pdf"), nested_forms_pdf(&[13])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap(); + for ext in ["html", "docx"] { + let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("deep.{ext}") })); + assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(true), &json!([2])), "{r}"); + let warning = r["warnings"][0].as_str().unwrap(); + assert!(warning.contains(&format!("{MAX_FORM_DEPTH} deep")) && warning.contains(&MAX_FORM_VISITS.to_string()), "{warning}"); + assert!(warning.contains("truncated_pages"), "{warning}"); + } + let html = std::fs::read_to_string(dir.join("deep.html")).unwrap(); + let tails: Vec = html.split("
").map(|page| page.matches("TAIL").count()).collect(); + assert_eq!(tails, [1, 0, 1], "{html}"); + assert_eq!(html.matches("Head").count(), 3, "{html}"); + + let fine = ok(&mut a, "doc_open", json!({ "path": "fine.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_export_office", json!({ "doc": fine, "path": "fine.html" })); + assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(false), &json!([])), "{r}"); + assert!(r.get("warnings").is_none(), "{r}"); + assert!(std::fs::read_to_string(dir.join("fine.html")).unwrap().contains("TAIL")); +} + /// #773: a diagonal watermark must not enter the body flow or move invoice fields. #[test] fn exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow() { @@ -5325,12 +7550,79 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET"; assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. }))); } +/// #4158: a word set in italic inside a sentence ("Before *Chevron* after.") reaches Word as an +/// italic run of the sentence's paragraph, not as a paragraph of its own between two halves. #[test] -fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() { +fn exporting_to_word_keeps_an_italic_word_inside_its_sentence() { + let dir = workdir("italic-run-word"); + let content = "BT /F1 12 Tf 72 700 Td (Before ) Tj /F2 12 Tf (Chevron) Tj /F1 12 Tf ( after.) Tj ET \ +BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("opinion.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "opinion.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "opinion.docx" })); + let zip = std::fs::read(dir.join("opinion.docx")).unwrap(); + let mut at = 0; + let mut xml = String::new(); + while zip[at..].starts_with(b"PK\x03\x04") { + let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize; + let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize; + let data = at + 30 + len; + if &zip[at + 30..data] == b"word/document.xml" { + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap(); + break; + } + at = data + size; + } + // The paragraphs that hold text, each as its runs: (text, italic). + let paragraphs: Vec> = xml + .split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("") + .skip(1) + .filter_map(|r| Some((r.split_once("")?.1.split_once("")?.0.to_string(), r.contains("")))) + .collect::>() + }) + .filter(|runs| !runs.is_empty()) + .collect(); + let sentence = paragraphs.iter().find(|p| p.iter().any(|(t, _)| t.contains("Before"))).unwrap_or_else(|| panic!("{xml}")); + assert_eq!(sentence.len(), 3, "one paragraph, three runs: {xml}"); + assert_eq!(sentence.iter().map(|(t, _)| t.as_str()).collect::(), "Before Chevron after.", "{xml}"); + assert_eq!(sentence.iter().map(|(_, i)| *i).collect::>(), [false, true, false], "{xml}"); + assert_eq!(paragraphs.len(), 2, "{xml}"); +} + +/// Hundreds of calculations run on opening and again after a change through the tools. The +/// time limits are raised so that a busy machine does not stop them (the engine's tests check +/// that the limits stop them). +#[test] +fn hundreds_of_xfa_calculations_open_and_recalculate_through_tools() { let dir = workdir("xfa-many"); let (sections, rows) = (10, 20); std::fs::write(dir.join("many.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))).unwrap(); - let mut a = auto(&dir); + let mut a = auto(&dir).with_xfa_time_scale(100); let started = std::time::Instant::now(); let opened = ok(&mut a, "doc_open", json!({ "path": "many.pdf" })); let open_took = started.elapsed(); @@ -5412,6 +7704,65 @@ fn xfa_data_files_export_and_import_through_tools() { assert!(std::fs::read_to_string(dir.join("static.xml")).unwrap().contains("Ada"), "a static XFA form exports its data too"); } +#[test] +fn xfa_rich_text_max_length_is_reported_and_enforced_through_tools() { + let dir = workdir("xfa-rich-limit"); + let template = pdfcraft_xfa::fixtures::template_with_data(0, "
Old
") + .replace(r#""#, r#""#) + .replace("", ""); + std::fs::write(dir.join("rich.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "rich.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let family = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "familyName").unwrap(); + assert_eq!(family["max_length"], 5); + + // The boundary counts Unicode characters, not UTF-8 bytes. + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "familyName": "éàöüñ" } })); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "before.xml", "what": "fields" })); + let before = std::fs::read(dir.join("before.xml")).unwrap(); + assert!(std::str::from_utf8(&before).unwrap().contains("éàöüñ")); + let error = a.call("form_fill", &json!({ "doc": doc, "values": { "familyName": "éàöüñX" } })).unwrap_err(); + assert!(error.to_string().contains("at most 5 characters"), "{error}"); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "after.xml", "what": "fields" })); + assert_eq!(std::fs::read(dir.join("after.xml")).unwrap(), before, "refused edits leave the XFA data unchanged"); + + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + ok(&mut a, "doc_close", json!({ "doc": doc })); + let doc = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let family = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "familyName").unwrap(); + assert_eq!(family["max_length"], 5); + assert_eq!(family["value"], "éàöüñ"); +} + +/// A link in a dynamic XFA form's rich text is a link on the laid-out page, as link_list reports +/// it, and stays one after a save and a fresh open. +#[test] +fn links_in_xfa_rich_text_are_listed_through_tools() { + let dir = workdir("xfa-links"); + let xdp = r##" +"##; + std::fs::write(dir.join("links.pdf"), pdfcraft_xfa::fixtures::shell(xdp)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "links.pdf" }))["doc"].as_u64().unwrap(); + let urls = |a: &mut Automation, doc: u64| -> Vec { + let listed = ok(a, "link_list", json!({ "doc": doc })); + let links = listed["links"].as_array().cloned().unwrap_or_default(); + links.iter().filter_map(|l| l["url"].as_str().map(str::to_string)).collect() + }; + assert_eq!(urls(&mut a, doc), ["mailto:forms@example.com"]); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "name": "Ada" } })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" })); + ok(&mut a, "doc_close", json!({ "doc": doc })); + let doc = ok(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(urls(&mut a, doc), ["mailto:forms@example.com"], "one link after a save and a fresh open, not two"); +} + #[test] fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() { let dir = workdir("xfa"); @@ -5479,7 +7830,9 @@ fn cut_stack_printing_through_tools() { &mut a, "doc_print", json!({ - "doc": doc, "pages": "2-10", "subset": "odd", "reverse": true, + // Even pages by page number (#3984): 2, 4, 6, 8 and 10 (counting places in the range, + // "even" would have been 3, 5, 7 and 9). + "doc": doc, "pages": "2-10", "subset": "even", "reverse": true, "layout": "multiple", "order": "cut-stack", "per_sheet": 2, "auto_rotate": false, "orientation": "landscape", "path": "range.pdf" }), @@ -5502,6 +7855,9 @@ fn cut_stack_printing_through_tools() { } assert!(matches!(a.call("doc_print", &json!({"doc": doc, "order": "cut-stack", "path": "refused.pdf"})), Err(ToolError::InvalidArgs(_)))); assert!(!dir.join("refused.pdf").exists()); + // The printer's own two-sided default isn't refused: cut and stack sends single-sided (#2422). + ok(&mut a, "doc_print", json!({"doc": doc, "layout": "multiple", "order": "cut-stack", "duplex": "printer", "path": "printer-default.pdf"})); + assert!(dir.join("printer-default.pdf").exists()); assert!(a.call("doc_print", &json!({"doc": doc, "layout": "multiple", "order": "cut-stack", "path": "../escaped.pdf"})).is_err()); let _ = std::fs::remove_dir_all(dir); } @@ -5560,6 +7916,51 @@ fn measurements_calibrate_draw_save_reopen_and_export() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn measurement_scale_ratio_takes_the_page_side_in_a_unit() { + // #2752: a plan drawn at 1:50 is "1 mm = 50 mm", so a line 10 mm long on the page is 500 mm. + let dir = workdir("measurement-ratio"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + let mm = 72.0 / 25.4; + let line = json!([[20.0, 30.0], [20.0 + 10.0 * mm, 30.0]]); + let read = |a: &mut Automation, doc: u64| ok(a, "measure_info", json!({"doc":doc,"page":1,"points":line}))["reading"]["value"].as_f64().unwrap(); + let set = ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"page_distance":1,"page_unit":"mm","distance":50,"unit":"mm"})); + assert_eq!(set["scale"]["ratio"], "1 mm = 50 mm"); + assert!((read(&mut a, doc) - 500.0).abs() < 1e-9); + // The PDF keeps its factors per point: 50 mm for every 25.4 / 72 mm of page. + assert!((set["scale"]["x"].as_f64().unwrap() - 50.0 / mm).abs() < 1e-9); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert!((read(&mut a, doc) - 10.0 / 25.4).abs() < 1e-9, "true size in inches without a scale"); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert!((read(&mut a, doc) - 500.0).abs() < 1e-9); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"ratio.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"ratio.pdf"}))["doc"].as_u64().unwrap(); + assert!((read(&mut a, reopened) - 500.0).abs() < 1e-9); + assert_eq!(ok(&mut a, "measure_scale", json!({"doc":reopened,"page":1,"at":[20,30]}))["scale"]["ratio"], "1 mm = 50 mm"); + // The page side defaults to the real-world unit; any page unit converts to points. + ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"page_distance":2,"distance":1,"unit":"cm"})); + assert!((read(&mut a, doc) - 0.5).abs() < 1e-9); + ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"page_distance":0.25,"page_unit":"in","distance":1,"unit":"ft"})); + assert!((read(&mut a, doc) - 10.0 / 25.4 * 4.0).abs() < 1e-9); + // Units per point keep meaning points. + ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"units_per_point":50,"unit":"mm"})); + assert!((read(&mut a, doc) - 500.0 * mm).abs() < 1e-9); + let before = ok(&mut a, "doc_list", json!({})); + for args in [ + json!({"doc":doc,"page":1,"page_distance":1,"page_unit":"ft","distance":1,"unit":"ft"}), + json!({"doc":doc,"page":1,"page_distance":1,"distance":1,"unit":"m"}), + json!({"doc":doc,"page":1,"page_distance":1,"unit":"mm"}), + json!({"doc":doc,"page":1,"page_unit":"mm","units_per_point":1}), + json!({"doc":doc,"page":1,"page_distance":1,"units_per_point":1,"distance":1}), + json!({"doc":doc,"page":1,"page_distance":1,"points":[[0,0],[10,0]],"distance":1}), + ] { + assert!(a.call("measure_scale", &args).is_err(), "{args}"); + assert_eq!(ok(&mut a, "doc_list", json!({})), before); + } + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn measurements_bad_arguments_leave_document_and_history_unchanged() { let dir = workdir("measurement-errors"); @@ -5606,11 +8007,74 @@ fn measurement_snap_tool_covers_all_targets() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn growable_xfa_text_paginates_after_fill_save_and_print() { + let dir = workdir("xfa-growable-text"); + let text: String = (0..150).map(|i| format!("Row{i:03} {}sample\n", "sample ".repeat(8))).collect(); + assert_eq!(text.len(), 10_500); + for scripted in [false, true] { + let template = pdfcraft_xfa::fixtures::growable_text_template(scripted); + std::fs::write(dir.join("growing.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap(); + let mut a = auto(&dir); + let opened = ok(&mut a, "doc_open", json!({ "path": "growing.pdf" })); + let doc = opened["doc"].as_u64().unwrap(); + assert_eq!(opened["pages"], 2); + let filled = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "ACTIVIDADES": text } })); + assert_eq!(filled["pages"], 3, "scripted={scripted}: {filled}"); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xml", "what": "fields" })); + let data = std::fs::read_to_string(dir.join("data.xml")).unwrap(); + assert!(data.contains(text.trim_end()), "the full value remains in the datasets"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" })); + ok(&mut a, "doc_close", json!({ "doc": doc })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "saved.pdf" })); + assert_eq!(reopened["pages"], 3); + let doc = reopened["doc"].as_u64().unwrap(); + ok(&mut a, "doc_print", json!({ "doc": doc, "path": "printed.pdf" })); + let printed = ok(&mut a, "doc_open", json!({ "path": "printed.pdf" })); + assert_eq!(printed["pages"], 3); + let output = ok(&mut a, "text_extract", json!({ "doc": printed["doc"] })); + let printed_text = output["pages"].as_array().unwrap().iter().map(|p| p["text"].as_str().unwrap()).collect::>().join("\n"); + let expected = format!("{text}\nFollowing section"); + assert_eq!(printed_text.split_whitespace().collect::>(), expected.split_whitespace().collect::>(), "scripted={scripted}"); + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() { + let dir = workdir("xfa-print-buttons"); + let template = pdfcraft_xfa::fixtures::repeated_print_button_template(); + std::fs::write(dir.join("print.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap(); + // Raised time limits: a busy machine does not stop the click scripts this checks. + let mut a = auto(&dir).with_xfa_time_scale(100); + let doc = ok(&mut a, "doc_open", json!({ "path": "print.pdf" }))["doc"].as_u64().unwrap(); + let check = |a: &mut Automation, doc| { + let fields = ok(a, "form_fields", json!({ "doc": doc })); + let buttons: Vec<_> = fields["fields"].as_array().unwrap().iter().filter(|f| f["type"] == "button").collect(); + assert_eq!(buttons.len(), 3); + for (index, field) in buttons.iter().enumerate() { + assert_eq!(field["page"], index + 1); + let out = ok(a, "js_run", json!({ "doc": doc, "field": field["name"], "script": "throw new Error('AcroForm fallback ran')" })); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["requests"], json!([{ "print": true }]), "{out}"); + assert_eq!(out["alerts"], json!([])); + } + }; + check(&mut a, doc); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" })); + ok(&mut a, "doc_close", json!({ "doc": doc })); + let doc = ok(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap(); + check(&mut a, doc); +} + +/// A dynamic XFA form's scripts run on open, on fills and for buttons through the tools, with undo, +/// and `js_run` reports their messages. #[test] fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { let dir = workdir("xfa-scripts"); std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap(); - let mut a = auto(&dir); + // Raised time limits: a busy machine does not stop the scripts this checks. + let mut a = auto(&dir).with_xfa_time_scale(100); let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap(); let field = |a: &mut Automation, n: &str| { let f = ok(a, "form_fields", json!({ "doc": doc })); @@ -5633,7 +8097,36 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { ok(&mut a, "edit_undo", json!({ "doc": doc })); assert!(field(&mut a, "amount_2").is_none()); let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" })); - assert_eq!(r["alerts"], json!(["Hello 4"])); + assert_eq!(r["alerts"], json!([{ "message": "Hello 4", "title": null, "icon": "error", "buttons": "ok" }])); +} + +#[test] +fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() { + let dir = workdir("xfa-container-scripts"); + std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "form.pdf"}))["doc"].as_u64().unwrap(); + let fallback = "app.alert('This PDF form requires a newer version of Adobe Acrobat');"; + ok(&mut a, "js_set_document_script", json!({"doc":doc,"name":"viewerFallback","script":fallback})); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"app.alert('Console message');"})); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["alerts"], json!([{ "message": "Console message", "title": null, "icon": "error", "buttons": "ok" }])); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"})); + ok(&mut a, "doc_close", json!({"doc":doc})); + let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "js_document_scripts", json!({"doc":doc}))["scripts"], json!([{"name":"viewerFallback","script":fallback}])); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""})); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["alerts"], json!([])); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"","field":"hello"})); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["alerts"], json!([{ "message": "Hello 2", "title": null, "icon": "error", "buttons": "ok" }])); + ok(&mut a, "xfa_to_static", json!({"doc":doc})); + let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""})); + assert_eq!(out["alerts"].as_array().unwrap().len(), 1); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_eq!(ok(&mut a, "js_run", json!({"doc":doc,"script":""}))["alerts"], json!([])); + let _ = std::fs::remove_dir_all(dir); } #[test] @@ -5651,12 +8144,13 @@ fn doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off() { let _ = std::fs::remove_dir_all(dir); } -/// doc_info describes a link's set-layer-visibility action by layer name. +/// doc_info describes a link's set-layer-visibility action by layer name, and each layer's +/// visibility now and as the file opens. #[test] fn doc_info_describes_set_layer_links() { let dir = workdir("layer-links"); let objs = [ - "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << >> >> >>", + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << /OFF [5 0 R] >> >> >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R] >>", "<< /Type /OCG /Name (Red) >>", @@ -5687,6 +8181,7 @@ fn doc_info_describes_set_layer_links() { "preserve_rb": false, }) ); + assert_eq!(info["layers"], json!([{ "name": "Red", "visible": true, "initial": true }, { "name": "Green", "visible": false, "initial": false }])); } mod close_argument_tests { @@ -6049,7 +8544,7 @@ fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() { let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); let text = "שם משפחה: שרון, טל׳ 052-1234567 (נייד)"; - let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 400, 40], "size": 11, "align": "right"}); + let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 190, 40], "size": 7, "align": "right"}); if pdfcraft_fonts::document_hebrew_font(false).is_none() { assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("U+05E9")); assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0); @@ -6371,6 +8866,7 @@ fn architectural_and_iso_papers_through_tools() { ("Letter", 612.0, 792.0), ("US Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), + ("US #10 Envelope", 4.125 * 72.0, 9.5 * 72.0), ] { for orientation in ["portrait", "landscape"] { let out = ok( @@ -6447,7 +8943,8 @@ fn reading_colors_workdir() -> PathBuf { dir } -/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. +/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. Opaque pages +/// come as RGB; their alpha is 255. fn png_pixels(content: &Content) -> Vec { let Content::Png { data, width, height } = content else { panic!("expected a PNG") }; assert_eq!((*width, *height), (200, 300)); @@ -6455,7 +8952,11 @@ fn png_pixels(content: &Content) -> Vec { let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; let info = reader.next_frame(&mut pixels).unwrap(); assert_eq!((info.width, info.height), (200, 300)); - pixels + match info.color_type { + png::ColorType::Rgba => pixels, + png::ColorType::Rgb => pixels.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(), + other => panic!("unexpected colour type {other:?}"), + } } /// The RGBA pixel at (`x`, `y`), top-left origin, of a 200-wide image. @@ -6464,6 +8965,91 @@ fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] { pixels[at..at + 4].try_into().unwrap() } +/// A 300 × 100 page: red in layer "Walls" (left), green in "Doors" (middle), blue in a second +/// group also named "Walls" (right), and a black Square comment along the bottom. +fn layered_pdf() -> Vec { + let content = b"/OC /W1 BDC 1 0 0 rg 10 30 80 60 re f EMC /OC /D BDC 0 1 0 rg 110 30 80 60 re f EMC /OC /W2 BDC 0 0 1 rg 210 30 80 60 re f EMC"; + let ap = b"0 g 0 0 280 10 re f"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /Order [5 0 R 6 0 R 7 0 R] >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 0 R /Annots [8 0 R] /Resources << /Properties << /W1 5 0 R /D 6 0 R /W2 7 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + b"<< /Type /OCG /Name (Walls) >>".to_vec(), + b"<< /Type /OCG /Name (Doors) >>".to_vec(), + b"<< /Type /OCG /Name (Walls) >>".to_vec(), + b"<< /Type /Annot /Subtype /Square /Rect [10 2 290 12] /C [0 0 0] /AP << /N 9 0 R >> >>".to_vec(), + [format!("<< /Type /XObject /Subtype /Form /BBox [0 0 280 10] /Length {} >>\nstream\n", ap.len()).as_bytes(), ap, b"\nendstream"].concat(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #2986: `layer_set` hides and shows layers in what `page_render` draws, by name (every group +/// with that name) or by position; `comments_hide` now changes `page_render` too. Neither +/// changes the file. +#[test] +fn layers_and_hidden_comments_change_what_page_render_draws() { + let dir = workdir("layer-set"); + std::fs::write(dir.join("layers.pdf"), layered_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap(); + let at = |a: &mut Automation, x: u32, y: u32| { + let (w, _, px) = a.render_pixels(doc, 1, 1.0).unwrap(); + let i = ((y * w + x) * 4) as usize; + [px.as_ref()[i], px.as_ref()[i + 1], px.as_ref()[i + 2]] + }; + // Points (50, 40) red, (150, 40) green, (250, 40) blue, (150, 93) the comment; y down. + assert_eq!( + [at(&mut a, 50, 40), at(&mut a, 150, 40), at(&mut a, 250, 40), at(&mut a, 150, 93)], + [[255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 0, 0]] + ); + + let r = ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Walls", "visible": false })); + assert_eq!( + r["layers"], + json!([{ "name": "Walls", "visible": false }, { "name": "Doors", "visible": true }, { "name": "Walls", "visible": false }]) + ); + assert_eq!( + [at(&mut a, 50, 40), at(&mut a, 150, 40), at(&mut a, 250, 40)], + [[255, 255, 255], [0, 255, 0], [255, 255, 255]], + "both groups named Walls hide" + ); + + ok(&mut a, "layer_set", json!({ "doc": doc, "layer": 3, "visible": true })); + assert_eq!([at(&mut a, 50, 40), at(&mut a, 250, 40)], [[255, 255, 255], [0, 0, 255]], "position 3 is the second Walls only"); + + ok(&mut a, "comments_hide", json!({ "doc": doc })); + assert_eq!(at(&mut a, 150, 93), [255, 255, 255], "hidden comments are not drawn"); + ok(&mut a, "comments_hide", json!({ "doc": doc, "hidden": false })); + assert_eq!(at(&mut a, 150, 93), [0, 0, 0]); + + for (args, what) in [ + (json!({ "doc": doc, "layer": "Roof", "visible": false }), "an unknown name"), + (json!({ "doc": doc, "layer": 0, "visible": false }), "position 0"), + (json!({ "doc": doc, "layer": 4, "visible": false }), "a position past the end"), + (json!({ "doc": doc, "layer": true, "visible": false }), "a layer that is neither"), + (json!({ "doc": doc, "layer": "Doors" }), "no visible"), + ] { + assert!(matches!(a.call("layer_set", &args), Err(ToolError::InvalidArgs(_))), "{what} must be refused"); + } + let list = ok(&mut a, "doc_list", json!({})); + assert_eq!(list["documents"][0]["dirty"], json!(false), "a view setting leaves the document clean: {list}"); +} + /// The `view_reading_colors` tool changes what `page_render` draws — text and background remap, /// images never do — without touching the file, and its errors name what's wrong (#897). #[test] @@ -6697,3 +9283,910 @@ fn a_root_less_write_output_is_atomic_too() { assert!(matches!(&e, ToolError::Failed(m) if m.contains("is a folder")), "{e:?}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn attaching_and_deleting_files_through_the_tools() { + let dir = workdir("attach"); + std::fs::write(dir.join("notes.txt"), b"keep this with the PDF").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].as_array().unwrap().is_empty()); + // A text file and a PDF, the PDF under a name of its own; the second notes.txt is kept apart. + let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "notes.txt", "description": "Meeting notes" })); + assert_eq!(r["name"], "notes.txt"); + let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "b.pdf", "name": "annex.pdf" })); + assert_eq!(r["name"], "annex.pdf"); + let r = ok(&mut a, "attachment_add", json!({ "doc": doc, "path": "notes.txt" })); + assert_eq!(r["name"], "notes.txt (2)"); + let listed = ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].clone(); + let names: Vec<&str> = listed.as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["annex.pdf", "notes.txt", "notes.txt (2)"]); + assert_eq!(listed[1]["description"], "Meeting notes"); + assert_eq!(listed[1]["size"], 22); + // Saved and reopened, the attachment is there with its bytes. + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "with-notes.pdf" })); + let saved = std::fs::read(dir.join("with-notes.pdf")).unwrap(); + let info = pdfcraft_render::inspect(std::sync::Arc::new(saved.clone()), None).unwrap(); + let att = info.attachments.iter().find(|x| x.name == "notes.txt").expect("notes.txt is in the file"); + assert_eq!(pdfcraft_render::attachment_data(&saved, None, att).unwrap(), b"keep this with the PDF"); + // Deleting one leaves the others; a name that is not there is refused; undo brings it back. + ok(&mut a, "attachment_delete", json!({ "doc": doc, "name": "notes.txt" })); + let names: Vec = ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"] + .as_array() + .unwrap() + .iter() + .map(|x| x["name"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(names, ["annex.pdf", "notes.txt (2)"]); + assert!(a.call("attachment_delete", &json!({ "doc": doc, "name": "notes.txt" })).is_err()); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["attachments"].as_array().unwrap().len(), 3); + assert!(a.call("attachment_add", &json!({ "doc": doc, "path": "missing.txt" })).is_err(), "a file that is not there is refused"); +} + +#[test] +fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full}; + use std::sync::Arc; + let dir = workdir("fdf-nested-values"); + let mut source = Document::open(Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&source)[0].obj; + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Square")); + note.set(b"NM".to_vec(), PdfString::text("nested-values")); + note.set(b"Rect".to_vec(), Object::Array([10, 20, 30, 40].map(|value| Object::Ref(source.add(Object::Int(value)))).into())); + note.set(b"C".to_vec(), Object::Array([0.25, 0.5, 0.75].map(|value| Object::Ref(source.add(Object::Real(value)))).into())); + let note = source.add(note); + source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap(); + std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let source = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap(); + + ok(&mut a, "doc_export_data", json!({ "doc": source, "path": "notes.fdf", "what": "comments" })); + let target = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_import_data", json!({ "doc": target, "path": "notes.fdf" })); + ok(&mut a, "doc_save", json!({ "doc": target, "path": "restored.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "restored.pdf" }))["doc"].as_u64().unwrap(); + let after = ok(&mut a, "comment_list", json!({ "doc": reopened })); + // The saved input has legal indirect scalar numbers. The engine's source inspection + // currently reads those as defaults, so compare the restored data to the fixture's + // known values (converted from PDF bottom-left to the tool's top-left coordinates). + assert_eq!(after["comments"][0]["rect"], json!([10.0, 260.0, 30.0, 280.0])); + assert_eq!(after["comments"][0]["color"], "#4080BF"); +} + +#[test] +fn refused_fdf_export_keeps_the_destination_and_source_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_full}; + use std::sync::Arc; + let dir = workdir("fdf-export-limit"); + let mut source = Document::open(Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&source)[0].obj; + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Square")); + note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()])); + note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 100_001])); + let note = source.add(note); + source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap(); + let bytes = write_full(&source, &SaveOptions::default()).unwrap(); + std::fs::write(dir.join("source.pdf"), &bytes).unwrap(); + std::fs::write(dir.join("notes.fdf"), b"existing destination").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "comment_list", json!({ "doc": doc })); + let error = a.call("doc_export_data", &json!({ "doc": doc, "path": "notes.fdf", "what": "comments" })).unwrap_err(); + assert!(error.to_string().contains("100,000 values"), "{error}"); + assert_eq!(std::fs::read(dir.join("notes.fdf")).unwrap(), b"existing destination"); + assert_eq!(std::fs::read(dir.join("source.pdf")).unwrap(), bytes); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc })), before); +} + +#[test] +fn text_box_and_callout_fill_and_border_through_tools() { + let dir = workdir("textbox-fill"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for kind in ["textbox", "callout"] { + let mut args = json!({ "doc": doc, "page": 1, "type": kind, "rect": [80, 40, 180, 100], "contents": "Example" }); + if kind == "callout" { + args["to"] = json!([20, 120]); + } + let added = ok(&mut a, "comment_add", args); + let comment = &added["comment"]; + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": comment["index"], "fill": "white", "width": 3 })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" })); + let mut session = pdfcraft_engine::Session::new(); + let bytes = std::sync::Arc::new(std::fs::read(dir.join("filled.pdf")).unwrap()); + let saved = session.open("filled.pdf", None, bytes, None).unwrap(); + for index in 0..2 { + let props = session.get(saved).unwrap().comment_props(0, index).unwrap(); + assert_eq!((props.fill, props.width), (Some([1.0; 3]), Some(3.0))); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": index + 1, "fill": "none" })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "transparent.pdf" })); + let bytes = std::sync::Arc::new(std::fs::read(dir.join("transparent.pdf")).unwrap()); + let saved = session.open("transparent.pdf", None, bytes, None).unwrap(); + for index in 0..2 { + assert_eq!(session.get(saved).unwrap().comment_props(0, index).unwrap().fill, None); + } +} + +/// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading +/// of #2516. Alpha's last baseline is 35.6 pt above Bravo's; moving Alpha down 21.2 pt leaves +/// 14.4 pt, Alpha's own leading, so the next read groups the two as one paragraph. +fn three_paragraph_pdf() -> Vec { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + use std::sync::Arc; + + let content = "BT /F1 12 Tf 14.4 TL 1 0 0 1 10 250 Tm (Alpha paragraph, first row.) Tj T* (Alpha paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 10 200 Tm (Bravo paragraph, a single row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 10 150 Tm (Charlie paragraph, first row.) Tj T* (Charlie paragraph, second row.) Tj ET"; + let mut doc = Document::open(Arc::new(fixture(1))).unwrap(); + doc.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// A one-page PDF whose second paragraph is drawn over the first one's box, as an OCR text layer +/// or a stamp would be. +fn overlaid_paragraph_pdf() -> Vec { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + use std::sync::Arc; + + let content = "BT /F1 12 Tf 14.4 TL 1 0 0 1 10 250 Tm (Alpha paragraph, first row.) Tj T* (Alpha paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 10 243 Tm (Zulu overlay.) Tj ET"; + let mut doc = Document::open(Arc::new(fixture(1))).unwrap(); + doc.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// Emptying a paragraph that another paragraph overlaps must not hand the emptied paragraph's +/// number to the neighbour that took over its box: the next edit by that number would rewrite it. +#[test] +fn an_emptied_paragraph_does_not_pass_its_number_to_an_overlapping_neighbour() { + let dir = workdir("text-edit-overlap-number"); + std::fs::write(dir.join("p.pdf"), overlaid_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + let listed = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + assert_eq!(listed["count"], 2, "{listed}"); + let k = listed["paragraphs"].as_array().unwrap().iter().position(|p| p["text"].as_str().unwrap().starts_with("Alpha")).unwrap() + 1; + + let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":k,"text":""})); + assert_eq!(first["stale_paragraphs"], json!([k]), "the emptied paragraph's number is gone: {first}"); + let again = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":k,"text":"Rewritten."})); + assert!(matches!(&again, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{again:?}"); + assert!(page_text(&mut a, doc).join(" ").contains("Zulu overlay."), "the neighbour is untouched"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// #2516: a paragraph number names the paragraph `text_paragraphs` listed. When an edit regroups +/// the page so that paragraph no longer exists on its own, the next edit by that number is +/// refused. It used to rewrite whichever paragraph had taken the number, and that text was gone. +/// +/// Moving Alpha down (no text change) is the regrouping edit: #2517 wraps a rewrite that adds or +/// keeps rows in a marked region precisely so it can't be mistaken for a neighbour by geometry +/// alone, so a text rewrite can no longer manufacture this scenario; a pure move still can. +#[test] +fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() { + let dir = workdir("text-edit-stale-number"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + let listed = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + assert_eq!(listed["count"], 3); + assert!(listed["paragraphs"][1]["text"].as_str().unwrap().starts_with("Bravo")); + + // Listing again here would renumber the page, so the first edit's result says what moved. + // Moving Alpha down by 21.2 pt brings its last row within 14.4 pt of Bravo, its own leading. + let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2})); + assert_eq!(first["stale_paragraphs"], json!([1, 2]), "moving Alpha down regroups it with Bravo: {first}"); + + // Paragraph 2 of the earlier listing was Bravo, which is now part of Alpha's paragraph. + let stale = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); + assert!(matches!(&stale, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{stale:?}"); + let text = page_text(&mut a, doc).join(" "); + assert!(text.contains("Charlie paragraph, first row."), "Charlie is untouched: {text}"); + assert!(text.contains("Charlie paragraph, second row."), "Charlie is untouched: {text}"); + assert!(!text.contains("Bravo paragraph, edited."), "nothing was rewritten: {text}"); + + // After listing again, the number names what the listing says. + let relisted = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + assert!(relisted["paragraphs"][1]["text"].as_str().unwrap().starts_with("Charlie")); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Charlie paragraph, edited."})); + let text = page_text(&mut a, doc).join(" "); + assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, second row."), "{text}"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// An edit that merges its paragraph into a neighbour leaves no paragraph of its own to follow: +/// the merged one holds the edited text too, but rewriting it by the edited paragraph's number +/// would drop the neighbour's text (#2516). Moving Alpha down (see the previous test) is the +/// regrouping edit, since a text rewrite is marked precisely so it cannot merge this way (#2517). +#[test] +fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() { + let dir = workdir("text-edit-merged-number"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2})); + assert!(first.get("paragraph").is_none(), "the result must not present the merged paragraph as the edited one: {first}"); + + // Paragraph 1 was Alpha. Alpha now shares a paragraph with Bravo, so the number is refused. + let again = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha only."})); + assert!(matches!(&again, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{again:?}"); + let text = page_text(&mut a, doc).join(" "); + assert!(text.contains("Bravo paragraph, a single row."), "Bravo is untouched: {text}"); + assert!(!text.contains("Alpha only."), "nothing was rewritten: {text}"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The editor background preview takes the same paragraph number as `text_edit`, so it hides the +/// paragraph the number named in the listing and refuses a number an edit made stale (#2516). +/// Moving Alpha down (see the two tests above) is the regrouping edit; a text rewrite is marked +/// precisely so it cannot merge this way (#2517). +#[test] +fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrouped() { + let dir = workdir("hide-text-block-stale-number"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2})); + + // Paragraph 2 of the listing was Bravo, now inside Alpha's paragraph; position 2 is Charlie. + let stale = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":2})); + assert!(matches!(&stale, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{:?}", stale.map(|_| ())); + // Paragraph 3 of the listing is Charlie, wherever it is now. + assert!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":3})).is_ok()); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The listings kept for paragraph numbers are capped. A page whose listing was dropped must not +/// fall back to reading its numbers as positions, which an edit may have regrouped: it is refused +/// until the page is listed again (#2516). +#[test] +fn text_edit_refuses_a_paragraph_number_whose_listing_was_dropped_at_the_cap() { + let dir = workdir("text-edit-evicted-listing"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let open = |a: &mut Automation| ok(a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + let doc = open(&mut a); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + // 256 more pages fill the cap, so the next new page drops every earlier listing. + let others: Vec = (0..256).map(|_| open(&mut a)).collect(); + for &other in &others { + ok(&mut a, "text_paragraphs", json!({"doc":other,"page":1})); + } + + let dropped = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); + assert!(matches!(&dropped, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{dropped:?}"); + assert!(!page_text(&mut a, doc).join(" ").contains("Bravo paragraph, edited."), "nothing was rewritten"); + let hidden = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":2})); + assert!(matches!(&hidden, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{:?}", hidden.map(|_| ())); + + // A page listed after the drop keeps its numbers, and so does the page once it is listed again. + ok(&mut a, "text_edit", json!({"doc":others[255],"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); + assert!(page_text(&mut a, doc).join(" ").contains("Bravo paragraph, edited.")); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A number from the listing keeps naming its paragraph while other paragraphs are edited, and +/// a paragraph can be edited twice in a row by the same number. +#[test] +fn text_edit_numbers_survive_edits_that_do_not_regroup() { + let dir = workdir("text-edit-stable-number"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":3,"text":"Charlie one.\nCharlie two."})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha one.\nAlpha two."})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha again.\nAlpha two."})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":3,"text":"Charlie once more.\nCharlie two."})); + // A rewrite rewraps at the width of the widest row, so compare without line breaks. + let text = page_text(&mut a, doc).join(" ").split_whitespace().collect::>().join(" "); + for want in ["Alpha again.", "Bravo paragraph, a single row.", "Charlie once more."] { + assert!(text.contains(want), "{want}: {text}"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Where the first bookmark on `path` goes, as `bookmark_list` reports it: points from the +/// top-left of the displayed page. +fn bookmark_destination(a: &mut Automation, doc: u64, title: &str) -> Value { + let list = ok(a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + let row = list.as_array().unwrap().iter().find(|r| r["title"] == title).unwrap_or_else(|| panic!("{title} in {list}")); + row["destination"].clone() +} + +#[test] +fn a_bookmark_added_at_a_position_reports_the_same_position_back_on_any_page_rotation() { + let dir = workdir("bookmark-position"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [3], "degrees": 90 })); + let near = |v: &Value, want: Option, what: &str| match want { + Some(w) => assert!((v.as_f64().unwrap_or_else(|| panic!("{what}: {v}")) - w).abs() < 0.05, "{what}: {v} vs {w}"), + None => assert!(v.is_null(), "{what}: {v}"), + }; + // Page 1 upright, page 3 turned a quarter: a displayed top and left come back as given. + for (page, title) in [(1, "Upright"), (3, "Turned")] { + for (suffix, left, top) in [(" both", Some(72.5), Some(120.25)), (" top", None, Some(200.0)), (" left", Some(50.0), None)] { + let name = format!("{title}{suffix}"); + let mut args = json!({ "doc": doc, "title": name, "page": page }); + if let Some(l) = left { + args["left"] = json!(l); + } + if let Some(t) = top { + args["top"] = json!(t); + } + ok(&mut a, "bookmark_add", args); + let d = bookmark_destination(&mut a, doc, &name); + assert_eq!(d["fit"], "XYZ", "{name}"); + near(&d["left"], left, &format!("{name} left")); + near(&d["top"], top, &format!("{name} top")); + assert!(d["zoom"].is_null(), "the zoom is kept: {name}"); + } + } + // No position is the page top at the reader's zoom, as before. + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Plain", "page": 2 })); + let plain = bookmark_destination(&mut a, doc, "Plain"); + assert!(plain["left"].is_null() && plain["top"].is_null() && plain["zoom"].is_null(), "{plain}"); + + // A position that is not a usable number is refused and adds nothing. + let before = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + for bad in [json!("high"), json!(2_000_000), json!(-2_000_000.5), json!(true)] { + for key in ["top", "left"] { + let args = json!({ "doc": doc, "title": "Bad", "page": 1, key: bad }); + assert!(matches!(a.call("bookmark_add", &args), Err(ToolError::InvalidArgs(_))), "{key} {bad}"); + } + } + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], before, "nothing was added"); + + // It survives a save and reopen. + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap(); + let d = bookmark_destination(&mut b, re, "Turned both"); + near(&d["left"], Some(72.5), "reopened left"); + near(&d["top"], Some(120.25), "reopened top"); +} + +/// `comment_edit points` reshapes a line, connected lines or a polygon, in one undo step, and +/// `comment_list` reports the points it takes. +#[test] +fn comments_are_reshaped_through_the_tool() { + let dir = workdir("reshape"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 40] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [120.0, 40.0]])); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "points": [[20, 40], [20, 140]] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [20.0, 140.0]])); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Reshape comment"); + assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": [[1, 2]] })).is_err(), "a line has two ends"); + assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": "no" })), Err(ToolError::InvalidArgs(_)))); +} + +/// #3193: bookmark_list reports a bookmark's colour and style (ISO 32000-2 §12.3.3, Tables 151 +/// and 152), only where they differ from black and regular. The list is read from the opened file +/// and, once the document has been edited, from the edited one; both read them alike. +/// bookmark_set_style changes them, and they survive a save. +#[test] +fn bookmark_list_reports_colour_and_style() { + let dir = workdir("bookmark-style"); + let objects: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + b"<< /Type /Page /Parent 2 0 R >>", + b"<< /Type /Outlines /First 5 0 R /Last 7 0 R /Count 3 >>", + b"<< /Title (Plain) /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>", + b"<< /Title (Red bold) /Parent 4 0 R /Prev 5 0 R /Next 7 0 R /Dest [3 0 R /Fit] /C [1 0 0] /F 2 >>", + b"<< /Title (Blue italic) /Parent 4 0 R /Prev 6 0 R /Dest [3 0 R /Fit] /C [0 0 1] /F 1 >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + std::fs::write(dir.join("styled.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap(); + let style = |list: &Value| -> Vec<(Value, Value, Value)> { + list.as_array().unwrap().iter().map(|b| (b["color"].clone(), b["bold"].clone(), b["italic"].clone())).collect() + }; + let expected = vec![ + (Value::Null, Value::Null, Value::Null), + (json!([1.0, 0.0, 0.0]), json!(true), Value::Null), + (json!([0.0, 0.0, 1.0]), Value::Null, json!(true)), + ]; + let opened = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!(style(&opened), expected, "as opened: {opened}"); + ok(&mut a, "bookmark_rename", json!({ "doc": doc, "path": [1], "title": "Still plain" })); + let edited = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!(style(&edited), expected, "after an edit: {edited}"); + + // bookmark_set_style changes only what it is given. + ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [1], "color": [0, 0.5, 0], "italic": true })); + ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [2], "bold": false })); + ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [3], "color": [0, 0, 0] })); + let set = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!( + style(&set), + vec![ + (json!([0.0, 0.5, 0.0]), Value::Null, json!(true)), + (json!([1.0, 0.0, 0.0]), Value::Null, Value::Null), + (Value::Null, Value::Null, json!(true)) + ], + "{set}" + ); + // A colour and a style in one call are one undo step. + ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [2], "color": [0, 0, 0], "bold": true, "italic": true })); + let both = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!(style(&both)[1], (Value::Null, json!(true), json!(true)), "{both}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + let undone = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!(style(&undone), style(&set), "one undo takes back both: {undone}"); + for bad in [ + json!({ "doc": doc, "path": [1] }), + json!({ "doc": doc, "path": [1], "color": [2, 0, 0] }), + json!({ "doc": doc, "path": [1], "color": [1, 0] }), + ] { + assert!(matches!(a.call("bookmark_set_style", &bad), Err(ToolError::InvalidArgs(_))), "{bad}"); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "restyled.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "restyled.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved"); +} + +/// `aligns` gives each line of added text an alignment of its own; `align` alone is the whole box +/// again. Text edits take `aligns` for a paragraph's rows the same way. +#[test] +fn added_text_lines_take_their_own_alignments() { + let dir = workdir("paragraph-aligns"); + let mut a = auto(&dir); + let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]})); + let rect = |a: &mut Automation, text: &str| -> Vec { + let lines = ok(a, "text_lines", json!({"doc":id,"page":1})); + let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap(); + line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect() + }; + assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0); + assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line")); + // The whole box centred: the right-aligned line goes with it. + ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"})); + let right = rect(&mut a, "Right line"); + assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}"); + assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err()); + assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err()); +} + +/// Existing-row alignment is withheld until its intent survives later editing sessions. +#[test] +fn text_edit_row_alignment_is_refused_without_changing_existing_text() { + let dir = workdir("paragraph-row-aligns"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running.) Tj ET"; + std::fs::write(dir.join("rows.pdf"), win_ansi_paragraph_pdf(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "rows.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 })); + let refused = err(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "aligns": [null, "right"] })); + assert!(matches!(&refused, ToolError::Failed(m) | ToolError::InvalidArgs(m) if m.contains("selected-row alignment")), "{refused:?}"); + assert_eq!(ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 })), before); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "unchanged.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "unchanged.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_lines", json!({ "doc": reopened, "page": 1 })), before); +} + +/// An editable combo box (ISO 32000-2 Table 233, Edit) takes a value that isn't one of its +/// options, and its page appearance shows it, before and after a save; one without Edit takes +/// only its options. +#[test] +fn form_fill_takes_free_text_only_in_an_editable_combo_box() { + let dir = workdir("editable-combo"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let combo = |name: &str, top: f64, editable: bool| json!({ "doc": doc, "page": 1, "type": "combo", "rect": [20, top, 200, top + 22.0], "name": name, "options": ["Apple", "Banana"], "editable": editable }); + ok(&mut a, "form_add_field", combo("fruit", 20.0, true)); + ok(&mut a, "form_add_field", combo("veg", 60.0, false)); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "fruit": "Cherry" } })); + assert!(page_text(&mut a, doc)[0].contains("Cherry"), "the typed value is drawn"); + let refused = err(&mut a, "form_fill", json!({ "doc": doc, "values": { "veg": "Cherry" } })); + assert!(matches!(&refused, ToolError::Failed(m) | ToolError::InvalidArgs(m) if m.contains("no option")), "{refused:?}"); + // A choice from the list still works in both. + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "veg": "Banana" } })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "filled.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": saved })); + let value = |name: &str| fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).map(|f| f["value"].clone()); + assert_eq!(value("fruit"), Some(json!("Cherry")), "{fields}"); + assert_eq!(value("veg"), Some(json!("Banana")), "{fields}"); + let text = page_text(&mut a, saved); + assert!(text[0].contains("Cherry") && text[0].contains("Banana"), "{text:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `page_render`'s pixels show a rubber stamp written without an appearance as the stamp its +/// `/Name` names (ISO 32000-2 Table 184), and one with an unknown name as a grey placeholder. +#[test] +fn page_render_draws_stamps_without_an_appearance() { + let dir = workdir("stamps-without-appearance"); + let objects: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 100] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [10 20 130 80] /Name /Approved >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [170 20 290 80] /Name /MyCustomStamp >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + std::fs::write(dir.join("stamps.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "stamps.pdf" }))["doc"].as_u64().unwrap(); + let (width, _, pixels) = a.render_pixels(doc, 1, 1.0).unwrap(); + let pixels = pixels.as_ref(); + // Ink in columns `x0..x1` of rows 20..80, which are the stamps' rows whether counted from the top or the bottom. + let ink = |x0: u32, x1: u32| -> Vec<[u8; 3]> { + (20..80u32) + .flat_map(|y| (x0..x1).map(move |x| (y, x))) + .map(|(y, x)| { + let i = ((y * width + x) * 4) as usize; + [pixels[i], pixels[i + 1], pixels[i + 2]] + }) + .filter(|p| p.iter().any(|v| *v < 200)) + .collect() + }; + let approved = ink(10, 130); + let green = approved.iter().filter(|p| p[1] > p[0] && p[1] > p[2]).count(); + assert!(green > 300 && green * 10 > approved.len() * 9, "Approved is drawn green: {green} of {}", approved.len()); + let custom = ink(170, 290); + let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count(); + assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len()); +} + +#[test] +fn deleting_one_radio_button_through_tools_keeps_the_group() { + let dir = workdir("delete-radio"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (x, export) in [(20, "S"), (40, "M"), (60, "L")] { + let args = json!({ "doc": doc, "page": 1, "type": "radio", "rect": [x, 100, x + 14, 114], "group": "size", "export": export }); + ok(&mut a, "form_add_field", args); + } + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "L" } })); + ok(&mut a, "form_delete_field", json!({ "doc": doc, "field": "size", "widget": 2 })); + let size = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"][0].clone(); + assert_eq!((size(&mut a)["options"].clone(), size(&mut a)["value"].clone()), (json!(["S", "L"]), json!("L")), "only M is gone"); + for widget in [0, 3, -1] { + let r = a.call("form_delete_field", &json!({ "doc": doc, "field": "size", "widget": widget })); + assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "widget {widget}: {r:?}"); + } + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(size(&mut a)["options"], json!(["S", "M", "L"]), "one undo step brings it back"); +} +/// `/I` of the top-level field `name` in the saved file `path`. +fn saved_selection_indices(path: &Path, name: &str) -> Option> { + let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + let root = d.get(d.root().unwrap()).as_dict().cloned().unwrap(); + let form = d.resolve(root.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + let all = d.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap(); + let f = all + .iter() + .filter_map(|o| d.resolve(o).as_dict().cloned()) + .find(|f| f.get(b"T").and_then(|t| d.resolve(t).as_string().map(|s| s.to_text())).as_deref() == Some(name)) + .unwrap(); + f.get(b"I").map(|i| d.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect()) +} + +#[test] +fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items() { + let dir = workdir("choice-index"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let args = json!({ "doc": doc, "page": 1, "type": "list", "rect": [20, 60, 180, 140], "options": ["A", "B", "C"], "multi_select": true, "name": "letters" }); + ok(&mut a, "form_add_field", args); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "letters": ["B", "C"] } })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "letters", "options": ["B", "C", "D"] })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "letters.pdf" })); + assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1"); +} + +/// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none, +/// even when its parent field has one it would otherwise inherit. +#[test] +fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /T (address) /FT /Tx /DV (Main St) /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /DV (Elm) /V (Elm) /Rect [10 250 150 268] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("kid-default"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "default": "" })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Oak" } })); + ok(&mut a, "form_reset", json!({ "doc": doc })); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone(); + assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]); +} + +/// #2517, through the same tools as the report. One blank line is two paragraphs, and editing +/// the first leaves the second. The neighbour at y=656.8 is one blank line below (two 14.4 pt +/// leadings under the second row). Growing the first paragraph, saving, reopening and editing +/// again leaves that neighbour in place. An explicit TL of 27.6 pt stays one paragraph. +#[test] +fn a_blank_line_stays_a_separate_paragraph_through_tools() { + let dir = workdir("blank-line"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET"; + std::fs::write(dir.join("a.pdf"), helvetica_page(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 })); + assert_eq!(before["count"], 2, "{before}"); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "First paragraph, edited row.\nFirst paragraph, second row." })); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 })); + assert_eq!(after["count"], 2, "{after}"); + let second = after["paragraphs"][1]["text"].as_str().unwrap(); + assert!(second.contains("Second paragraph"), "{second}"); + + let grown = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbour) Tj ET"; + std::fs::write(dir.join("grown.pdf"), helvetica_page(grown)).unwrap(); + let grown_doc = ok(&mut a, "doc_open", json!({ "path": "grown.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 }))["count"], 2); + ok(&mut a, "text_edit", json!({ "doc": grown_doc, "page": 1, "paragraph": 1, "text": "One\nTwo\nThree" })); + ok(&mut a, "doc_save", json!({ "doc": grown_doc, "path": "grown-out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "grown-out.pdf" }))["doc"].as_u64().unwrap(); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 })); + assert_eq!(after["count"], 2, "{after}"); + assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}"); + ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "text": "One\nTwo\nFour" })); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 })); + assert_eq!(after["count"], 2, "{after}"); + assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}"); + + let spaced = "BT /F1 12 Tf 27.6 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET"; + std::fs::write(dir.join("spaced.pdf"), helvetica_page(spaced)).unwrap(); + let spaced_doc = ok(&mut a, "doc_open", json!({ "path": "spaced.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1); +} + +/// #2529, through the same tools as a width drag. The typed double space falls on the wrap. +/// Omitting `text` reflows with the saved gap count. An incremental save and a full rewrite +/// both still have both spaces after the box is widened again. +#[test] +fn narrowing_then_widening_keeps_a_typed_double_space_through_tools() { + let dir = workdir("double-space-width"); + std::fs::write(dir.join("a.pdf"), helvetica_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]["editor_text"], "One Two"); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "width": 45.0 })); + let narrow = &ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]; + assert_eq!(narrow["editor_text"], "One\nTwo", "{narrow}"); + for (name, full) in [("narrow-inc.pdf", false), ("narrow-full.pdf", true)] { + ok(&mut a, "doc_save", json!({ "doc": doc, "path": name, "full": full })); + let saved = ok(&mut a, "doc_open", json!({ "path": name }))["doc"].as_u64().unwrap(); + let block = &ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }))["paragraphs"][0]; + assert_eq!(block["editor_text"], "One\nTwo", "{name}: {block}"); + ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "width": 200.0 })); + let wide_name = format!("wide-{name}"); + ok(&mut a, "doc_save", json!({ "doc": saved, "path": wide_name, "full": full })); + let wide = ok(&mut a, "doc_open", json!({ "path": wide_name }))["doc"].as_u64().unwrap(); + let block = &ok(&mut a, "text_paragraphs", json!({ "doc": wide, "page": 1 }))["paragraphs"][0]; + assert_eq!(block["editor_text"], "One Two", "{name}: the second typed space was lost: {block}"); + } +} + +fn paragraph_texts(a: &mut Automation, doc: u64) -> Vec { + ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"] + .as_array() + .unwrap() + .iter() + .map(|p| p["text"].as_str().unwrap().to_string()) + .collect() +} + +/// The reported edits, through open / text_paragraphs / text_edit / save / reopen / text_extract. +/// Covers an explicit TL, the three ways a file moves rows without TL, a blank line written with +/// Td, a two-row file that must stay split, and a partial rewrite that adds a row. +#[test] +fn paragraph_grouping_survives_save_and_reopen_through_tools() { + let dir = workdir("paragraph-grouping"); + let mut a = auto(&dir); + for step in [27.6_f64, 28.8] { + let y2 = 700.0 - step; + let y3 = 700.0 - 2.0 * step; + let bodies = [ + format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"), + format!( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y2} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y3} Tm (Three) Tj ET" + ), + format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {y2} Td (Two) Tj ET BT /F1 12 Tf 72 {y3} Td (Three) Tj ET"), + ]; + for (i, body) in bodies.iter().enumerate() { + let path = format!("step-{step}-{i}.pdf"); + std::fs::write(dir.join(&path), helvetica_page(body)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let paras = paragraph_texts(&mut a, doc); + assert_eq!(paras.len(), 1, "{path}: {paras:?}"); + assert!(paras[0].contains("One") && paras[0].contains("Two") && paras[0].contains("Three"), "{path}: {paras:?}"); + let extracted = page_text(&mut a, doc); + assert!(extracted[0].contains("One") && extracted[0].contains("Three"), "{path}: {extracted:?}"); + } + } + + // One gap and no third row: still two paragraphs. + let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - 27.6); + std::fs::write(dir.join("pair.pdf"), helvetica_page(&pair)).unwrap(); + let pair_doc = ok(&mut a, "doc_open", json!({ "path": "pair.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, pair_doc).len(), 2); + + // Blank line written with Td, not TL. The inner step repeats; the break does not. + let inner = 14.4_f64; + let blank = format!( + "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \ + BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET", + 700.0 - inner, + 700.0 - 3.0 * inner, + 700.0 - 4.0 * inner + ); + std::fs::write(dir.join("blank.pdf"), helvetica_page(&blank)).unwrap(); + let blank_doc = ok(&mut a, "doc_open", json!({ "path": "blank.pdf" }))["doc"].as_u64().unwrap(); + let blank_paras = paragraph_texts(&mut a, blank_doc); + assert_eq!(blank_paras.len(), 2, "{blank_paras:?}"); + assert!(blank_paras[0].contains("One") && blank_paras[0].contains("Two"), "{blank_paras:?}"); + assert!(blank_paras[1].contains("Neighbour") && blank_paras[1].contains("Line"), "{blank_paras:?}"); + + // The first row is unchanged. Save incremental, reopen, edit again, save in full, reopen. + let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET"; + std::fs::write(dir.join("partial.pdf"), helvetica_page(partial)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc).len(), 2); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" })); + let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "partial.pdf" })); + assert_eq!(incremental["incremental"], true, "{incremental}"); + let reopened = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" })); + let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "partial-full.pdf", "full": true })); + assert_eq!(full["incremental"], false, "{full}"); + let again = ok(&mut a, "doc_open", json!({ "path": "partial-full.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays."]); + let extracted = page_text(&mut a, again); + assert!(extracted[0].contains("Neighbor stays.") && extracted[0].contains("Four"), "{extracted:?}"); +} + +/// Editing the only paragraph on the page, while leaving its second row in the source, stays +/// one paragraph through save and reopen. A neighbour in a different marked region stays in +/// its own paragraph. +#[test] +fn a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools() { + let dir = workdir("paragraph-tail"); + let mut a = auto(&dir); + let only = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET"; + std::fs::write(dir.join("only.pdf"), helvetica_page(only)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row."]); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." })); + assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row."]); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "only.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." })); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two."]); + + let marked = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \ + /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + std::fs::write(dir.join("marked.pdf"), helvetica_page(marked)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc).len(), 2); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." })); + assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row.", "Neighbor stays."]); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row.", "Neighbor stays."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." })); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two.", "Neighbor stays."]); +} + +/// Both ends of one text object, through open / text_edit / save / reopen / text_extract. +/// Editing the last paragraph must not take the one before it, and editing the first must +/// not take the one after it. Incremental and full saves. +#[test] +fn a_shared_text_object_keeps_both_paragraphs_through_tools() { + let dir = workdir("shared-text-object"); + let mut a = auto(&dir); + let body = + "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj T* (Neighbor next.) Tj ET"; + std::fs::write(dir.join("last.pdf"), helvetica_page(body)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Neighbor next."]); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nChanged." })); + assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]); + let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "last.pdf" })); + assert_eq!(incremental["incremental"], true, "{incremental}"); + let reopened = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nEdited." })); + let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "last-full.pdf", "full": true })); + assert_eq!(full["incremental"], false, "{full}"); + let again = ok(&mut a, "doc_open", json!({ "path": "last-full.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Alpha next row.", "Neighbor stays. Edited."]); + let extracted = page_text(&mut a, again); + assert!(extracted[0].contains("Alpha first row.") && extracted[0].contains("Edited."), "{extracted:?}"); + + std::fs::write(dir.join("first.pdf"), helvetica_page(body)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "first.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays. Neighbor next."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" })); + ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "first-full.pdf", "full": true })); + let again = ok(&mut a, "doc_open", json!({ "path": "first-full.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays. Neighbor next."]); + let extracted = page_text(&mut a, again); + assert!(extracted[0].contains("Four") && extracted[0].contains("Neighbor stays."), "{extracted:?}"); +} + +/// Unambiguous separated one-pixel rules test the requested scale independently of the +/// conservative local-tone guard. Ragged text can legitimately be retained by that guard. +fn simple_rule_scan_drawn(content: &[u8]) -> Vec { + use pdfcraft_cos::{Document, Object, SaveOptions, Stream, write_full}; + let mut doc = Document::open(std::sync::Arc::new(scan_drawn(content))).unwrap(); + for number in doc.object_numbers() { + let reference = pdfcraft_cos::ObjRef::new(number, doc.generation(number)); + let object = doc.get(reference); + let Object::Stream(image) = &*object else { continue }; + if image.dict.name(b"Subtype") != Some(b"Image") { + continue; + } + let mut data = vec![0xFF; 125 * 400]; + for y in 0..400 { + data[y * 125] &= 0x7F; + data[y * 125 + 124] &= 0xFE; + } + doc.set(reference, Object::Stream(Stream::flate(image.dict.clone(), &data))); + } + write_full(&doc, &SaveOptions::default()).unwrap() +} diff --git a/crates/automation/tests/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs similarity index 96% rename from crates/automation/tests/automation_roots.rs rename to crates/automation/tests/it/automation_roots.rs index 94958ea2fb..a96870ca02 100644 --- a/crates/automation/tests/automation_roots.rs +++ b/crates/automation/tests/it/automation_roots.rs @@ -152,6 +152,7 @@ fn tools_that_read_a_path_are_held_to_the_read_root() { ("doc_create_multiple", json!({"paths": [secret], "mode": "combine", "out": out})), ("doc_create", json!({"from": "images", "paths": [pic]})), ("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})), + ("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "rect": [10, 10, 493, 300]})), ("page_add_image", json!({"doc": 1, "page": 1, "path": pic})), ("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})), ("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})), @@ -351,6 +352,19 @@ fn links_out_of_a_root_are_refused() { assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]); } +/// #4003: without `--root`, another network share or a device is refused by name through the +/// tools too, with the same words as any other path outside a root. +#[cfg(windows)] +#[test] +fn other_shares_and_devices_are_refused_by_name() { + let (mut server, base) = confined("shares", true, true); + ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))})); + for p in [r"\\.\pipe\pdfcraft-4003.pdf", r"\\pdfcraft-no-such-host.invalid\share\a.pdf", "//pdfcraft-no-such-host.invalid/share/a.pdf"] { + refused(&mut server, "doc_open", json!({"path": p}), "outside the read root"); + refused(&mut server, "doc_save", json!({"doc": 1, "path": p}), "outside the write root"); + } +} + #[test] fn without_roots_nothing_is_confined() { let base = folders("none"); diff --git a/crates/automation/tests/it/backdrop.rs b/crates/automation/tests/it/backdrop.rs new file mode 100644 index 0000000000..00570b260c --- /dev/null +++ b/crates/automation/tests/it/backdrop.rs @@ -0,0 +1,269 @@ +//! Add Background on pages that paint their own backdrop, end to end through the tools: what +//! `page_render` draws, `doc_backdrops`, save and reopen, and Remove putting the page back. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// Two 200 × 100 pt pages saying "Hi": page 1 on a white fill of its own (as browsers write), +/// page 2 on bare paper; a third page is a "scan" (a page-sized grey picture). +fn fixture() -> Vec { + let text = "0 g BT /F1 24 Tf 20 40 Td (Hi) Tj ET"; + let contents = [format!("q 1 1 1 rg 0 0 200 100 re f Q {text}"), text.to_string(), format!("q 200 0 0 100 0 0 cm /Im0 Do Q {text}")]; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [4 0 R 6 0 R 8 0 R] /Count 3 /MediaBox [0 0 200 100] >>".into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{80}\nendstream" + .into(), + ]; + for (i, c) in contents.iter().enumerate() { + objs.push(format!( + "<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 3 0 R >> >> >>", + 5 + 2 * i + )); + objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn workdir() -> PathBuf { + named_workdir("main") +} + +/// A folder of its own per test: the tests of this binary run at the same time. +fn named_workdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-automation-backdrop-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.pdf"), fixture()).unwrap(); + dir +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +/// The RGB of a blank spot (top-right corner area) of a page rendered at 72 dpi. +fn blank_spot(a: &mut Automation, doc: u64, page: u64) -> [u8; 3] { + let rendered = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap(); + let Content::Png { data, width, .. } = &rendered[0] else { panic!("expected a PNG") }; + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.color_type.samples(); + let at = (10 * *width as usize + 180) * channels; + [pixels[at], pixels[at + 1], pixels[at + 2]] +} + +const RED: [u8; 3] = [255, 0, 0]; +const WHITE: [u8; 3] = [255, 255, 255]; + +#[test] +fn a_background_shows_on_a_page_that_paints_its_own_white_backdrop() { + let dir = workdir(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }, { "page": 3, "backdrop": "image" }]) + ); + + // Not replacing: the page's own white hides the background, as it always did. + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000", "replace_backdrop": false })); + assert_eq!(blank_spot(&mut a, doc, 1), WHITE); + assert_eq!(blank_spot(&mut a, doc, 2), RED); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + + // The default replaces it: red on both pages; the scan still hides it. + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" })); + assert_eq!(blank_spot(&mut a, doc, 1), RED); + assert_eq!(blank_spot(&mut a, doc, 2), RED); + assert_ne!(blank_spot(&mut a, doc, 3), RED); + assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([{ "page": 3, "backdrop": "image" }])); + + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(blank_spot(&mut a, saved, 1), RED, "after save and reopen"); + ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" })); + assert_eq!(blank_spot(&mut a, saved, 1), WHITE); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": saved, "pages": [1] }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }]), + "the backdrop is back" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// One 200 × 100 pt page on a white fill of its own: a line to redact, invisible (`3 Tr`) text +/// that Remove Hidden Information removes, and a line that stays (`0 Tr` again: the render mode +/// outlives `ET`). With `tag`, the content is a +/// backdrop copy carrying that tag, under a background. +fn secret_pdf(tag: Option<&str>) -> Vec { + let fill = if tag.is_some() { "n" } else { "f" }; + let content = format!( + "1 1 1 rg 0 0 200 100 re {fill} 0 g BT /F1 12 Tf 10 70 Td (SECRET-LINE) Tj ET BT 3 Tr /F1 12 Tf 10 45 Td (HIDDEN-LINE) Tj ET BT 0 Tr /F1 12 Tf 10 20 Td (KEEP) Tj ET" + ); + let bg = "q 1 0 0 rg 0 0 200 100 re f Q"; + let (contents, extra) = match tag { + Some(t) => ( + "[5 0 R 6 0 R]", + vec![ + format!("<< /PCMark /Background /Length {} >>\nstream\n{bg}\nendstream", bg.len()), + format!("<< /PCBackdrop {t} /Length {} >>\nstream\n{content}\nendstream", content.len()), + ], + ), + None => ("[5 0 R]", vec![format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())]), + }; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 100] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /Font << /F1 4 0 R >> >> >>"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + objs.extend(extra); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Every stream of a saved file, decoded, joined: what anyone reading the file can recover. +fn every_stream(path: &std::path::Path) -> String { + let doc = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + doc.scan_objects() + .filter_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(s) => Some(String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// Text a redaction or Remove Hidden Information took off a page whose backdrop a background +/// replaced is gone from the saved file. The backdrop copy's tag used to keep a reference to the +/// page's original content stream, and with it the removed text. Removing the background +/// afterwards still turns the page's white fill back on. +#[test] +fn removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop() { + for (remove, gone) in [ + (vec![("redact_mark", json!({ "find": "SECRET-LINE" })), ("redact_apply", json!({}))], "SECRET-LINE"), + (vec![("doc_remove_hidden", json!({ "categories": ["hidden-text"] }))], "HIDDEN-LINE"), + ] { + let dir = named_workdir(gone); + std::fs::write(dir.join("s.pdf"), secret_pdf(None)).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "s.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" })); + for (tool, args) in &remove { + let mut args = args.clone(); + args["doc"] = json!(doc); + ok(&mut a, tool, args); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); + let file = every_stream(&dir.join("out.pdf")); + assert!(file.contains("(KEEP)"), "{gone}: the page's other text is still there: {file}"); + assert!(!file.contains(gone), "{gone} is still in the file: {file}"); + + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap(); + let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" })); + assert!(removed.get("warnings").is_none(), "{gone}: {removed}"); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": saved }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }]), + "{gone}: the fill is back" + ); + assert_eq!(blank_spot(&mut a, saved, 1), WHITE); + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// When the backdrop can't be turned back on safely, Remove background says which pages keep it +/// off. +#[test] +fn remove_background_warns_about_pages_whose_own_backdrop_stays_off() { + let dir = named_workdir("left-off"); + // A tag whose hash doesn't match the bytes before the fill: they changed since. + let tag = format!("<< /Index 2 /Op (f) /Before <{}> >>", "00".repeat(32)); + std::fs::write(dir.join("t.pdf"), secret_pdf(Some(&tag))).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "t.pdf" }))["doc"].as_u64().unwrap(); + let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "background" })); + let warnings = removed["warnings"].as_array().unwrap_or_else(|| panic!("{removed}")); + assert_eq!(warnings.len(), 1, "{removed}"); + assert!(warnings[0].as_str().unwrap().starts_with("page 1 changed after the background was added"), "{removed}"); + assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "the fill stays off"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Two 200 × 100 pt pages whose full-page `1 g` fill is not a plain white backdrop (#2815 +/// review): page 1 paints it half transparent, through a `gs` that sets `ca` and a later one +/// that only sets the line width; page 2 sends it through a transfer function that maps 1 to 0. +fn graphics_state_pdf() -> Vec { + let invert = "<< /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>"; + let resources = format!("<< /ExtGState << /Transparent << /ca 0.5 >> /LineOnly << /LW 2 >> /Invert << /TR {invert} >> >> >>"); + let contents = ["/Transparent gs /LineOnly gs 1 g 0 0 200 100 re f", "/Invert gs 1 g 0 0 200 100 re f"]; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 200 100] >>".into()]; + for (i, c) in contents.iter().enumerate() { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 4 + 2 * i)); + objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #2815 review: a translucent fill (its `ca` left in force by a later `gs`) and a fill through +/// a transfer function are not backdrops, so a red background goes under them and the +/// translucent white still lightens it. +#[test] +fn fills_the_graphics_state_changes_are_not_replaced() { + let dir = named_workdir("graphics-state"); + std::fs::write(dir.join("gs.pdf"), graphics_state_pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "gs.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "neither fill is a backdrop"); + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" })); + let [r, g, b] = blank_spot(&mut a, doc, 1); + assert_eq!(r, 255, "{:?}", [r, g, b]); + assert!((100..=160).contains(&g) && (100..=160).contains(&b), "half-transparent white over red, not red: {:?}", [r, g, b]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/batch.rs b/crates/automation/tests/it/batch.rs similarity index 100% rename from crates/automation/tests/batch.rs rename to crates/automation/tests/it/batch.rs diff --git a/crates/automation/tests/button_caption.rs b/crates/automation/tests/it/button_caption.rs similarity index 100% rename from crates/automation/tests/button_caption.rs rename to crates/automation/tests/it/button_caption.rs diff --git a/crates/automation/tests/form_bulk.rs b/crates/automation/tests/it/form_bulk.rs similarity index 86% rename from crates/automation/tests/form_bulk.rs rename to crates/automation/tests/it/form_bulk.rs index 8b8904c974..4296cbc891 100644 --- a/crates/automation/tests/form_bulk.rs +++ b/crates/automation/tests/it/form_bulk.rs @@ -151,3 +151,26 @@ fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() { assert_eq!(bytes(&a, id), before); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn button_export_edit_is_one_undo_step_and_survives_save() { + let (mut a, id, dir) = setup("button-export"); + tool(&mut a, "form_add_field", json!({"doc": id, "page": 1, "type": "checkbox", "name": "agree", "rect": [20, 20, 40, 40]})); + tool(&mut a, "form_fill", json!({"doc": id, "values": {"agree": true}})); + let before = bytes(&a, id); + call(&mut a, json!({"doc": id, "field": "agree", "export_value": "Accepted", "widget": 1})); + let f = tool(&mut a, "form_fields", json!({"doc": id})); + let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap(); + assert_eq!(check["export_values"], json!(["Accepted"])); + assert_eq!(check["value"], true); + tool(&mut a, "edit_undo", json!({"doc": id})); + assert_eq!(bytes(&a, id), before); + tool(&mut a, "edit_redo", json!({"doc": id})); + tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"})); + let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); + let f = tool(&mut a, "form_fields", json!({"doc": reopened})); + let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap(); + assert_eq!(check["export_values"], json!(["Accepted"])); + assert_eq!(check["value"], true); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/hostile_args.rs b/crates/automation/tests/it/hostile_args.rs similarity index 56% rename from crates/automation/tests/hostile_args.rs rename to crates/automation/tests/it/hostile_args.rs index 607696899a..c017ff78d3 100644 --- a/crates/automation/tests/hostile_args.rs +++ b/crates/automation/tests/it/hostile_args.rs @@ -190,6 +190,54 @@ fn guarded(a: &mut Automation, tool: &str, args: &Value) -> std::result::Result< } } +/// A new one-page blank document, by id. +fn blank_doc(a: &mut Automation) -> u64 { + let created = a.call("doc_create", &json!({ "from": "blank", "pages": 1 })).expect("create a blank document"); + created + .into_iter() + .find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(), + pdfcraft_automation::Content::Png { .. } => None, + }) + .expect("the new document has an id") +} + +/// Delete everything inside `dir`, so one tool never sees files an earlier tool wrote: a digital ID +/// that `sign_id_create` wrote to the `in.pdf` placeholder would stop every later `sign_document` +/// call at its decrypt, before the argument under test. +/// +/// The entries are reached through the canonical path, as the tools reach them: on Windows that +/// is a `\\?\` path, whose names are taken literally. Through the plain path a file the tools +/// named `" "` would lose its trailing space and name `dir` itself, which would then be deleted. +fn empty_dir(dir: &Path) { + let dir = dir.canonicalize().expect("the root exists"); + for entry in std::fs::read_dir(&dir).expect("read the root") { + let entry = entry.unwrap_or_else(|e| panic!("list {}: {e}", dir.display())); + let path = entry.path(); + let is_dir = entry.file_type().unwrap_or_else(|e| panic!("file type of {}: {e}", path.display())).is_dir(); + let removed = if is_dir { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) }; + removed.unwrap_or_else(|e| panic!("remove {} before the next tool: {e}", path.display())); + } +} + +/// Real values for the arguments a tool checks before the others, so a hostile value reaches the +/// argument it is meant for instead of failing on a placeholder. `sign_document` checks its +/// digital ID first and its document second: this ID opens with its password, and each call gets +/// a fresh document (which the caller closes), since a successful call signs it. The ID is a test +/// key with a low key derivation count, so the hundreds of calls that open it stay fast. +fn fixture(a: &mut Automation, root: &Path, tool: &str) -> serde_json::Map { + match tool { + "sign_document" => { + let id = Path::new(env!("CARGO_MANIFEST_DIR")).join("../sign/tests/data/ec-p256.p12"); + std::fs::copy(&id, root.join("id.p12")).expect("copy the test digital ID"); + let doc = blank_doc(a); + let args = json!({ "doc": doc, "id": "id.p12", "password": "test", "out": "signed.pdf" }); + args.as_object().cloned().unwrap_or_default() + } + _ => serde_json::Map::new(), + } +} + #[test] fn no_tool_panics_on_hostile_arguments() { let root = tmp("args"); @@ -199,6 +247,7 @@ fn no_tool_panics_on_hostile_arguments() { let mut calls = 0usize; for def in tools() { + empty_dir(&root); // The whole arguments object, not just its properties. for bad in [json!(null), json!([]), json!("string"), json!(7), json!(true), json!({})] { calls += 1; @@ -210,13 +259,24 @@ fn no_tool_panics_on_hostile_arguments() { for prop in &props { for value in &values { let mut obj = plausible(&def); + let fixed = fixture(&mut a, &root, def.name); + let fixture_doc = fixed.get("doc").cloned(); + obj.extend(fixed); obj.insert(prop.clone(), value.clone()); calls += 1; if let Err(e) = guarded(&mut a, def.name, &Value::Object(obj)) { failures.push(e); } + // Later tools pass placeholder document ids, which must not find this one open. + if let Some(doc) = fixture_doc { + a.call("doc_close", &json!({ "doc": doc, "discard_changes": true })).expect("close the fixture's document"); + } } } + // A signed file shows the fixture got calls past the ID and document checks. + if def.name == "sign_document" { + assert!(root.join("signed.pdf").is_file(), "no sign_document call signed: the hostile values stop at the ID or document"); + } } eprintln!("hostile arguments: {calls} calls over {} tools, {} panics", tools().len(), failures.len()); @@ -256,14 +316,7 @@ fn no_tool_escapes_the_root() { let mut a = Automation::new().with_root(&root).expect("root"); // A real document, so tools reach their path arguments instead of failing on `doc` first. - let created = a.call("doc_create", &json!({ "from": "blank", "pages": 1 })).expect("create a blank document"); - let doc = created - .into_iter() - .find_map(|c| match c { - pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(), - pdfcraft_automation::Content::Png { .. } => None, - }) - .expect("the new document has an id"); + let doc = blank_doc(&mut a); let mut leaked = Vec::new(); let mut tried = 0usize; @@ -336,3 +389,146 @@ fn listing(dir: &Path) -> Vec<(PathBuf, u64)> { out.sort(); out } + +/// A six-page file whose fourth `/Kids` entry points at an object that does not exist (#2660), +/// with a link, a URI link and a comment on every page, and a form field and text added by +/// PdfCraft (its `/PCAdded` marker) on the last. Readers +/// disagree on such a file: the viewer stopped at the dangling entry and saw three pages, while +/// the editing layer sees all six, so annotations come back on pages the viewer does not have. +fn dangling_kid_pdf() -> Vec { + let pages: Vec = (0..6).map(|i| 10 + 2 * i).collect(); + let mut kids: Vec = pages[..3].to_vec(); + kids.push(77); // does not exist + kids.extend(&pages[3..]); + let kids: Vec = kids.iter().map(|k| format!("{k} 0 R")).collect(); + let mut objs: Vec<(u32, String)> = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [50 0 R] >> >>".into()), + (2, format!("<< /Type /Pages /Kids [{}] /Count 6 >>", kids.join(" "))), + (90, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()), + ]; + for (i, &page) in pages.iter().enumerate() { + let text = format!("BT /F1 24 Tf 50 300 Td (Page {}) Tj ET", i + 1); + let (field, contents) = if i == 5 { (" 50 0 R", format!("[{} 0 R 60 0 R]", page + 1)) } else { ("", format!("{} 0 R", page + 1)) }; + objs.push(( + page, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources << /Font << /F1 90 0 R >> >> /Contents {contents} \ + /Annots [<< /Type /Annot /Subtype /Link /Rect [10 10 100 40] /Dest [10 0 R /Fit] >> \ + << /Type /Annot /Subtype /Link /Rect [10 50 100 80] /A << /S /URI /URI (https://example.com/) >> >> \ + << /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /T (author) >>{field}] >>" + ), + )); + objs.push((page + 1, format!("<< /Length {} >>\nstream\n{text}\nendstream", text.len()))); + } + objs.push((50, "<< /FT /Tx /T (name) /Type /Annot /Subtype /Widget /Rect [50 100 250 130] /P 20 0 R >>".into())); + let added = "BT /F1 12 Tf 72 80 Td (AddedSample) Tj ET"; + objs.push(( + 60, + format!( + "<< /PCMark /Added /PCAdded << /Kind /Text /Rect [70 70 150 100] /Text (AddedSample) /Size 12 >> /Length {} >>\nstream\n{added}\nendstream", + added.len() + ), + )); + objs.sort_by_key(|(n, _)| *n); + + let size = 100; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![None; size]; + for (num, body) in &objs { + if let Some(slot) = offsets.get_mut(*num as usize) { + *slot = Some(out.len()); + } + out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for offset in offsets.iter().skip(1) { + out.extend_from_slice( + match offset { + Some(o) => format!("{o:010} 00000 n \n"), + None => "0000000000 65535 f \n".to_string(), + } + .as_bytes(), + ); + } + out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +#[test] +fn no_tool_panics_when_the_viewer_sees_fewer_pages_than_the_file_has() { + // #2660: `link_list` indexed the viewer's pages with a page number from the editing layer + // ("index out of bounds: the len is 3 but the index is 3"). Every tool, on a fresh copy of a + // file where the two disagree, must answer with a result or an error. + let root = tmp("dangling-kid"); + // The JSON a call answers with, or the error it gave. + let answer = |a: &mut Automation, tool: &str, args: &Value| -> std::result::Result { + match catch_unwind(AssertUnwindSafe(|| a.call(tool, args))) { + Ok(Ok(content)) => content + .into_iter() + .find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => Some(v), + pdfcraft_automation::Content::Png { .. } => None, + }) + .ok_or_else(|| format!("{tool} answered without JSON")), + Ok(Err(e)) => Err(format!("{tool} failed: {e:?}")), + Err(_) => Err(format!("{tool} panicked")), + } + }; + let pdf = dangling_kid_pdf(); + let mut failures = Vec::new(); + for def in tools() { + // Written again for every tool: tools that save write to `in.pdf`, the plausible path. + std::fs::write(root.join("in.pdf"), &pdf).expect("fixture"); + let mut a = Automation::new().with_root(&root).expect("root"); + let opened = a.call("doc_open", &json!({ "path": "in.pdf" })).expect("open the damaged file"); + let doc = opened + .into_iter() + .find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(), + pdfcraft_automation::Content::Png { .. } => None, + }) + .expect("the opened document has an id"); + if let Err(e) = guarded(&mut a, def.name, &Value::Object(plausible_with(&def, Some(doc)))) { + failures.push(e); + } + match def.name { + // The field sits on the sixth page; a rect is placed on the field's own page. That is + // refused, saying why, and the field is left as it was. + "form_set_props" => { + let fields = |a: &mut Automation| answer(a, "form_fields", &json!({ "doc": doc })); + let before = fields(&mut a); + match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &json!({ "doc": doc, "field": "name", "rect": [10, 10, 60, 30] })))) { + Ok(Err(ToolError::Failed(m))) if m == "name is on page 6, which could not be read" => {} + Ok(Err(e)) => failures.push(format!("form_set_props gave the wrong error: {e:?}")), + Ok(Ok(_)) => failures.push("form_set_props placed a rect on a page the viewer could not read".into()), + Err(_) => failures.push("form_set_props panicked on a rect for the field on page 6".into()), + } + let after = fields(&mut a); + if before.is_err() || before != after { + failures.push(format!("form_set_props changed the field: {before:?} became {after:?}")); + } + } + // Listing still reports what is on the pages the viewer lacks, not just survives it. + "link_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) { + Ok(v) if v["count"] == 12 => {} + other => failures.push(format!("link_list did not list the file's 12 links: {other:?}")), + }, + // The added text on page 6 is reported, its rect unchanged (user space), as + // `doc_info` reports rects on pages the viewer could not read. + "content_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) { + Ok(v) + if v["items"].as_array().is_some_and(|items| { + items.iter().any(|it| it["page"] == 6 && it["type"] == "text" && it["rect"] == json!([70.0, 70.0, 150.0, 100.0])) + }) => {} + other => failures.push(format!("content_list did not report the added text on page 6 at [70, 70, 150, 100]: {other:?}")), + }, + _ => {} + } + } + for f in &failures { + eprintln!(" {f}"); + } + let _ = std::fs::remove_dir_all(&root); + assert!(failures.is_empty(), "{} failure(s); the first is: {}", failures.len(), failures.first().map_or("", String::as_str)); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs new file mode 100644 index 0000000000..d36dbe93a9 --- /dev/null +++ b/crates/automation/tests/it/main.rs @@ -0,0 +1,14 @@ +//! The crate's integration tests, linked into one test binary. + +mod attachment_key; +mod automation; +mod automation_roots; +mod backdrop; +mod batch; +mod button_caption; +mod form_bulk; +mod hostile_args; +mod optimize_content_boundaries; +mod redact_after_stray_q_form; +mod redact_text_after_q; +mod render_warnings; diff --git a/crates/automation/tests/it/optimize_content_boundaries.rs b/crates/automation/tests/it/optimize_content_boundaries.rs new file mode 100644 index 0000000000..c37da05eb4 --- /dev/null +++ b/crates/automation/tests/it/optimize_content_boundaries.rs @@ -0,0 +1,165 @@ +//! Contributor-original PDFs exercise optimization across content stream boundaries. + +use std::sync::Arc; + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; +use serde_json::{Value, json}; + +const COLORS: [[u8; 3]; 4] = [[220, 30, 40], [20, 190, 60], [30, 60, 220], [230, 190, 20]]; + +fn fixture(ending: &str, split_operands: bool) -> Vec { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let font = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type1")), + (b"BaseFont".to_vec(), Object::name("Helvetica")), + ]))); + let mut xobjects = Dict::new(); + for (i, color) in COLORS.iter().enumerate() { + let dict = Dict::from_iter([ + (b"Type".to_vec(), Object::name("XObject")), + (b"Subtype".to_vec(), Object::name("Image")), + (b"Width".to_vec(), 20.into()), + (b"Height".to_vec(), 5.into()), + (b"BitsPerComponent".to_vec(), 8.into()), + (b"ColorSpace".to_vec(), Object::name("DeviceRGB")), + ]); + let image = doc.add(Object::Stream(Stream::flate(dict, &color.repeat(100)))); + xobjects.set(format!("Im{i}").into_bytes(), Object::Ref(image)); + } + // q and the translation stay active across both pieces. The optional split places + // the final two cm operands and its operator in the next stream. + let first = if split_operands { + "q 1 0 0 1".to_string() + } else { + format!("q 1 0 0 1 10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n% trailing comment{ending}") + }; + let mut second = if split_operands { + "10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n".to_string() + } else { + "BT /F1 20 Tf 30 400 Td (HIDDEN) Tj ET\n".to_string() + }; + for i in 0..4 { + second.push_str(&format!("q 20 0 0 5 100 {} cm /Im{i} Do Q\n", 200 + (3 - i) * 5)); + } + second.push_str("BT /F1 20 Tf 30 100 Td (LAST) Tj ET\nQ\n"); + let contents: Vec = + [first, second].into_iter().map(|piece| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), piece.into_bytes()))))).collect(); + let resources = Dict::from_iter([ + (b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Ref(font))]))), + (b"XObject".to_vec(), Object::Dict(xobjects)), + ]); + let page = doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])), + (b"Resources".to_vec(), Object::Dict(resources)), + (b"Contents".to_vec(), Object::Array(contents)), + ]))); + doc.update_dict(pages, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + dict.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +fn call(a: &mut Automation, tool: &str, args: Value) -> Value { + let result = a.call(tool, &args).unwrap(); + let Content::Json(value) = &result[0] else { panic!("expected JSON from {tool}") }; + value.clone() +} + +fn check_page(a: &mut Automation, doc: u64, hidden: bool, phase: &str) { + let text = call(a, "text_extract", json!({"doc": doc})); + let words: Vec<_> = text["pages"][0]["text"].as_str().unwrap().split_whitespace().collect(); + assert_eq!(words, if hidden { vec!["FIRST", "HIDDEN", "LAST"] } else { vec!["FIRST", "LAST"] }, "{phase} text"); + let rendered = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap(); + let Content::Png { data, width, height } = &rendered[0] else { panic!("expected page PNG") }; + assert_eq!((*width, *height), (600, 800)); + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.color_type.samples(); + assert!(channels >= 3); + let pixel = |x: usize, y: usize| &pixels[(y * 600 + x) * channels..(y * 600 + x) * channels + 3]; + let dark = |left, top, right, bottom| { + (top..bottom).flat_map(|y| (left..right).map(move |x| (x, y))).filter(|&(x, y)| pixel(x, y).iter().any(|v| *v < 128)).count() + }; + assert!(dark(35, 45, 130, 75) > 30, "FIRST must draw at its translated position"); + assert!(dark(35, 675, 130, 705) > 30, "LAST must draw at its translated position"); + assert_eq!(dark(35, 375, 150, 405) > 0, hidden, "HIDDEN region must match the real comment ending"); + for (row, color) in COLORS.iter().enumerate() { + for y in 581 + row * 5..584 + row * 5 { + for x in 112..128 { + assert_eq!(pixel(x, y), color, "strip {row}, pixel {x},{y}"); + } + } + } + assert_eq!(pixel(105, 590), [255, 255, 255], "the cross-piece translation stays active"); +} + +fn check_saved_images(bytes: Vec, merged: bool) { + let doc = Document::open(Arc::new(bytes)).unwrap(); + let root = doc.get(doc.root().unwrap()); + let pages = doc.get(root.as_dict().unwrap().reference(b"Pages").unwrap()); + let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + let page = doc.resolve(&kids[0]); + let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap()); + let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + let images: Vec<_> = xobjects.as_dict().unwrap().iter().map(|(_, value)| doc.resolve(value)).collect(); + assert_eq!(images.len(), if merged { 1 } else { 4 }); + if merged { + let Object::Stream(image) = &*images[0] else { panic!("expected saved image") }; + assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(20), Some(20))); + let expected: Vec<_> = COLORS.iter().flat_map(|color| color.repeat(100)).collect(); + assert_eq!(image.decoded().unwrap(), expected, "saved RGB rows keep their strip order"); + } +} + +fn roundtrip(ending: &str, split_operands: bool, merge: bool) { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("input.pdf"), fixture(ending, split_operands)).unwrap(); + let mut a = Automation::new().with_root(dir.path()).unwrap(); + let doc = call(&mut a, "doc_open", json!({"path": "input.pdf"}))["doc"].as_u64().unwrap(); + let hidden = !ending.is_empty() && !split_operands; + check_page(&mut a, doc, hidden, "original"); + let report = call( + &mut a, + "doc_optimize", + json!({ + "doc": doc, "path": "output.pdf", "merge_fragments": merge, + "color": {"downsample": false, "compression": "retain"}, + "gray": {"downsample": false, "compression": "retain"} + }), + ); + assert_eq!(report["fragments_merged"], if merge { 4 } else { 0 }); + assert_eq!(report["merged_images"], if merge { 1 } else { 0 }); + let reopened = call(&mut a, "doc_open", json!({"path": "output.pdf"}))["doc"].as_u64().unwrap(); + check_page(&mut a, reopened, hidden, "reopened"); + check_saved_images(std::fs::read(dir.path().join("output.pdf")).unwrap(), merge); +} + +#[test] +fn fragment_merge_preserves_a_comment_across_content_pieces() { + roundtrip("", false, true); +} + +#[test] +fn fragment_merge_preserves_explicit_comment_line_endings() { + for ending in ["\n", "\r", "\r\n"] { + roundtrip(ending, false, true); + } +} + +#[test] +fn fragment_merge_preserves_split_operands_and_graphics_state() { + roundtrip("", true, true); +} + +#[test] +fn disabled_fragment_merge_preserves_content_pieces() { + roundtrip("", false, false); +} diff --git a/crates/automation/tests/it/redact_after_stray_q_form.rs b/crates/automation/tests/it/redact_after_stray_q_form.rs new file mode 100644 index 0000000000..a91f872750 --- /dev/null +++ b/crates/automation/tests/it/redact_after_stray_q_form.rs @@ -0,0 +1,67 @@ +//! Search-and-redact of page text drawn after a form XObject with more `Q` than `q`. Find's boxes +//! come from the renderer's text and redaction measures the content itself, so the two must agree +//! that the form's stray `Q`s do not undo the page's `cm` (vendored hayro-interpret patch 68). + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +const CONTENT: &str = "q 1 0 0 1 20 -100 cm BT /F1 12 Tf 72 700 Td (AAAA) Tj ET /X Do \ + BT /F1 12 Tf 72 680 Td (SECRET) Tj ET Q"; +const FORM: &str = "BT /F1 12 Tf 200 0 Td ET Q Q Q"; + +fn pdf() -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \ + /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /X 5 0 R >> >> >>" + .to_owned(), + format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{FORM}\nendstream", FORM.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +fn text(a: &mut Automation, doc: &Value) -> String { + ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned() +} + +#[test] +fn find_and_redact_removes_text_drawn_after_a_form_with_stray_q() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redact-stray-q-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("in.pdf"), pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone(); + assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone(); + let after = text(&mut a, &saved); + assert!(!after.contains("SECRET"), "{after:?}"); + assert!(after.contains("AAAA"), "the text before the mark stays: {after:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/redact_text_after_q.rs b/crates/automation/tests/it/redact_text_after_q.rs new file mode 100644 index 0000000000..fceaffb872 --- /dev/null +++ b/crates/automation/tests/it/redact_text_after_q.rs @@ -0,0 +1,77 @@ +//! Search-and-redact of text that follows a `q`/`Q` inside a text object. `Q` restores the text +//! matrices there (ISO 32000-2 §9.4.1 as corrected by the PDF Association's errata, issue 368), so +//! "SECRET" is drawn from the pen position before `q`, over the "B". Find's boxes come from the +//! renderer's text, which places it there; redaction measures the content itself, and while it +//! carried the pen across `Q` the two disagreed and `redact_apply` refused to change anything. +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// The content, the text that stays after SECRET is redacted, and the text that goes with it. +const CASES: [(&str, &str, &str); 3] = [ + // A larger font inside `q`/`Q`: the font and the pen are both restored, so SECRET lies over + // the B and the B goes with it. + ("BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET", "AAAA", "B"), + // A move inside `q`/`Q` is undone. + ("BT /F1 12 Tf 72 700 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET", "AA", "SECRET"), + // The text line matrix is restored too: `T*` starts the next line from the line before `q`. + ("BT /F1 12 Tf 14 TL 72 700 Td (AA) Tj q 100 0 Td Q T* (SECRET) Tj ET", "AA", "SECRET"), +]; + +fn pdf(content: &str) -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \ + /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \ + /F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>" + .to_owned(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut out = b"%PDF-2.0\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +fn text(a: &mut Automation, doc: &Value) -> String { + ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned() +} + +#[test] +fn find_and_redact_removes_text_after_a_q_q_inside_its_text_object() { + for (case, (content, kept, gone)) in CASES.iter().enumerate() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}-{case}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("in.pdf"), pdf(content)).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone(); + assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1, "{content}"); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone(); + let after = text(&mut a, &saved); + assert!(!after.contains("SECRET") && !after.contains(gone), "{content}: {after:?}"); + assert!(after.contains(kept), "{content}: the text before the mark stays: {after:?}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/automation/tests/it/render_warnings.rs b/crates/automation/tests/it/render_warnings.rs new file mode 100644 index 0000000000..ff50c11e16 --- /dev/null +++ b/crates/automation/tests/it/render_warnings.rs @@ -0,0 +1,119 @@ +//! `page_render` reports the renderer's warnings next to the PNG, so a headless caller can tell +//! that a picture is incomplete or approximate, as `pdfcraft-cli render` can (#4340). + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// The warning a colour that falls back gives, worded as `pdfcraft-cli render` prints it. +const FALLBACK: &str = "a colour that could not be computed was painted in a fallback colour"; + +/// A one-page 200 × 200 pt PDF that draws `content`, with the colour space `CS1` defined as +/// `[/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R]` and object 5 a calculator function `program`. +fn devicen_pdf(content: &str, program: &str) -> Vec { + format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /CS1 [/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R] >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Length {} >> stream\n{program}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + program.len() + ) + .into_bytes() +} + +/// A page whose colour falls back: the tint transform pops more than it is given, so it can't be +/// evaluated (the renderer's own test of the warning uses the same function). +fn fallback_pdf() -> Vec { + devicen_pdf("/CS1 cs 0.5 0.5 scn 0 0 100 100 re f", "{ pop pop pop }") +} + +/// The same page with a tint transform that works: nothing to warn about. +fn clean_pdf() -> Vec { + devicen_pdf("/CS1 cs 0.5 0.5 scn 0 0 100 100 re f", "{ pop pop 0 0 0 }") +} + +/// A fresh folder holding `fallback.pdf` and `clean.pdf`, and an automation rooted there. +fn setup(test: &str) -> (PathBuf, Automation) { + let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("fallback.pdf"), fallback_pdf()).unwrap(); + std::fs::write(dir.join("clean.pdf"), clean_pdf()).unwrap(); + let a = Automation::new().with_root(&dir).unwrap(); + (dir, a) +} + +fn open(a: &mut Automation, path: &str) -> u64 { + match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) { + Content::Json(v) => v["doc"].as_u64().unwrap(), + other => panic!("expected JSON: {other:?}"), + } +} + +#[test] +fn page_render_lists_the_warnings_after_the_png() { + let (dir, mut a) = setup("fallback"); + let doc = open(&mut a, "fallback.pdf"); + let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + assert_eq!(out.len(), 2, "the PNG and the warnings: {out:?}"); + assert!(matches!(out[0], Content::Png { width: 200, height: 200, .. }), "the image comes first, as before"); + let Content::Json(report) = &out[1] else { panic!("expected the warnings as JSON: {:?}", out[1]) }; + assert_eq!(report["page"], 1, "the page is 1-based, like the argument"); + assert_eq!(report["warnings"], json!([FALLBACK])); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn render_preview_lists_the_warnings_after_the_png_too() { + let (dir, mut a) = setup("preview"); + let doc = open(&mut a, "fallback.pdf"); + let out = a.call("render_preview", &json!({ "doc": doc, "max_side": 64 })).unwrap(); + assert!(matches!(out.first(), Some(Content::Png { width: 64, height: 64, .. })), "{out:?}"); + let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") }; + assert_eq!(report["page"], 1, "the page defaults to the first"); + assert_eq!(report["warnings"], json!([FALLBACK])); + let clean = open(&mut a, "clean.pdf"); + assert_eq!(a.call("render_preview", &json!({ "doc": clean })).unwrap().len(), 1, "a clean page gives the PNG alone"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_clean_page_renders_to_the_png_alone() { + let (dir, mut a) = setup("clean"); + let fallback = open(&mut a, "fallback.pdf"); + let clean = open(&mut a, "clean.pdf"); + // The warning of one render doesn't carry over to the next, nor to another document. + a.call("page_render", &json!({ "doc": fallback, "page": 1, "dpi": 72 })).unwrap(); + let out = a.call("page_render", &json!({ "doc": clean, "page": 1, "dpi": 72 })).unwrap(); + assert_eq!(out.len(), 1, "no warnings, no extra part: {out:?}"); + assert!(matches!(out[0], Content::Png { .. })); + let again = a.call("page_render", &json!({ "doc": fallback, "page": 1, "dpi": 72 })).unwrap(); + assert_eq!(again.len(), 2, "a cached renderer still reports the page's warning: {again:?}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// Over MCP the warnings are a text block after the image and the call's `structuredContent`. +#[cfg(feature = "mcp")] +#[test] +fn mcp_page_render_carries_the_warnings_as_structured_content() { + use pdfcraft_automation::mcp::McpServer; + let (dir, a) = setup("mcp"); + let mut s = McpServer::new(a); + let mut rpc = |id: u64, method: &str, params: Value| -> Value { + let line = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string(); + serde_json::from_str(&s.handle_line(&line).expect("a reply")).unwrap() + }; + rpc(1, "initialize", json!({})); + rpc(2, "tools/call", json!({ "name": "doc_open", "arguments": { "path": "fallback.pdf" } })); + let reply = rpc(3, "tools/call", json!({ "name": "page_render", "arguments": { "doc": 1, "page": 1 } })); + let result = &reply["result"]; + assert_eq!(result["isError"], false, "{reply}"); + assert_eq!(result["content"][0]["type"], "image"); + assert_eq!(result["content"][1]["type"], "text"); + assert_eq!(result["structuredContent"]["warnings"], json!([FALLBACK])); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/content/Cargo.toml b/crates/content/Cargo.toml index c553df0b8c..e6b4c28a42 100644 --- a/crates/content/Cargo.toml +++ b/crates/content/Cargo.toml @@ -10,3 +10,6 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +# The renderer's content tokenizer, already in the tree through pdfcraft-cos: inline images end +# where the renderer ends them. +hayro-syntax = { workspace = true } diff --git a/crates/content/README.md b/crates/content/README.md index 6ab984dd49..fedeadc56b 100644 --- a/crates/content/README.md +++ b/crates/content/README.md @@ -13,7 +13,10 @@ The parser is tolerant (unreadable bytes are skipped and counted, never fatal) a panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker and the `Patch` API of architecture §6 grow here. -Unfiltered inline images with built-in or Indexed colour spaces, and image masks, use their -sample layout (including per-row byte padding) to locate `EI`. This preserves pixel bytes -that look like the terminator. Filtered images and resource-defined colour spaces retain -the tolerant delimiter heuristic; damaged or truncated layouts fall back to it too. +An inline image ends where the renderer's tokenizer (`hayro-syntax`) ends it, so the parser +and the renderer read the same operators after the image, and redaction removes what the page +actually draws. Only where that tokenizer reads no image (its parameters don't read, or no `EI` +passes its checks) does the parser locate `EI` by its own rules: unfiltered images with built-in +or Indexed colour spaces, and image masks, by their sample layout (including per-row byte +padding), other images by the tolerant delimiter heuristic. Such an image is counted in +`Parsed::ambiguous_images`, because the operators after it may not be the ones the page draws. diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index c7c8bca9ee..c52916d332 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -52,6 +52,12 @@ impl Op { pub fn name(&self, i: usize) -> Option<&[u8]> { self.operands.get(i).and_then(Object::as_name) } + + /// The last operand as a name: the one `Do` and the other one-operand operators act on, since + /// the renderer takes operands from the end and ignores any surplus before them. + pub fn last_name(&self) -> Option<&[u8]> { + self.operands.last().and_then(Object::as_name) + } } /// A parsed stream. @@ -60,6 +66,10 @@ pub struct Parsed { pub ops: Vec, /// Bytes that could not be read as operands or operators. pub skipped: usize, + /// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it + /// accepts). Their end here is a guess, so the operators after them may not be the ones the + /// page draws. + pub ambiguous_images: usize, /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before /// and whether one runs out of it into the next. Only boundaries are recorded, so the size /// depends on the number of pieces, not of comments. @@ -74,95 +84,172 @@ fn is_delim(b: u8) -> bool { matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') } +/// Longest unreadable number token retried from its next byte. Real numbers are far shorter. +const MAX_RETRIED_TOKEN: usize = 64; + /// Parse a (decoded) content stream. pub fn parse(data: &[u8]) -> Parsed { parse_pieces(data, &[]) } +/// The operators of a (decoded) content stream, read one at a time, as [`parse`] reads them. A +/// caller that stops early or keeps only a summary never holds every operator of the stream. +pub fn ops(data: &[u8]) -> Ops<'_> { + Ops::new(data, &[]) +} + /// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment /// crosses. fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { - let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() }; - let mut boundary = 0; - let mut lx = Lexer::new(data, 0); - let mut operands: Vec = Vec::new(); - let mut start: Option = None; - loop { - let trivia = lx.pos; - lx.skip_ws(); - // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a - // string, a name, an array or dictionary operand or inline image data is not one. - if boundary < ends.len() { - comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges); + let mut reader = Ops::new(data, ends); + let ops = reader.by_ref().collect(); + Parsed { ops, skipped: reader.skipped, ambiguous_images: reader.ambiguous_images, comment_edges: reader.comment_edges } +} + +/// The iterator [`ops`] returns. +pub struct Ops<'a> { + data: &'a [u8], + lx: Lexer<'a>, + operands: Vec, + start: Option, + skipped: usize, + ambiguous_images: usize, + ends: &'a [usize], + boundary: usize, + comment_edges: Vec<(bool, bool)>, +} + +impl<'a> Ops<'a> { + fn new(data: &'a [u8], ends: &'a [usize]) -> Self { + Ops { + data, + lx: Lexer::new(data, 0), + operands: Vec::new(), + start: None, + skipped: 0, + ambiguous_images: 0, + ends, + boundary: 0, + comment_edges: vec![(false, false); ends.len()], } - let Some(&b) = data.get(lx.pos) else { break }; - let at = lx.pos; - let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); - if operand_start { - match lx.object() { - Ok(o) => { + } + + /// Bytes read so far that could not be read as operands or operators. + pub fn skipped(&self) -> usize { + self.skipped + } + + /// Inline images read so far whose end is this parser's guess (as in + /// [`Parsed::ambiguous_images`]). + pub fn ambiguous_images(&self) -> usize { + self.ambiguous_images + } +} + +impl Iterator for Ops<'_> { + type Item = Op; + + fn next(&mut self) -> Option { + let Ops { data, lx, operands, start, skipped, ambiguous_images, ends, boundary, comment_edges: edges } = self; + let (data, ends): (&[u8], &[usize]) = (data, ends); + loop { + let trivia = lx.pos; + lx.skip_ws(); + // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a + // string, a name, an array or dictionary operand or inline image data is not one. + if *boundary < ends.len() { + comment_edges(data, trivia..lx.pos, ends, boundary, edges); + } + let Some(&b) = data.get(lx.pos) else { + // Operands left without an operator. + *skipped += operands.len(); + operands.clear(); + return None; + }; + let at = lx.pos; + let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); + if operand_start { + match lx.object() { + Ok(o) => { + start.get_or_insert(at); + operands.push(o); + } + Err(_) if b == b'(' => { + // An unterminated string runs to the end of the stream. + *skipped += data.len() - at; + lx.pos = data.len(); + } + Err(_) => { + let read = lx.pos.saturating_sub(at); + #[cfg(test)] + tests::RESCANNED.with(|c| c.set(c.get() + read)); + let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit(); + if number && read > MAX_RETRIED_TOKEN { + // A long unreadable number is skipped whole: retrying from each of its + // bytes would read the rest of it again every time. + *skipped += read; + } else { + // Skip the offending byte and carry on. + lx.pos = at + 1; + *skipped += 1; + } + } + } + continue; + } + if is_delim(b) { + // A stray `)`, `>`, `]`, `{`, `}`. + lx.pos += 1; + *skipped += 1; + continue; + } + let kw = lx.token(); + if kw.is_empty() { + lx.pos = at + 1; + *skipped += 1; + continue; + } + match kw { + b"true" => { start.get_or_insert(at); - operands.push(o); + operands.push(Object::Bool(true)); } - Err(_) if b == b'(' => { - // An unterminated string runs to the end of the stream. - out.skipped += data.len() - at; - lx.pos = data.len(); + b"false" => { + start.get_or_insert(at); + operands.push(Object::Bool(false)); } - Err(_) => { - // Skip the offending byte and carry on. - lx.pos = at + 1; - out.skipped += 1; + b"null" => { + start.get_or_insert(at); + operands.push(Object::Null); } - } - continue; - } - if is_delim(b) { - // A stray `)`, `>`, `]`, `{`, `}`. - lx.pos += 1; - out.skipped += 1; - continue; - } - let kw = lx.token(); - if kw.is_empty() { - lx.pos = at + 1; - out.skipped += 1; - continue; - } - match kw { - b"true" => { - start.get_or_insert(at); - operands.push(Object::Bool(true)); - } - b"false" => { - start.get_or_insert(at); - operands.push(Object::Bool(false)); - } - b"null" => { - start.get_or_insert(at); - operands.push(Object::Null); - } - b"BI" => match inline_image(data, lx.pos) { - Ok((dict, img, end)) => { + b"BI" => { + let image = inline_image(data, at, lx.pos); + *ambiguous_images += usize::from(image.ambiguous); + let end = image.end; lx.pos = end; - out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end }); operands.clear(); + match image.image { + Some((dict, img)) => { + return Some(Op { + op: b"BI".to_vec(), + operands: Vec::new(), + inline: Some((dict, img)), + span: start.take().unwrap_or(at)..end, + }); + } + None => { + *skipped += end.saturating_sub(at); + *start = None; + } + } } - Err(end) => { - out.skipped += end.saturating_sub(at); - lx.pos = end; - start = None; - operands.clear(); + _ => { + let s = start.take().unwrap_or(at); + return Some(Op { op: kw.to_vec(), operands: std::mem::take(operands), inline: None, span: s..lx.pos }); } - }, - _ => { - let s = start.take().unwrap_or(at); - out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos }); } } } - out.skipped += operands.len(); - out } /// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and @@ -198,11 +285,29 @@ fn comment_edges(data: &[u8], trivia: std::ops::Range, ends: &[usize], bo } } -/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds -/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read. -fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> { +/// An inline image as [`inline_image`] reads it. +struct Inline { + /// Its parameters and data; `None` when the parameters can't be read. + image: Option<(Dict, Vec)>, + /// The position after its `EI` (or the end of the stream). + end: usize, + /// The renderer's tokenizer reads no image here, so `end` is this parser's guess. + ambiguous: bool, +} + +/// Read the inline image whose `BI` keyword starts at `bi` (its parameters start at `pos`). +/// +/// Filtered image data has no length, and `EI` can occur inside it, so where it ends is a +/// heuristic choice among `EI` candidates. The renderer's choice decides which operators the +/// page draws after the image, and redaction must see the same ones, so the end is taken from +/// the renderer's tokenizer (hayro-syntax) rather than worked out here by another rule. Only +/// when that tokenizer reads no image does this parser fall back to its own rules, and the +/// image is marked ambiguous. +fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { let mut lx = Lexer::new(data, pos); let mut dict = Dict::new(); + let mut readable = true; + let mut stop = pos; loop { lx.skip_ws(); if lx.eat_keyword(b"ID") { @@ -216,20 +321,87 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize let Some((k, v)) = entry else { // A stream that ends inside the parameters is cut short, not unreadable: keep what was read. if at >= data.len() { - return Ok((dict, Vec::new(), data.len())); + return Inline { image: Some((dict, Vec::new())), end: data.len(), ambiguous: false }; } - // Without an `EI` after it, the image runs to the end of the stream. - return Err(end_of_image(data, at).map_or(data.len(), |(_, end)| end)); + readable = false; + stop = at; + break; }; dict.set(k, v); } + if let Some((start, end_data, end)) = renderer_image_end(data, bi) { + // Where the renderer ends the data at an `EI` found by its heuristic, one whitespace byte + // before that `EI` separates it from the data. + let mut samples = data.get(start..end_data).unwrap_or_default(); + if data.get(end_data..).is_some_and(|rest| rest.starts_with(b"EI")) + && let Some((last, rest)) = samples.split_last() + && is_ws(*last) + { + samples = rest; + } + return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false }; + } + if !readable { + // Without an `EI` after it, the image runs to the end of the stream. + return Inline { image: None, end: end_of_image(data, stop, b"").map_or(data.len(), |(_, end)| end), ambiguous: true }; + } // One whitespace byte separates ID from the data. let begin = (lx.pos + 1).min(data.len()); - let end = unfiltered_image_end(data, begin, &dict).or_else(|| end_of_image(data, begin)); - Ok(match end { - Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end), - None => (dict, data[begin..].to_vec(), data.len()), - }) + let end = unfiltered_image_end(data, begin, &dict) + .or_else(|| declared_image_end(data, begin, &dict)) + .or_else(|| image_data_end(data, begin, filter_eod(&dict))); + let (image, end) = match end { + Some((end_data, end)) => (data.get(begin..end_data).unwrap_or_default().to_vec(), end), + None => (data.get(begin..).unwrap_or_default().to_vec(), data.len()), + }; + Inline { image: Some((dict, image)), end, ambiguous: true } +} + +/// Where the renderer's tokenizer ends the inline image whose `BI` starts at `bi`: the start and +/// end of its data and the position after its `EI`. `None` when it reads no image there (its +/// parameters don't read, or no `EI` passes its checks). +fn renderer_image_end(data: &[u8], bi: usize) -> Option<(usize, usize, usize)> { + use hayro_syntax::content::TypedIter; + use hayro_syntax::content::ops::{InlineImage, TypedInstruction}; + let tail = data.get(bi..)?; + let mut iter = TypedIter::new(tail); + let (start, len) = match iter.next()? { + TypedInstruction::InlineImage(InlineImage(stream)) => match stream.raw_data() { + // The data is a slice of `tail`: its offset is where it starts. + std::borrow::Cow::Borrowed(samples) => ((samples.as_ptr() as usize).checked_sub(tail.as_ptr() as usize)?, samples.len()), + std::borrow::Cow::Owned(_) => return None, + }, + _ => return None, + }; + let end_data = start.checked_add(len)?; + // `committed` is past the whitespace after `EI`; the image ends at the `EI`. + let mut end = iter.committed(); + while end > end_data && tail.get(end - 1).is_some_and(|b| is_ws(*b)) { + end -= 1; + } + if end < end_data.checked_add(2)? || tail.get(end - 2..end) != Some(b"EI") { + return None; + } + Some((bi.checked_add(start)?, bi.checked_add(end_data)?, bi.checked_add(end)?)) +} + +/// The end-of-data marker (`>` for ASCIIHex, `~>` for ASCII85) of the outer stream filter, if any. +fn filter_eod(dict: &Dict) -> &'static [u8] { + for key in [b"Filter".as_slice(), b"F"] { + if let Some(filter) = dict.get(key) { + let name = match filter { + Object::Name(name) => Some(name.as_slice()), + Object::Array(filters) => filters.first().and_then(Object::as_name), + _ => None, + }; + return match name { + Some(b"ASCIIHexDecode" | b"AHx") => b">", + Some(b"ASCII85Decode" | b"A85") => b"~>", + _ => b"", + }; + } + } + b"" } /// An unfiltered image's samples determine its length, including byte padding per row. @@ -293,23 +465,60 @@ fn unfiltered_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8); let end_data = begin.checked_add(row.checked_mul(height)?)?; let after = data.get(end_data..)?; - if !after.first().is_some_and(|b| is_ws(*b)) { + let separator = after.iter().position(|b| !is_ws(*b))?; + if separator == 0 && data.get(end_data.checked_sub(1)?).is_some_and(|b| is_ws(*b)) { return None; } - let separator = after.iter().position(|b| !is_ws(*b))?; let ei = end_data.checked_add(separator)?; let end = ei.checked_add(2)?; if data.get(ei..end) == Some(b"EI") && data.get(end).is_none_or(|b| is_ws(*b) || is_delim(*b)) { Some((end_data, end)) } else { None } } +/// The length declared by `/L` (or `/Length`, ISO 32000-2 §8.9.7), used when `EI` follows the +/// declared bytes after optional whitespace. A declared length without that `EI` is ignored. +fn declared_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize, usize)> { + let length = dict.get(b"L").or_else(|| dict.get(b"Length"))?.as_int()?; + let end_data = begin.checked_add(usize::try_from(length).ok()?)?; + let after = data.get(end_data..)?; + let ei = end_data.checked_add(after.iter().position(|b| !is_ws(*b))?)?; + let end = ei.checked_add(2)?; + if data.get(ei..end) == Some(b"EI") && data.get(end).is_none_or(|b| is_ws(*b) || is_delim(*b)) { Some((end_data, end)) } else { None } +} + +/// Where inline image data of unknown length ends. A candidate `EI` followed by binary bytes before +/// the next `EI` lies inside the (compressed) data, unless another inline image starts first. +fn image_data_end(data: &[u8], begin: usize, eod: &[u8]) -> Option<(usize, usize)> { + let mut candidate = end_of_image(data, begin, eod)?; + loop { + let (end_data, end) = candidate; + let Some(next) = end_of_image(data, end, eod) else { + return Some(candidate); + }; + let between = data.get(end..next.0).unwrap_or_default(); + if !between.iter().any(|b| !b.is_ascii()) || starts_inline_image(between) { + return Some((end_data, end)); + } + candidate = next; + } +} + +/// Whether `bytes` hold a whitespace-delimited `BI` operator. +fn starts_inline_image(bytes: &[u8]) -> bool { + bytes + .windows(2) + .enumerate() + .any(|(i, pair)| pair == b"BI" && (i == 0 || is_ws(bytes[i - 1])) && bytes.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b))) +} + /// Where inline image data starting at `begin` ends: (end of the data, position after `EI`). The -/// data ends at whitespace + `EI` + (whitespace, a delimiter or the end). -fn end_of_image(data: &[u8], begin: usize) -> Option<(usize, usize)> { +/// data ends at whitespace (or `eod`: `>` for ASCIIHex, `~>` for ASCII85) + `EI` + (whitespace, +/// a delimiter or the end). +fn end_of_image(data: &[u8], begin: usize, eod: &[u8]) -> Option<(usize, usize)> { let mut i = begin; while i + 2 <= data.len() { if data[i] == b'E' && data.get(i + 1) == Some(&b'I') - && (i == begin || is_ws(data[i - 1])) + && (i == begin || is_ws(data[i - 1]) || (!eod.is_empty() && data[begin..i].ends_with(eod))) && data.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b)) { let end_data = if i > begin && is_ws(data[i - 1]) { i - 1 } else { i }; @@ -433,9 +642,20 @@ impl Pieces { /// for byte. Returns every piece's new bytes (unchanged pieces come back equal). pub fn splice(&self, edits: impl IntoIterator, Vec)>) -> Vec> { let mut out: Vec> = (0..self.len()).map(|i| Vec::with_capacity(self.range(i).len())).collect(); + // Only the pieces from the one holding `from` up to `to`: one call per edit must not + // visit every piece. let copy = |out: &mut Vec>, from: usize, to: usize| { - for (i, piece) in out.iter_mut().enumerate() { + if from >= to { + return; + } + let first = self.piece_of(from); + for (i, piece) in out.iter_mut().enumerate().skip(first) { + #[cfg(test)] + tests::PIECE_VISITS.with(|c| c.set(c.get() + 1)); let r = self.range(i); + if r.start >= to { + break; + } let (a, b) = (from.max(r.start), to.min(r.end)); if a < b { piece.extend_from_slice(self.data.get(a..b).unwrap_or_default()); diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 1119bdaf90..c5c7a3aaac 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -1,5 +1,51 @@ use super::*; +thread_local! { + /// Bytes the lexer read before an operand failed, on this thread. + pub(crate) static RESCANNED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +thread_local! { + /// Pieces [`Pieces::splice`] looked at while copying, on this thread. + pub(crate) static PIECE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn splicing_visits_each_piece_a_few_times() { + let n = 5_000; + let pieces: Vec = (0..n).map(|i| format!("({i}) Tj")).collect(); + let joined = Pieces::join(&pieces); + let parsed = joined.parse(); + let edits: Vec<_> = parsed.ops.iter().map(|op| (op.span.clone(), b"(x) Tj".to_vec())).collect(); + assert_eq!(edits.len(), n); + PIECE_VISITS.with(|c| c.set(0)); + let out = joined.splice(edits); + let visits = PIECE_VISITS.with(|c| c.get()); + assert!(visits <= 4 * n, "{visits} piece visits for {n} pieces"); + assert!(out.iter().all(|p| p == b"(x) Tj")); + // A replacement spanning two pieces stays whole in the first and leaves the rest of the second. + let two = Pieces::join(&["q (a)", "Tj Q"]); + let tj = two.parse().ops.into_iter().find(|o| o.is("Tj")).map(|o| o.span).unwrap_or_default(); + assert_eq!(two.splice([(tj, b"(b) Tj".to_vec())]), [b"q (b) Tj".to_vec(), b" Q".to_vec()]); +} + +#[test] +fn a_long_unreadable_number_is_read_once() { + for junk in ["+", "5"] { + let n = 20_000; + let src = format!("{}{} 1 0 0 1 0 0 cm", junk.repeat(n), if junk == "5" { "x" } else { "" }); + RESCANNED.with(|c| c.set(0)); + let p = parse(src.as_bytes()); + let rescanned = RESCANNED.with(|c| c.get()); + assert!(rescanned <= 2 * n, "{rescanned} bytes read for a {n}-byte token"); + assert_eq!(p.ops.last().and_then(|o| o.nums::<6>()), Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + } + // A short damaged number still recovers byte by byte. + let p = parse(b"-+5 0 Td"); + assert_eq!((p.ops.len(), p.skipped), (1, 1)); + assert_eq!(p.ops[0].nums::<2>(), Some([5.0, 0.0])); +} + #[test] fn operators_keep_operands_and_spans() { let src = b"q 1 0 0 1 72 720 cm BT /F1 12 Tf (Hello \\(world\\)) Tj [(A) -120 (B)] TJ ET Q"; @@ -102,10 +148,11 @@ fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_compon let parsed = inline_pixels(parameters.as_bytes(), b" EI "); assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); assert_eq!(parsed.ops.len(), 3); - // Indexed samples permit at most 8 bits, even though device spaces permit 16. + // Indexed samples permit at most 8 bits, even though device spaces permit 16, so the + // length is unknown, and the renderer takes the second `EI`. let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16"); let parsed = inline_pixels(invalid.as_bytes(), b" EI "); - assert!(parsed.ops[1].inline.as_ref().unwrap().1.is_empty()); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI "); } } @@ -127,7 +174,7 @@ fn unfiltered_inline_image_terminator_must_follow_its_samples() { } #[test] -fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { +fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() { for parameters in [ b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(), b"/W 4 /H 1 /BPC 8 /CS /ResourceColor", @@ -142,12 +189,49 @@ fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() { b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK", b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G", ] { + // What follows the first `EI` (`!!~>`) does not read as content, so the renderer takes + // the later one. let parsed = inline_pixels(parameters, b"! EI !!~>"); - assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"!", "{}", String::from_utf8_lossy(parameters)); + let label = String::from_utf8_lossy(parameters); + assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"! EI !!~>", "{label}"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"BI", b"Q"], "{label}"); + assert_eq!(parsed.ambiguous_images, 0, "{label}"); } - // A short payload still uses the tolerant recovery, including when there is no EI. - for src in [b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc"] { - assert_eq!(parse(src).ops[0].inline.as_ref().unwrap().1, b"abc"); + // A short payload is read whole; without an `EI`, the renderer reads no image and this + // parser's end is a guess. + for (src, ambiguous) in [(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), 0), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc", 1)] { + let parsed = parse(src); + assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc"); + assert_eq!(parsed.ambiguous_images, ambiguous); + let mut reader = ops(src); + reader.by_ref().for_each(drop); + assert_eq!(reader.ambiguous_images(), ambiguous, "the streaming reader counts it too"); + } +} + +/// A zlib stream (one stored block) for 64 DeviceGray samples whose bytes hold " EI\n" at offset 41, +/// the way a stored deflate block copies incompressible samples verbatim (#3579). +const FLATE_WITH_EI: &str = "789c014000bfffed746159d50788f7ea604984cd08e00b4c8983207f56fe82c77ef8dfb8b9a3a586382045490a3120\ + 31206c2025630aaa4353e6813e233b96539c18bc3a2c5009e9d51b81"; + +#[test] +fn flate_inline_image_is_not_cut_at_an_ei_inside_its_compressed_bytes() { + let flate: Vec = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect(); + assert_eq!(flate.len(), 75); + assert_eq!(&flate[41..45], b" EI\n"); + for parameters in [b"/W 64 /H 1 /BPC 8 /CS /G /F /Fl".as_slice(), b"/W 64 /H 1 /BPC 8 /CS /G /F /Fl /L 75"] { + let mut src = b"q 200 0 0 200 100 300 cm BI ".to_vec(); + src.extend_from_slice(parameters); + src.extend_from_slice(b" ID "); + src.extend_from_slice(&flate); + src.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET"); + let parsed = parse(&src); + let label = String::from_utf8_lossy(parameters); + assert_eq!(parsed.ops[2].inline.as_ref().unwrap().1.len(), 75, "{label}"); + let names: Vec<&[u8]> = parsed.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!(names, [&b"q"[..], b"cm", b"BI", b"Q", b"BT", b"Tf", b"Td", b"Tj", b"ET"], "{label}"); + assert_eq!(parsed.skipped, 0, "{label}"); + assert_eq!(parse(&serialize_ops(&parsed.ops)).ops[2].inline, parsed.ops[2].inline, "{label}"); } } @@ -388,3 +472,83 @@ fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() { assert_eq!(empty.piece_of(0), 0); assert_eq!(empty.piece_of(usize::MAX), 0); } + +#[test] +fn inline_image_terminator_without_preceding_whitespace() { + let cases: &[(&[u8], &[u8])] = &[ + (b"BI /W 4 /H 1 /BPC 8 /CS /G /F /AHx ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCIIHexDecode ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/AHx] ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/AHx /RL] ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCII85Decode ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/A85 /RL] ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab()EI Q BT /F1 18 Tf (After) Tj ET", b"ab()"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab[]EI Q BT /F1 18 Tf (After) Tj ET", b"ab[]"), + (b"BI /W 4 /H 1 /BPC 8 /CS /G ID \x20\x60\xa0\xe0EI Q BT /F1 18 Tf (After) Tj ET", b"\x20\x60\xa0\xe0"), + ]; + + for (src, expected_data) in cases { + let p = parse(src); + assert_eq!(p.skipped, 0, "stream should have 0 skipped bytes: {}", String::from_utf8_lossy(src)); + let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!( + names, + [&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"], + "operators after inline image must be preserved: {}", + String::from_utf8_lossy(src) + ); + let (_, img) = p.ops[0].inline.as_ref().unwrap(); + assert_eq!(img, expected_data, "inline image data must match: {}", String::from_utf8_lossy(src)); + } +} + +#[test] +fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() { + for src in [ + b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET".as_slice(), + b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/A85 /AHx] ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET".as_slice(), + ] { + let p = parse(src); + assert_eq!(p.skipped, 0); + let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!(names, [&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"]); + let (_, img) = p.ops[0].inline.as_ref().unwrap(); + assert_eq!(img, b"!!!!>EI !!~>"); + } +} + +/// The operators the renderer's tokenizer reads from `data`. +fn renderer_operators(data: &[u8]) -> Vec> { + let mut iter = hayro_syntax::content::UntypedIter::new(data); + let mut out = Vec::new(); + while let Some(instruction) = iter.next() { + out.push(instruction.operator.as_ref().to_vec()); + } + out +} + +/// Inline images end where the renderer ends them, so both read the same operators after one: +/// an `EI` without whitespace before it in the data, upper-case text ending in "EI" after the +/// image, and an `EI` inside compressed bytes (#3579). +#[test] +fn inline_images_end_where_the_renderer_ends_them() { + let flate: Vec = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect(); + let mut stored = b"q BI /W 64 /H 1 /BPC 8 /CS /G /F /Fl ID ".to_vec(); + stored.extend_from_slice(&flate); + stored.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET"); + let fixtures: [&[u8]; 6] = [ + b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", + b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F> EI BT /F1 10 Tf 10 100 Td (ZWEI) Tj (EIN FREI BEI) Tj ET", + b"q BI /W 2 /H 1 /CS /G /BPC 8 /F /AHx ID 7F7F> EI Q BT (DREI) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI", + b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID ! EI !!~>\nEI Q", + b"q BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q", + &stored, + ]; + for data in fixtures { + let parsed = parse(data); + let ours: Vec> = parsed.ops.iter().map(|op| op.op.clone()).collect(); + assert_eq!(ours, renderer_operators(data), "{}", String::from_utf8_lossy(data)); + assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data)); + } +} diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index 9ef9126ca8..a2fb3f4c4e 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -12,6 +12,8 @@ workspace = true pdfcraft-crypt = { workspace = true } pdfcraft-filters = { workspace = true } thiserror = { workspace = true } +# PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code). +chrono = { workspace = true } [dev-dependencies] pdfcraft-oracle = { workspace = true } diff --git a/crates/cos/README.md b/crates/cos/README.md index ab382219d5..2cf3342954 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -24,6 +24,9 @@ let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc, resolving first let data = stream.decoded()?; // filters applied on demand +let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too +let px = doc.decoded_image(&stream)?; // an image's samples, a CCITT fax image's too +let fax = doc.fax_params(&stream); // a CCITT fax image's /DecodeParms, Table 11 defaults doc.set(r, Object::Dict(d)); // edit: lands in the overlay let r = doc.add(obj); // append a new object @@ -87,5 +90,6 @@ recognisable in a diff and byte-stable when re-serialized. - Linearized output, Fast Web View (`core.linearization`, M11). - Structural validation against the Arlington model (`core.arlington-validation`), which will live in the reserved `arlington` crate. -- Image codecs. `pdfcraft-filters` carries the non-image filters; DCT, JPX, JBIG2 and CCITT are - still decoded through the bootstrap renderer (`core.image-filters`, M2.6). +- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax decoding + (`Document::decoded_image`); DCT, JPX and JBIG2 are still decoded through the bootstrap renderer + (`core.image-filters`, M2.6). diff --git a/crates/cos/examples/protect.rs b/crates/cos/examples/protect.rs index 6f27a60969..48498aefa8 100644 --- a/crates/cos/examples/protect.rs +++ b/crates/cos/examples/protect.rs @@ -1,4 +1,17 @@ //! Encrypt a PDF: `cargo run -p pdfcraft-cos --example protect in.pdf out.pdf rc4-40|rc4-128|aes-128|aes-256 USER OWNER` +/// 32 fresh bytes for the file key. `RandomState` is seeded by the operating system (as the +/// engine's `entropy` uses it), so every run encrypts with a different key. +fn seed() -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_usize(i); + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out +} + fn main() { let a: Vec = std::env::args().skip(1).collect(); let [input, output, alg, user, owner] = &a[..] else { panic!("usage: protect in out alg user owner") }; @@ -15,7 +28,7 @@ fn main() { owner_password: owner, permissions: -1, encrypt_metadata: true, - seed: [1; 32], + seed: seed(), }) .unwrap(); std::fs::write(output, pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()).unwrap(); diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs new file mode 100644 index 0000000000..1ddd42eaf5 --- /dev/null +++ b/crates/cos/src/budget.rs @@ -0,0 +1,220 @@ +//! A shared allowance for operations that decode many streams. + +use std::collections::HashSet; + +use pdfcraft_filters::FilterError; + +use crate::{CosError, Document, ObjRef, Object, Stream}; + +/// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every +/// form XObject it enters, each occurrence counted again. +pub const MAX_PAGE_DECODED: usize = 256 << 20; +/// Form XObjects one page's content walk may enter, each entry counted again. +pub const MAX_PAGE_FORMS: usize = 50_000; + +/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or +/// the error, each with that count. +type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usize)>; + +/// What one operation may still decode and enter across all the streams it reads. +/// +/// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose +/// `/Contents` names the same compressed stream thousands of times, or a form drawn thousands of +/// times from other forms: each occurrence would get a fresh allowance. A `DecodeBudget` is +/// shared by every decode of one operation, so the total decoded bytes and form entries stay +/// under fixed limits however the file repeats its references. A decode that runs past the +/// remaining allowance spends all of it, so a bomb referenced many times is inflated once. +#[derive(Debug)] +pub struct DecodeBudget { + /// The whole allowance, for errors. + limit: usize, + bytes: usize, + forms: usize, + seen: HashSet, +} + +impl DecodeBudget { + /// An allowance of `bytes` decoded bytes and `forms` form entries. + pub fn new(bytes: usize, forms: usize) -> Self { + Self { limit: bytes, bytes, forms, seen: HashSet::new() } + } + + /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. + pub fn page() -> Self { + Self::new(MAX_PAGE_DECODED, MAX_PAGE_FORMS) + } + + /// Decoded bytes still allowed. + pub fn remaining(&self) -> usize { + self.bytes + } + + /// Whether a decode was refused for lack of allowance or a form entry for lack of visits. + pub fn exhausted(&self) -> bool { + self.bytes == 0 || self.forms == 0 + } + + /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without + /// decoding, once the allowance is spent. + pub fn decode(&mut self, stream: &Stream) -> Result, CosError> { + self.charge(stream, usize::MAX, Stream::decoded_within_counted) + } + + /// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding + /// past `max` bytes is an error that charges `max`, the work it took to find out. + pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result, CosError> { + self.charge(stream, max, Stream::decoded_within_counted) + } + + /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to + /// the allowance. + pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> { + self.charge(stream, usize::MAX, Stream::decoded_strict_within_counted) + } + + /// Decodes `stream` within `max` and the allowance, charging what every filter stage + /// decoded: a stage's output counts even when a later stage shrinks it, and a corrupt stream + /// pays for the bytes decoded before the error, so referencing a stream again and again + /// spends the allowance by the work each decode does. + fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result, CosError> { + if self.bytes == 0 && !stream.raw.is_empty() { + return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); + } + if max < self.bytes { + let result = decode(stream, max); + let spent = match &result { + Ok((_, spent)) => *spent, + Err((FilterError::LimitExceeded(_), _)) => max, + Err((_, spent)) => *spent, + }; + self.bytes = self.bytes.saturating_sub(spent); + return result.map(|(data, _)| data).map_err(|(e, _)| CosError::Filter(e.to_string())); + } + match decode(stream, self.bytes) { + Ok((data, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Ok(data) + } + Err((FilterError::LimitExceeded(_), _)) => { + self.bytes = 0; + Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) + } + Err((e, spent)) => { + self.bytes = self.bytes.saturating_sub(spent); + Err(CosError::Filter(e.to_string())) + } + } + } + + /// Counts one form entry. `false` once [`DecodeBudget::new`]'s `forms` entries are spent: + /// the caller leaves that form out. + pub fn enter_form(&mut self) -> bool { + match self.forms.checked_sub(1) { + Some(left) => { + self.forms = left; + true + } + None => false, + } + } + + /// `true` the first time `r` is passed to this budget, for callers that need each + /// referenced stream only once. + pub fn first_visit(&mut self, r: ObjRef) -> bool { + self.seen.insert(r) + } + + /// Each stream `contents` names, decoded against the allowance, in order. `contents` is a + /// page's `/Contents`: one stream, or an array whose non-stream entries are left out. + /// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its + /// place, so the caller chooses between skipping it and giving up. + pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec, CosError>> { + match &*doc.resolve(contents) { + Object::Stream(s) => vec![self.decode(s)], + Object::Array(list) => list + .iter() + .filter_map(|entry| match &*doc.resolve(entry) { + Object::Stream(s) => Some(self.decode(s)), + _ => None, + }) + .collect(), + _ => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dict; + + #[test] + fn repeated_references_share_one_allowance() { + let mut doc = Document::new_empty(); + let bomb = doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]))); + let contents = Object::Array(vec![Object::Ref(bomb); 64]); + let mut budget = DecodeBudget::new(10 << 20, 1); + let decoded = budget.decode_contents(&doc, &contents); + assert_eq!(decoded.len(), 64); + assert_eq!(decoded.iter().filter(|d| d.is_ok()).count(), 10); + let total: usize = decoded.iter().flatten().map(Vec::len).sum(); + assert_eq!(total, 10 << 20); + assert!(budget.exhausted()); + // A decode past the allowance spends it, so a bomb is inflated at most once more. + let mut budget = DecodeBudget::new((1 << 20) - 1, 1); + assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err)); + assert_eq!(budget.remaining(), 0); + let mut budget = DecodeBudget::new((1 << 20) - 1, 1); + let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") }; + assert!(budget.decode_strict(stream).is_err()); + assert_eq!(budget.remaining(), 0); + + let mut budget = DecodeBudget::new(10 << 20, 1); + assert!(budget.decode_within(stream, 1000).is_err()); + assert_eq!(budget.remaining(), (10 << 20) - 1000); + assert_eq!(budget.decode_within(stream, 2 << 20).map(|d| d.len()).ok(), Some(1 << 20)); + assert_eq!(budget.remaining(), (9 << 20) - 1000); + + assert!(budget.enter_form()); + assert!(!budget.enter_form()); + assert!(budget.first_visit(bomb)); + assert!(!budget.first_visit(bomb)); + } + + /// A corrupt stream fails a strict decode after decoding part of it: each reference pays + /// for that part, so references to it can't decode without limit. + #[test] + fn failed_strict_decodes_charge_what_they_decoded() { + let whole = Stream::flate(Dict::new(), &vec![b' '; 1 << 20]); + let cut = whole.raw.len() / 2; + let corrupt = Stream::from_raw(whole.dict.clone(), whole.raw.get(..cut).unwrap_or_default().to_vec()); + let partial = corrupt.decoded().map(|d| d.len()).unwrap_or(0); + assert!(partial > 0, "a cut flate stream decodes in part"); + let mut budget = DecodeBudget::new(10 << 20, 1); + assert!(budget.decode_strict(&corrupt).is_err()); + assert_eq!(budget.remaining(), (10 << 20) - partial); + let mut budget = DecodeBudget::new(10 << 20, 1); + assert!(budget.decode_within(&corrupt, 1 << 30).is_ok()); + assert_eq!(budget.remaining(), (10 << 20) - partial); + } + + /// A chain whose later stage shrinks the data pays for every stage's output: a Flate stage + /// that inflates to hex text, which ASCIIHex then halves, charges both. + #[test] + fn decodes_charge_every_stage_of_a_chain() { + let hex: Vec = std::iter::repeat_n(b"ab".as_slice(), 1 << 19).flatten().copied().collect(); + let flate = Stream::flate(Dict::new(), &hex); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + let chained = Stream::from_raw(dict, flate.raw.to_vec()); + assert_eq!(chained.decoded().map(|d| d.len()).ok(), Some(1 << 19)); + let charged = (1 << 20) + (1 << 19); + let mut budget = DecodeBudget::new(10 << 20, 1); + assert_eq!(budget.decode(&chained).map(|d| d.len()).ok(), Some(1 << 19)); + assert_eq!(budget.remaining(), (10 << 20) - charged); + assert!(budget.decode_within(&chained, 2 << 20).is_ok()); + assert_eq!(budget.remaining(), (10 << 20) - 2 * charged); + assert!(budget.decode_strict(&chained).is_ok()); + assert_eq!(budget.remaining(), (10 << 20) - 3 * charged); + } +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 639edf582c..0d9b84b749 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -5,387 +5,47 @@ //! Edits never touch the original bytes; the writer appends them (incremental save) or rewrites //! the file (full save). +use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex, Weak}; use crate::CosError; -use crate::object::{Dict, ObjRef, Object}; -use crate::parser::{Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; +use crate::object::{Dict, MAX_DECODED, ObjRef, Object, Stream}; +use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; -thread_local! { - /// The objects being loaded on this thread, innermost last (see `Document::try_get`). - static LOADING: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; - /// Indirect `/Length` values resolved during the current outermost load: a damaged file - /// retries a parse several ways, and each retry would resolve the same lengths again - /// (exponential in the chain of streams whose lengths point at further streams). - static LENGTHS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); -} +mod objects; +mod open; +mod reader; +mod state; +mod xref; -/// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream -/// `/Length`, object streams): an object already being loaded is a cycle, and the nesting is -/// bounded as well. -struct LoadGuard; - -impl LoadGuard { - const MAX: usize = 32; - - fn enter(num: u32) -> Option { - LOADING.with(|l| { - let mut l = l.borrow_mut(); - if l.len() >= Self::MAX || l.contains(&num) { - return None; - } - l.push(num); - Some(LoadGuard) - }) - } -} - -impl Drop for LoadGuard { - fn drop(&mut self) { - let outermost = LOADING.with(|l| { - let mut l = l.borrow_mut(); - l.pop(); - l.is_empty() - }); - if outermost { - LENGTHS.with(|m| m.borrow_mut().clear()); - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum XrefEntry { - Free { next_generation: u16 }, - InFile { offset: u64, generation: u16 }, - InStream { stream: u32, index: u32 }, -} - -#[derive(Clone, Copy, Debug)] -struct XrefRange { - start: u32, - count: u32, - offset: usize, - stride: usize, -} - -#[derive(Clone, Debug)] -enum XrefSectionData { - Table, - Stream { bytes: Arc>, widths: [usize; 3] }, -} - -#[derive(Clone, Debug)] -struct XrefSection { - data: XrefSectionData, - ranges: Vec, - row_count: usize, - max_num: u32, - has_entries: bool, - /// A table subsection was read one number lower than it says ([`subsection_start`]). - renumbered: bool, -} - -#[derive(Clone, Debug)] -struct XrefRevisionSection { - primary: XrefSection, - hybrid: Option, -} - -#[derive(Clone, Debug)] -enum XrefIndex { - Compact { revisions: Vec, max_num: u32, has_entries: bool }, - Materialized(Arc>), -} - -const MAX_COMPACT_XREF_ROWS: usize = 2_000_000; -const MAX_COMPACT_XREF_BYTES: usize = 64 * 1024 * 1024; - -impl XrefSection { - fn has_any_entry(&self) -> bool { - match &self.data { - XrefSectionData::Table => self.has_entries, - XrefSectionData::Stream { bytes, widths } => { - let row_width = widths.iter().sum::(); - if row_width == 0 { - return false; - } - for range in &self.ranges { - let available = bytes.len().saturating_sub(range.offset) / row_width; - for i in 0..available.min(range.count as usize) { - let Some(offset) = i.checked_mul(row_width).and_then(|delta| range.offset.checked_add(delta)) else { continue }; - let Some(row) = bytes.get(offset..offset.saturating_add(row_width)) else { continue }; - let read_field = |from: usize, width: usize, default: u64| -> Option { - if width == 0 { - return Some(default); - } - let end = from.checked_add(width)?; - Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte))) - }; - let Some(kind) = read_field(0, widths[0], 1) else { continue }; - if kind == 0 || kind == 2 { - return true; - } - if kind == 1 && read_field(widths[0], widths[1], 0).is_some_and(|offset| offset != 0) { - return true; - } - } - } - false - } - } - } - - fn retained_bytes(&self) -> usize { - match &self.data { - XrefSectionData::Table => 0, - XrefSectionData::Stream { bytes, .. } => bytes.capacity(), - } - } - - fn lookup(&self, num: u32, file: &[u8]) -> Option { - let after = self.ranges.partition_point(|range| range.start <= num); - let range = self.ranges.get(after.checked_sub(1)?)?; - let end = u64::from(range.start).checked_add(u64::from(range.count))?; - if u64::from(num) >= end { - return None; - } - let row = usize::try_from(num.checked_sub(range.start)?).ok()?; - let offset = range.offset.checked_add(row.checked_mul(range.stride)?)?; - match &self.data { - XrefSectionData::Table => { - let mut lexer = Lexer::new(file, offset); - lexer.skip_ws(); - let object_offset = lexer.token(); - lexer.skip_ws(); - let generation = lexer.token(); - lexer.skip_ws(); - let kind = lexer.token(); - match kind { - b"n" => { - let object_offset = std::str::from_utf8(object_offset).ok()?.parse::().ok()?; - if object_offset == 0 { - return None; - } - let generation = std::str::from_utf8(generation).ok()?.parse::().unwrap_or(0).min(u16::MAX as u32) as u16; - Some(XrefEntry::InFile { offset: object_offset, generation }) - } - b"f" => { - let generation = std::str::from_utf8(generation).ok()?.parse::().unwrap_or(0).min(u16::MAX as u32) as u16; - Some(XrefEntry::Free { next_generation: generation }) - } - _ => None, - } - } - XrefSectionData::Stream { bytes, widths } => { - let row_width = widths.iter().sum::(); - let end = offset.checked_add(row_width)?; - let row = bytes.get(offset..end)?; - let field = |from: usize, width: usize, default: u64| -> Option { - if width == 0 { - return Some(default); - } - let end = from.checked_add(width)?; - Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte))) - }; - let kind = field(0, widths[0], 1)?; - let a = field(widths[0], widths[1], 0)?; - let b = field(widths[0] + widths[1], widths[2], 0)?; - let entry = match kind { - 0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 }, - 1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 }, - 2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 }, - _ => return None, - }; - (!matches!(entry, XrefEntry::InFile { offset: 0, .. })).then_some(entry) - } - } - } - - fn extend_numbers(&self, numbers: &mut Vec) { - for range in &self.ranges { - let available = match &self.data { - XrefSectionData::Table => range.count as usize, - XrefSectionData::Stream { bytes, widths } => { - let row_width = widths.iter().sum::(); - bytes.len().saturating_sub(range.offset).checked_div(row_width).unwrap_or(0) - } - }; - for i in 0..available.min(range.count as usize) { - if let Ok(i) = u32::try_from(i) - && let Some(num) = range.start.checked_add(i) - { - numbers.push(num); - } - } - } - } -} - -impl XrefRevisionSection { - fn lookup(&self, num: u32, file: &[u8]) -> Option { - let primary = self.primary.lookup(num, file); - let hybrid = self.hybrid.as_ref().and_then(|section| section.lookup(num, file)); - match primary { - None | Some(XrefEntry::Free { .. }) => hybrid.or(primary), - entry => entry, - } - } - - fn extend_numbers(&self, numbers: &mut Vec) { - self.primary.extend_numbers(numbers); - if let Some(hybrid) = &self.hybrid { - hybrid.extend_numbers(numbers); - } - } -} - -impl XrefIndex { - fn empty() -> Self { - Self::Materialized(Arc::new(BTreeMap::new())) - } - - fn lookup(&self, num: u32, file: &[u8]) -> Option { - match self { - Self::Compact { revisions, .. } => revisions.iter().find_map(|revision| revision.lookup(num, file)), - Self::Materialized(entries) => entries.get(&num).copied(), - } - } - - fn is_empty(&self) -> bool { - match self { - Self::Compact { has_entries, .. } => !has_entries, - Self::Materialized(entries) => entries.is_empty(), - } - } - - /// Drop an entry for object number 4294967295, which no incremental `/Size` can cover. - /// Only a materialized index can hold one: the compact reader requires `max_num < /Size`. - fn remove_exhausted(&mut self) -> bool { - match self { - Self::Compact { .. } => false, - Self::Materialized(entries) => Arc::make_mut(entries).remove(&u32::MAX).is_some(), - } - } - - fn max_num(&self) -> u32 { - match self { - Self::Compact { max_num, .. } => *max_num, - Self::Materialized(entries) => entries.keys().next_back().copied().unwrap_or(0), - } - } - - fn object_numbers(&self, file: &[u8]) -> Vec { - match self { - Self::Materialized(entries) => { - entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect() - } - Self::Compact { revisions, .. } => { - if let [revision] = revisions.as_slice() - && revision.hybrid.is_none() - { - let mut numbers = Vec::with_capacity(revision.primary.row_count); - revision.primary.extend_numbers(&mut numbers); - numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. }))); - return numbers; - } - let mut numbers = Vec::new(); - for revision in revisions { - revision.extend_numbers(&mut numbers); - } - numbers.sort_unstable(); - numbers.dedup(); - numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. }))); - numbers - } - } - } -} - -/// One cross-reference section of the file (one per save; the first is the original). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Revision { - /// Offset of the xref section (`startxref` value). - pub xref_offset: u64, - /// `true` if the section is a cross-reference stream. - pub is_stream: bool, - /// `true` if `xref_offset` counts from the `%PDF-` header rather than from the start of the - /// file. The two differ only in a file with bytes before its header, and an update appended - /// to such a file must count the same way as the section it points back to. - pub header_relative: bool, -} - -#[derive(Clone, Debug)] -struct ObjStm { - /// (object number, byte offset within `data`). - index: Vec<(u32, usize)>, - data: Arc>, -} - -/// Where an overlay slot came from. -#[derive(Clone, Debug, PartialEq)] -enum Slot { - Set(u16, Arc), - Freed(u16), -} - -/// An immutable input/decode context shared by document clones. Holding this identity -/// retains neither the input bytes nor the parsed document. Weak ownership prevents an -/// expired identity from matching a later allocation at the same address. -#[derive(Clone, Debug)] -pub struct SourceIdentity(Weak<()>); - -impl PartialEq for SourceIdentity { - fn eq(&self, other: &Self) -> bool { - self.0.ptr_eq(&other.0) - } -} - -impl Eq for SourceIdentity {} - -/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed -/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every -/// change makes a different one. Changed objects are compared as allocations, which the state -/// keeps alive, so a later object can't take an address one of them had. -#[derive(Clone)] -pub struct ReadState { - source: SourceIdentity, - trailer: Dict, - overlay: BTreeMap, -} - -impl ReadState { - /// Whether `doc` reads as this state. - pub fn matches(&self, doc: &Document) -> bool { - self.source == doc.source_identity() - && self.trailer == doc.trailer - && self.overlay.len() == doc.overlay.len() - && self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| { - a == b - && match (x, y) { - (Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p), - (Slot::Freed(g), Slot::Freed(h)) => g == h, - _ => false, - } - }) - } -} - -impl std::fmt::Debug for ReadState { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive() - } -} +#[cfg(test)] +pub(crate) use reader::find; +#[cfg(test)] +use reader::rfind; +pub(crate) use state::ObjectReader; +use state::{ObjStm, Slot}; +pub use state::{ReadState, SourceIdentity}; +#[cfg(test)] +use state::{SECTION_READS, objstm_member, owned_bytes}; +use xref::XrefIndex; +pub use xref::{Revision, XrefEntry}; #[derive(Clone)] pub struct Document { data: Arc>, + /// Where `endstream` occurs in `data`; see [`EndstreamIndex`]. + endstreams: Arc, source_identity: Arc<()>, entries: Arc, trailer: Dict, revisions: Arc>, /// See [`Self::xref_oddities`]. xref_oddities: Arc>, + /// Where the file's object headers are, as (offset, generation) of the last header of each + /// number. Filled only when the cross-reference data proved inconsistent; see + /// `fill_xref_gaps`. + located: Arc>, repair_log: Arc>, cache: Arc>>>, objstms: Arc>>>, @@ -408,2484 +68,11 @@ pub struct Document { out_encrypt_num: Option, } -/// A temporary reader for whole-document work. Its caches never populate the editor or -/// undo snapshots. Returned objects survive cache eviction; a stream read from the file is a -/// view into the input bytes (it keeps the whole input alive while held, not a copy). -pub(crate) struct ObjectReader { - pub(crate) document: Document, - decoded_count: usize, -} - -impl ObjectReader { - const OBJECTS: usize = 128; - const STREAM_BYTES: usize = 8 * 1024 * 1024; - - pub(crate) fn get(&mut self, reference: ObjRef) -> Arc { - self.try_get(reference).unwrap_or_else(|_| Arc::new(Object::Null)) - } - - pub(crate) fn try_get(&mut self, reference: ObjRef) -> Result, CosError> { - let object = self.document.try_get(reference.num); - // A missed eviction here only costs a re-parse, so never fail the read on a poisoned - // lock. The editing-cache evictors propagate instead; they guard user edits. - if let Ok(mut streams) = self.document.objstms.lock() { - // Only insertion changes this private cache between reads. Most neighboring - // objects reuse a decoded stream: do not sum the whole store for every object. - if streams.len() != self.decoded_count { - let bytes = streams.values().fold(0usize, |bytes, stream| { - bytes - .saturating_add(stream.data.capacity()) - .saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<(u32, usize)>())) - }); - if bytes > Self::STREAM_BYTES || streams.len() >= Self::OBJECTS { - // Retain one oversized active stream so its next object does not require - // decoding it again. That stream still obeys the document's decode cap. - let current = match self.document.xref_entry(reference.num) { - Some(XrefEntry::InStream { stream, .. }) => Some(stream), - _ => None, - }; - streams.retain(|num, _| Some(*num) == current); - } - self.decoded_count = streams.len(); - } - } - // Parsed objects stay cached (an indirect /Length or a dictionary read again costs a - // lookup, not a parse) until the cache passes its object count or holds more than - // STREAM_BYTES of data of its own. Streams read from the file are views into the - // input bytes, which the document keeps anyway, so they don't count; decrypted - // streams and strings do. Checked after failed loads too: resolving a /Length may - // have cached large objects. - if let Ok(mut cache) = self.document.cache.lock() { - let data = &self.document.data; - if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES { - cache.clear(); - } - } - object - } -} - -/// Bytes `object` holds of its own: string and stream data that is not a view into `data` -/// (the input file), nested a few levels deep (deeper data is rare and still bounded by the -/// reader's object count). -fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { - if depth > 4 { - return 0; - } - match object { - Object::String(s) => s.bytes.len(), - Object::Stream(s) => { - let own = if s.raw.shares(data) { 0 } else { s.raw.len() }; - s.dict.iter().fold(own, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))) - } - Object::Array(a) => a.iter().fold(0usize, |n, v| n.saturating_add(owned_bytes(v, data, depth + 1))), - Object::Dict(d) => d.iter().fold(0usize, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))), - _ => 0, - } -} - impl std::fmt::Debug for Document { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Document").field("bytes", &self.data.len()).field("xref", &self.entries).field("edits", &self.overlay.len()).finish() } } -/// How an encrypted document is opened: with a password, or with the file key of a document -/// that has the same encryption (an earlier revision of the same file). -enum Credentials<'a> { - Password(Option<&'a str>), - Handler { handler: &'a Arc, id0: Vec }, -} - -impl Document { - /// A new document with an empty page tree (for split / extract / combine). It has no - /// original bytes, so saving always writes a full file. - pub fn new_empty() -> Self { - let mut doc = Document { - data: Arc::new(Vec::new()), - source_identity: Arc::new(()), - entries: Arc::new(XrefIndex::empty()), - trailer: Dict::new(), - revisions: Arc::new(Vec::new()), - xref_oddities: Arc::default(), - repair_log: Arc::new(Vec::new()), - cache: Arc::default(), - objstms: Arc::default(), - stream_limit: crate::object::MAX_DECODED, - overlay: BTreeMap::new(), - next_num: 1, - header_offset: 0, - version: "1.7".into(), - security: None, - encrypt_num: None, - encryption_changed: false, - full_save: false, - out_security: None, - out_encrypt_num: None, - }; - let mut pages = Dict::new(); - pages.set(b"Type".to_vec(), Object::name("Pages")); - pages.set(b"Kids".to_vec(), Object::Array(Vec::new())); - pages.set(b"Count".to_vec(), Object::Int(0)); - let pages = doc.add(pages); - let mut catalog = Dict::new(); - catalog.set(b"Type".to_vec(), Object::name("Catalog")); - catalog.set(b"Pages".to_vec(), Object::Ref(pages)); - let root = doc.add(catalog); - doc.trailer.set(b"Root".to_vec(), Object::Ref(root)); - doc - } - - /// Parse a document. Damaged cross-reference data is reconstructed; see `repair_log`. - /// Encrypted documents open only if their user password is empty; see `open_with_password`. - pub fn open(data: Arc>) -> Result { - Self::open_with_password(data, None) - } - - /// Parse a document, authenticating encrypted ones with `password` (owner or user; `None` - /// tries the empty password). Fails with `NeedsPassword` / `WrongPassword` as appropriate. - pub fn open_with_password(data: Arc>, password: Option<&str>) -> Result { - Self::open_with_stream_limit(data, password, crate::object::MAX_DECODED) - } - - /// Open with a tighter decompression bound for object and cross-reference streams. - /// Inspection can use this without changing the editing reader's default limit. Such a - /// document is read-only: objects in a larger object stream read as missing, so the - /// writers refuse to save it ([`CosError::ReadOnlyLimit`]). - pub fn open_with_stream_limit(data: Arc>, password: Option<&str>, stream_limit: usize) -> Result { - Self::open_as(data, Credentials::Password(password), stream_limit) - } - - /// Open `data`, an earlier revision of this file (the bytes a signature covers) or another - /// save of it, with this document's credentials: an encrypted one must have the same - /// `/Encrypt` dictionary and `/ID` as this document, whose file key is then reused, so no - /// password is needed or kept; unencrypted data opens as [`open`](Self::open) does. Data - /// encrypted differently fails with `NeedsPassword`. - pub fn open_with_same_credentials(&self, data: Arc>) -> Result { - let credentials = match &self.security { - Some(handler) => Credentials::Handler { handler, id0: self.first_id() }, - None => Credentials::Password(None), - }; - Self::open_as(data, credentials, self.stream_limit) - } - - fn open_as(data: Arc>, credentials: Credentials<'_>, stream_limit: usize) -> Result { - // Viewers accept files whose header is missing or damaged as long as the body looks - // like PDF; so do we (a note goes to the repair log). - let header = find(&data, b"%PDF-", 0, 1024); - let headerless = header.is_none(); - if headerless && find(&data, b" obj", 0, 4096).is_none() { - return Err(CosError::NotPdf); - } - let header_offset = header.unwrap_or(0); - let version = match header { - Some(h) => String::from_utf8_lossy(&data[(h + 5).min(data.len())..(h + 8).min(data.len())]).trim().to_string(), - None => "1.4".to_string(), - }; - let mut doc = Document { - data, - source_identity: Arc::new(()), - entries: Arc::new(XrefIndex::empty()), - trailer: Dict::new(), - revisions: Arc::new(Vec::new()), - xref_oddities: Arc::default(), - repair_log: Arc::new(Vec::new()), - cache: Arc::default(), - objstms: Arc::default(), - stream_limit: stream_limit.min(crate::object::MAX_DECODED), - overlay: BTreeMap::new(), - next_num: 1, - header_offset, - version, - security: None, - encrypt_num: None, - encryption_changed: false, - full_save: false, - out_security: None, - out_encrypt_num: None, - }; - let mut log = Vec::new(); - if headerless { - log.push("the %PDF- header is missing; reading the file as PDF 1.4".into()); - } - let parsed = doc.read_xref_chain(&mut log); - let mut authenticated = false; - let ok = match parsed { - Ok((entries, trailer, revisions, oddities)) => { - doc.entries = Arc::new(entries); - doc.trailer = trailer; - doc.revisions = Arc::new(revisions); - doc.xref_oddities = Arc::new(oddities); - // Authenticate before checking the catalog: it may sit in an encrypted object - // stream, which reads as garbage until the key is known. - if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() { - doc.authenticate(&enc, &credentials)?; - authenticated = true; - } - doc.root_is_catalog() - } - Err(e) => { - log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers")); - false - } - }; - if !ok { - if !doc.entries.is_empty() { - log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into()); - } - doc.reconstruct(&mut log)?; - } - doc.repair_log = Arc::new(log); - // Reconstruction may have found a different trailer: (re)authenticate against it. - if (!ok || !authenticated) - && let Some(enc) = doc.trailer.get(b"Encrypt").cloned() - { - doc.authenticate(&enc, &credentials)?; - } - if Arc::make_mut(&mut doc.entries).remove_exhausted() { - Arc::make_mut(&mut doc.repair_log).push("skipped exhausted xref object number 4294967295".into()); - } - let max = doc.entries.max_num(); - let size = u32::try_from(doc.trailer.int(b"Size").unwrap_or(0).max(0)).unwrap_or(u32::MAX); - if size == u32::MAX { - Arc::make_mut(&mut doc.repair_log).push("exhausted trailer /Size; new objects use unused object numbers".into()); - } - doc.next_num = max.saturating_add(1).max(size).max(1); - Ok(doc) - } - - fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> { - // Authentication can change how the same input is decoded. - self.source_identity = Arc::new(()); - let (num, dict) = match enc { - Object::Ref(r) => (Some(r.num), self.get(*r).as_dict().cloned()), - Object::Dict(d) => (None, Some(d.clone())), - _ => (None, None), - }; - let Some(dict) = dict else { - // A dangling /Encrypt: viewers treat the file as unencrypted, and so do we. - let mut log = self.repair_log.as_ref().clone(); - log.push("the trailer names an /Encrypt dictionary that does not exist; reading the file as unencrypted".into()); - self.repair_log = Arc::new(log); - self.trailer.remove(b"Encrypt"); - return Ok(()); - }; - let params = crate::security::encrypt_dict(self, &dict); - let id0 = self.first_id(); - let handler = match credentials { - Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e { - pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, - pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, - other => CosError::Security(other.to_string()), - })?), - // The same encryption as the document whose key this is: that key opens it. - Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler), - Credentials::Handler { .. } => return Err(CosError::NeedsPassword), - }; - self.security = Some(handler); - self.encrypt_num = num; - // Objects read while locating the catalog were read before decryption was possible. - self.cache = Arc::default(); - self.objstms = Arc::default(); - Ok(()) - } - - /// The first file identifier, part of the encryption key (empty when there is none). - fn first_id(&self) -> Vec { - match self.trailer.get(b"ID") { - Some(Object::Array(a)) => a.first().and_then(|s| s.as_string()).map(|s| s.bytes.clone()).unwrap_or_default(), - _ => Vec::new(), - } - } - - /// Lift the restrictions of a document opened with its user password by entering the - /// owner (permissions) password (§7.6.4). Fails with `WrongPassword` for any other - /// password. Does nothing when the document is unencrypted or already opened as owner. - /// Both passwords unlock the same file key, so nothing read so far is decoded again. - pub fn unlock(&mut self, password: &str) -> Result<(), CosError> { - let Some(current) = self.security.as_deref() else { return Ok(()) }; - if current.auth() == pdfcraft_crypt::Auth::Owner { - return Ok(()); - } - let owner = current.authenticate_owner(&self.first_id(), password).map_err(|e| match e { - pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, - other => CosError::Security(other.to_string()), - })?; - self.security = Some(Arc::new(owner)); - Ok(()) - } - - /// The security handler, when the document is encrypted. - pub fn security(&self) -> Option<&pdfcraft_crypt::SecurityHandler> { - self.security.as_deref() - } - - /// What the opening password allows (`None` for unencrypted documents: everything). - pub fn permissions(&self) -> Option { - self.security().map(|s| s.permissions()) - } - - /// Make the next save a full rewrite: earlier revisions (which still hold what an edit - /// removed) must not stay in the file. Redaction requires this. - pub fn require_full_save(&mut self) { - self.full_save = true; - } - - /// The next save must rewrite the whole file (see [`Document::require_full_save`]). - pub fn full_save_required(&self) -> bool { - self.full_save - } - - /// Security was set or removed since the document was opened (the next save applies it). - pub fn encryption_changed(&self) -> bool { - self.encryption_changed - } - - /// The handler used to write: the new one after `set_encryption` / `remove_encryption`, - /// otherwise the one the document was opened with. - pub(crate) fn output_security(&self) -> (Option<&pdfcraft_crypt::SecurityHandler>, Option) { - if self.encryption_changed { (self.out_security.as_deref(), self.out_encrypt_num) } else { (self.security.as_deref(), self.encrypt_num) } - } - - /// The security the next save writes: protection applied with `set_encryption`, none after - /// `remove_encryption`, otherwise the security the document was opened with. - pub fn output_handler(&self) -> Option<&pdfcraft_crypt::SecurityHandler> { - self.output_security().0 - } - - /// Protect the document with a password (§7.6.4). Takes effect on the next save, which is - /// always a full rewrite. Returns the handler (authenticated as owner). - pub fn set_encryption(&mut self, params: &pdfcraft_crypt::NewEncryption) -> Result<(), CosError> { - // The file identifier is part of the key; make sure it exists and keep it. - let id0 = match self.trailer.get(b"ID") { - Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(), - _ => { - let mut id = generated_id(¶ms.seed); - id.truncate(16); - let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true }); - self.trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); - id - } - }; - let h = pdfcraft_crypt::create(params, &id0).map_err(|e| CosError::Security(e.to_string()))?; - let d = h.dict(); - let s = |b: &[u8]| Object::String(crate::PdfString { bytes: b.to_vec(), hex: true }); - let mut e = Dict::new(); - e.set(b"Filter".to_vec(), Object::name("Standard")); - e.set(b"V".to_vec(), Object::Int(d.v)); - e.set(b"R".to_vec(), Object::Int(d.r)); - e.set(b"Length".to_vec(), Object::Int(d.length_bits)); - e.set(b"O".to_vec(), s(&d.o)); - e.set(b"U".to_vec(), s(&d.u)); - e.set(b"P".to_vec(), Object::Int(i64::from(d.p))); - if d.v >= 4 { - let mut cf = Dict::new(); - let mut std_cf = Dict::new(); - std_cf.set(b"Type".to_vec(), Object::name("CryptFilter")); - let (cfm, len) = if d.v >= 5 { ("AESV3", 32) } else { ("AESV2", 16) }; - std_cf.set(b"CFM".to_vec(), Object::name(cfm)); - std_cf.set(b"Length".to_vec(), Object::Int(len)); - std_cf.set(b"AuthEvent".to_vec(), Object::name("DocOpen")); - cf.set(b"StdCF".to_vec(), Object::Dict(std_cf)); - e.set(b"CF".to_vec(), Object::Dict(cf)); - e.set(b"StmF".to_vec(), Object::name("StdCF")); - e.set(b"StrF".to_vec(), Object::name("StdCF")); - if !d.encrypt_metadata { - e.set(b"EncryptMetadata".to_vec(), Object::Bool(false)); - } - } - if d.v >= 5 { - e.set(b"OE".to_vec(), s(&d.oe)); - e.set(b"UE".to_vec(), s(&d.ue)); - e.set(b"Perms".to_vec(), s(&d.perms)); - } - if let Some(Object::Ref(old)) = self.trailer.get(b"Encrypt").cloned() { - self.free(old); - } - let r = self.add(e); - self.trailer.set(b"Encrypt".to_vec(), Object::Ref(r)); - // Objects are still read with the original handler; saves use the new one. - self.out_security = Some(Arc::new(h)); - self.out_encrypt_num = Some(r.num); - self.encryption_changed = true; - Ok(()) - } - - /// Remove password protection (requires the document to be open — any authenticated - /// password; callers should require the owner password, §7.6.4). Applied by a full save. - pub fn remove_encryption(&mut self) { - if let Some(Object::Ref(r)) = self.trailer.get(b"Encrypt").cloned() { - self.free(r); - } - self.trailer.remove(b"Encrypt"); - self.out_security = None; - self.out_encrypt_num = None; - self.encryption_changed = true; - } - - /// Decrypt a freshly parsed indirect object if the document is encrypted. - fn decrypted(&self, id: ObjRef, o: Object) -> Object { - match &self.security { - Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, true), - _ => o, - } - } - - pub fn bytes(&self) -> &Arc> { - &self.data - } - - /// Identity of the original bytes, xref and authenticated decode context. Edits and - /// clones preserve it; reopening (including after any save) produces a new identity. - pub fn source_identity(&self) -> SourceIdentity { - SourceIdentity(Arc::downgrade(&self.source_identity)) - } - - /// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it - /// changes with every edit, so a value derived from the document can be cached against it. - pub fn read_state(&self) -> ReadState { - ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() } - } - - pub fn version(&self) -> &str { - &self.version - } - - pub fn trailer(&self) -> &Dict { - &self.trailer - } - - pub fn trailer_mut(&mut self) -> &mut Dict { - &mut self.trailer - } - - pub fn revisions(&self) -> &[Revision] { - &self.revisions - } - - /// Position of the `%PDF-` header in the file: 0 unless bytes come before it. - pub fn header_offset(&self) -> usize { - self.header_offset - } - - /// There is no cross-reference chain to append to: the index was rebuilt from object - /// headers because the file's own was unusable (see [`Self::repair_log`]), or the document - /// was built from scratch ([`Self::new_empty`]). A save writes such a document in full, even - /// when nothing was edited: for a repaired file that is what puts the repair on disk. - pub fn reconstructed(&self) -> bool { - self.revisions.is_empty() - } - - /// Where each revision of the file ends (oldest first): the byte after the `%%EOF` (and its - /// end-of-line) that closes its cross-reference section. `data[..end]` is that revision as it - /// was saved. A linearized file's first-page section belongs to the revision it precedes, so - /// ends only ever increase. Empty for a reconstructed file. - pub fn revision_ends(&self) -> Vec { - let d = &self.data[..]; - let mut ends: Vec = Vec::with_capacity(self.revisions.len()); - for r in self.revisions.iter() { - let from = (r.xref_offset as usize).saturating_add(self.header_offset).min(d.len()); - let Some(at) = d[from..].windows(5).position(|w| w == b"%%EOF") else { continue }; - let mut end = from + at + 5; - while end < d.len() && matches!(d[end], b'\r' | b'\n') && end - (from + at + 5) < 2 { - end += 1; - } - match ends.last_mut() { - Some(last) if end <= *last => {} - _ => ends.push(end), - } - } - ends - } - - pub fn repair_log(&self) -> &[String] { - &self.repair_log - } - - /// Add a line to [`Self::repair_log`]: a leniency applied while reading that the user should - /// know about (fidelity: never silent). - pub fn note_repair(&mut self, line: String) { - let mut log = self.repair_log.as_ref().clone(); - log.push(line); - self.repair_log = Arc::new(log); - } - - /// `true` when there are unsaved edits. Removing an `/Encrypt` dictionary written inline in - /// the trailer (as MuPDF writes it) changes no object, and is an edit all the same. - pub fn is_modified(&self) -> bool { - !self.overlay.is_empty() || self.encryption_changed - } - - /// Object numbers changed since the document was opened (or last saved). - pub fn modified_objects(&self) -> Vec { - self.overlay.keys().copied().collect() - } - - /// Where object `num` is stored in the file (ignoring unsaved edits). - pub fn xref_entry(&self, num: u32) -> Option { - self.entries.lookup(num, &self.data) - } - - /// Cross-reference entries this reader did not take at face value, as (byte offset of the - /// section, object number): an in-use entry at offset 0, a second and different entry for - /// one object in the same section, a reserved cross-reference stream entry type, or a - /// hybrid /XRefStm entry that disagrees with the table. The reader sets such an entry aside - /// and keeps what it already had for the object. Another reader, the page renderer among - /// them, may follow it instead, so the two can then show different objects for one number. - /// Code that compares revisions (signature validation) must not call such an object - /// unchanged. - pub fn xref_oddities(&self) -> &[(u64, u32)] { - &self.xref_oddities - } - - /// Whether the cross-reference entry of `num` locates that object itself: its `num G obj` - /// header at exactly the recorded offset, or the member with that number at the recorded - /// position of its object stream. When it does not, [`Self::get`] still finds the object by - /// searching, but another reader may resolve the number differently. Ignores unsaved edits. - pub fn xref_entry_is_exact(&self, num: u32) -> bool { - match self.xref_entry(num) { - None | Some(XrefEntry::Free { .. }) => true, - Some(XrefEntry::InFile { offset, .. }) => { - let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num); - // Offsets may count from a header that does not start the file. - usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) - } - Some(XrefEntry::InStream { stream, index }) => { - self.objstm(stream).is_ok_and(|stm| usize::try_from(index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num)) - } - } - } - - /// Object `num` has an unsaved edit. - pub fn is_edited(&self, num: u32) -> bool { - self.overlay.contains_key(&num) - } - - pub fn root(&self) -> Option { - self.trailer.reference(b"Root") - } - - // ── object access ─────────────────────────────────────────────────────────────────────── - - /// Fetch an object by reference. Missing objects are `Null` (ISO 32000-2 §7.3.10). - pub fn get(&self, r: ObjRef) -> Arc { - self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null)) - } - - /// Follow a reference if `o` is one; otherwise return `o` itself. - pub fn resolve(&self, o: &Object) -> Arc { - match o { - Object::Ref(r) => self.get(*r), - other => Arc::new(other.clone()), - } - } - - /// Resolve and return a dictionary (a stream's dictionary counts). - pub fn dict(&self, o: &Object) -> Option { - self.resolve(o).as_dict().cloned() - } - - pub fn try_get(&self, num: u32) -> Result, CosError> { - match self.overlay.get(&num) { - Some(Slot::Set(_, o)) => return Ok(o.clone()), - Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)), - None => {} - } - if let Some(o) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) { - return Ok(o.clone()); - } - // Loading can re-enter (indirect stream `/Length`, object streams); damaged files make - // cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path. - let _guard = - LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?; - let obj = self.load(num, 0)?; - let obj = Arc::new(obj); - // The editing cache is bounded like the scan reader's: re-parsing a file-backed view - // is cheap, and returned objects outlive eviction. - self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone()); - self.evict_editor_cache_if_over_budget(num, &obj)?; - Ok(obj) - } - - /// Bounds for the editing cache: roomier than the scan reader's (128 objects / 8 MB) - /// so interactive editing does not thrash, but still capped so touching a large - /// document cannot pin its whole object graph. Only owned (decrypted/copied) bytes - /// count; file-backed views re-parse for free. - /// - /// Worst case per document: input bytes + up to `EDITOR_CACHE_OBJECTS` × - /// `EDITOR_LARGE_OBJECT` (1024 × 256 KB = 256 MB) of small objects between full - /// accounting passes + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. - /// The count cap always holds; byte accounting between passes is approximate. - /// Clones share these caches, so one clone's eviction drops another's hot set; - /// that only costs re-parsing, never correctness. Eviction keeps just the newcomer: - /// file-backed views re-parse cheaply and editor working sets stay far below 1024. - const EDITOR_CACHE_OBJECTS: usize = 1024; - const EDITOR_CACHE_BYTES: usize = 32 * 1024 * 1024; - /// Object streams decode to megabytes each, so fewer are kept than plain objects. - const EDITOR_OBJSTM_OBJECTS: usize = 256; - const EDITOR_OBJSTM_BYTES: usize = 32 * 1024 * 1024; - /// Newcomers above this force a full accounting pass even below the count cap: - /// `EDITOR_CACHE_BYTES / 128`, so 128 large newcomers always trigger a pass before the - /// byte budget can double. - const EDITOR_LARGE_OBJECT: usize = 256 * 1024; - - fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> { - // Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer - // below the count cap. `len` is copied out first; nothing here holds the lock. - let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len(); - if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT { - return Ok(()); - } - // Walk a snapshot off-lock; re-lock only to evict. - let live: Vec<(u32, Arc)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect(); - let data = &self.data; - let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0))); - if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES { - return Ok(()); - } - // Re-check under the final lock: another thread may have inserted or evicted since - // the snapshot. Keep its newcomers, and skip the eviction if back within budget. - let snapshot: std::collections::HashSet = live.into_iter().map(|(n, _)| n).collect(); - let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?; - if cache.len() <= Self::EDITOR_CACHE_OBJECTS { - let data = &self.data; - let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))); - if owned <= Self::EDITOR_CACHE_BYTES { - return Ok(()); - } - } - cache.retain(|n, _| *n == fresh_num || !snapshot.contains(n)); - Ok(()) - } - - fn evict_editor_streams_if_over_budget(&self, current: u32) -> Result<(), CosError> { - // The caller inserts `current` first, so retaining it keeps a live entry. - let over = { - let streams = self.objstms.lock().map_err(|_| CosError::Poisoned)?; - let bytes = streams.values().fold(0usize, |bytes, stream| { - bytes - .saturating_add(stream.data.capacity()) - .saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<(u32, usize)>())) - }); - bytes > Self::EDITOR_OBJSTM_BYTES || streams.len() > Self::EDITOR_OBJSTM_OBJECTS - }; - if over { - self.objstms.lock().map_err(|_| CosError::Poisoned)?.retain(|n, _| *n == current); - } - Ok(()) - } - - /// Visit every current object without filling this document's editing cache. - /// - /// Full-document searches (for example, standalone timestamps) need to inspect objects - /// that may never be used again. Keep their parse caches private and release them in - /// batches. Returned objects own their data and remain valid after the next batch. - pub fn scan_objects(&self) -> impl Iterator)> + use<> { - let numbers = self.object_numbers(); - let mut reader = self.object_reader(); - numbers.into_iter().map(move |num| { - let reference = ObjRef::new(num, reader.document.generation(num)); - (reference, reader.get(reference)) - }) - } - - /// Like [`Self::scan_objects`], preserving read errors for callers that must distinguish - /// incomplete discovery from an actual null object. Caches remain private and bounded. - pub fn scan_objects_checked(&self) -> impl Iterator, CosError>)> + use<> { - self.scan_objects_subset(self.object_numbers()) - } - - /// Inspect selected current objects with the same bounded cache as a whole-file scan. - /// Missing/freed numbers yield `Null`; malformed objects return their read error. - pub fn scan_objects_subset(&self, numbers: Vec) -> impl Iterator, CosError>)> + use<> { - let mut reader = self.object_reader(); - numbers.into_iter().map(move |num| { - let reference = ObjRef::new(num, reader.document.generation(num)); - (reference, reader.try_get(reference)) - }) - } - - /// Inspect selected objects in the immutable source, ignoring all unsaved edits. This - /// allows a consumer to check an edit's dependencies without retaining an old document. - pub fn scan_original_objects_subset(&self, numbers: Vec) -> impl Iterator, CosError>)> + use<> { - let mut reader = self.object_reader(); - reader.document.overlay.clear(); - numbers.into_iter().map(move |num| { - let reference = ObjRef::new(num, reader.document.generation(num)); - (reference, reader.try_get(reference)) - }) - } - - /// Opened with a tighter stream limit than editing uses ([`Document::open_with_stream_limit`]). - pub(crate) fn stream_limited(&self) -> bool { - self.stream_limit < crate::object::MAX_DECODED - } - - pub(crate) fn object_reader(&self) -> ObjectReader { - let mut document = self.clone(); - document.cache = Arc::default(); - document.objstms = Arc::default(); - ObjectReader { document, decoded_count: 0 } - } - - fn load(&self, num: u32, depth: u32) -> Result { - if depth > 16 { - return Err(CosError::Syntax { offset: 0, detail: "reference cycle while loading".into() }); - } - match self.entries.lookup(num, &self.data) { - None | Some(XrefEntry::Free { .. }) => Ok(Object::Null), - Some(XrefEntry::InFile { offset, .. }) => { - let resolve = |r: ObjRef| { - if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) { - return known; - } - let length = self.try_get(r.num).ok().and_then(|o| o.as_int()); - LENGTHS.with(|m| m.borrow_mut().insert(r.num, length)); - length - }; - let off = offset as usize; - let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) { - Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), - _ => None, - }; - // Offsets relative to a shifted header, or simply wrong: try both fixes. - at(off) - .or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) }) - .or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o))) - .ok_or(CosError::MissingObject(num)) - } - Some(XrefEntry::InStream { stream, index }) => { - let stm = self.objstm(stream)?; - let (n, off) = stm - .index - .get(index as usize) - .copied() - .or_else(|| stm.index.iter().find(|(n, _)| *n == num).copied()) - .ok_or(CosError::MissingObject(num))?; - let off = - if n == num { off } else { stm.index.iter().find(|(n, _)| *n == num).map(|(_, o)| *o).ok_or(CosError::MissingObject(num))? }; - Lexer::new(&stm.data, off).object() - } - } - } - - fn objstm(&self, num: u32) -> Result, CosError> { - if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { - return Ok(s.clone()); - } - // A file entry that cannot place the stream (one that says it is itself compressed - // inside another stream, as in #2367, or points nowhere) is tried by the stream's own - // header, as a plain object with a wrong offset is. An edited or freed stream, in the - // overlay or in a saved revision's table, is what that says it is: the file's copy - // must not come back. - let loaded = match self.try_get(num) { - Ok(o) if matches!(*o, Object::Stream(_)) => o, - _ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => { - return Err(CosError::MissingObject(num)); - } - _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?), - }; - let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; - let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; - let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize; - let data = s.decoded_within(self.stream_limit)?; - let mut lx = Lexer::new(&data, 0); - let mut index = Vec::with_capacity(n); - for _ in 0..n { - let (Some(Object::Int(onum)), Some(Object::Int(off))) = (lx.object().ok(), lx.object().ok()) else { break }; - index.push((onum.max(0) as u32, first + off.max(0) as usize)); - } - let stm = Arc::new(ObjStm { index, data: Arc::new(data) }); - // Same bound as the object cache: decoded object streams can be megabytes each. - // Insert first so the retained `num` is actually in the map. - self.objstms.lock().map_err(|_| CosError::Poisoned)?.insert(num, stm.clone()); - self.evict_editor_streams_if_over_budget(num)?; - Ok(stm) - } - - /// Last-resort lookup: scan the file for `num G obj`. - fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> { - let needle = format!("{num} "); - let data = &self.data; - let mut found = None; - let mut i = 0; - while let Some(p) = find(data, needle.as_bytes(), i, data.len()) { - i = p + 1; - if p > 0 && !is_whitespace(data[p - 1]) { - continue; - } - if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None) - && id.num == num - { - found = Some((id, o)); // keep the last (newest) definition - } - } - found - } - - // ── editing ───────────────────────────────────────────────────────────────────────────── - - /// Stream data still pointing into another document's file would keep that whole file - /// alive for as long as this edit exists: give it a buffer of its own (just its bytes). - fn adopt(&self, obj: Object) -> Object { - match obj { - Object::Stream(mut s) if s.raw.borrows_other_than(&self.data) => { - s.raw = s.raw.detached(); - Object::Stream(s) - } - other => other, - } - } - - /// Replace an object (keeping its generation). - pub fn set(&mut self, r: ObjRef, obj: impl Into) { - let obj = self.adopt(obj.into()); - self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj))); - if r.num >= self.next_num { - self.next_num = r.num.saturating_add(1); - } - } - - /// Add a new indirect object and return its reference. - pub fn add(&mut self, obj: impl Into) -> ObjRef { - let r = ObjRef::new(self.allocate_num(), 0); - let obj = self.adopt(obj.into()); - self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj))); - r - } - - /// Find a never-used number, retaining the high-water mark for incremental /Size. - /// Exhausted hints fall back to gaps without replacing source or overlay objects. - fn allocate_num(&mut self) -> u32 { - let mut num = if self.next_num == u32::MAX { 1 } else { self.next_num.max(1) }; - while self.entries.lookup(num, &self.data).is_some() || self.overlay.contains_key(&num) { - num = if num == u32::MAX - 1 { 1 } else { num + 1 }; - } - self.next_num = self.next_num.max(num.saturating_add(1)); - num - } - - /// Delete an object (its number becomes free with the next generation). - pub fn free(&mut self, r: ObjRef) { - self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); - } - - /// Record `current`'s object graph as unsaved edits of this document. - /// - /// Used when the working snapshot was restored from an earlier revision and the next - /// incremental save must still append to this file (the last saved revision). Objects - /// that already match are left untouched. Reads go through private readers so neither - /// document's editing cache is filled. - pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> { - let saved_nums = self.object_numbers(); - let current_nums = current.object_numbers(); - let mut freed = Vec::new(); - let mut changed = Vec::new(); - { - let mut saved_reader = self.object_reader(); - let mut current_reader = current.object_reader(); - for &num in &saved_nums { - if current_nums.binary_search(&num).is_err() { - freed.push(num); - } - } - for &num in ¤t_nums { - let generation = current.generation(num); - let same = saved_nums.binary_search(&num).is_ok() - && self.generation(num) == generation - && saved_reader.try_get(ObjRef::new(num, 0))? == current_reader.try_get(ObjRef::new(num, 0))?; - if !same { - changed.push(num); - } - } - } - for num in freed { - let generation = self.generation(num); - self.free(ObjRef::new(num, generation)); - } - let mut current_reader = current.object_reader(); - for num in changed { - let generation = current.generation(num); - let object = current_reader.try_get(ObjRef::new(num, 0))?; - self.set(ObjRef::new(num, generation), (*object).clone()); - } - *self.trailer_mut() = current.trailer().clone(); - Ok(()) - } - - /// Edit a dictionary (or stream dictionary) object in place. - pub fn update_dict(&mut self, r: ObjRef, f: impl FnOnce(&mut Dict)) -> Result<(), CosError> { - let mut obj = (*self.get(r)).clone(); - let d = obj.as_dict_mut().ok_or(CosError::NotADictionary(r))?; - f(d); - self.set(r, obj); - Ok(()) - } - - /// Current generation of an object number. - pub fn generation(&self, num: u32) -> u16 { - match self.overlay.get(&num) { - Some(Slot::Set(g, _)) => *g, - Some(Slot::Freed(g)) => *g, - None => match self.entries.lookup(num, &self.data) { - Some(XrefEntry::InFile { generation, .. }) => generation, - Some(XrefEntry::Free { next_generation }) => next_generation, - _ => 0, - }, - } - } - - pub(crate) fn overlay_entries(&self) -> impl Iterator>)> + '_ { - self.overlay.iter().map(|(n, s)| match s { - Slot::Set(g, o) => (*n, *g, Some(o.clone())), - Slot::Freed(g) => (*n, *g, None), - }) - } - - pub(crate) fn next_num(&self) -> u32 { - self.next_num - } - - /// All object numbers known (file and overlay), excluding freed ones. - pub fn object_numbers(&self) -> Vec { - let source_numbers = self.entries.object_numbers(&self.data); - let mut numbers = Vec::with_capacity(source_numbers.len().max(self.overlay.len())); - let mut source = source_numbers.into_iter().peekable(); - for (&num, slot) in &self.overlay { - while source.peek().is_some_and(|n| *n < num) { - if let Some(n) = source.next() { - numbers.push(n); - } - } - if source.peek().is_some_and(|n| *n == num) { - source.next(); - } - if matches!(slot, Slot::Set(..)) { - numbers.push(num); - } - } - numbers.extend(source); - numbers - } - - /// Rebase this document on bytes written by the writer (after a save): edits become part of - /// the file and the overlay is cleared, so the next incremental save appends only new edits. - pub fn reopen_after_save(&self, bytes: Arc>) -> Result { - Document::open(bytes) - } - - // ── cross-reference parsing ───────────────────────────────────────────────────────────── - - fn root_is_catalog(&self) -> bool { - let Some(r) = self.root() else { return false }; - matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages")) - } - - #[allow(clippy::type_complexity)] - fn read_xref_chain(&self, log: &mut Vec) -> Result<(XrefIndex, Dict, Vec, Vec<(u64, u32)>), CosError> { - let mut compact_log = Vec::new(); - if let Ok(Some((index, trailer, revisions))) = self.read_compact_xref_chain(&mut compact_log) { - let size = trailer.int(b"Size").unwrap_or(0).max(0); - if size > 0 && size <= i64::from(u32::MAX) && u64::from(index.max_num()) < size as u64 { - log.extend(compact_log); - // The compact reader declines any section with set-aside entries, so there are none here. - return Ok((index, trailer, revisions, Vec::new())); - } - } - let (entries, trailer, revisions, oddities) = self.read_xref_chain_materialized(log)?; - Ok((XrefIndex::Materialized(Arc::new(entries)), trailer, revisions, oddities)) - } - - fn read_compact_xref_chain(&self, log: &mut Vec) -> Result)>, CosError> { - let data = &self.data; - let tail_start = data.len().saturating_sub(4096); - let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; - let mut lx = Lexer::new(data, sx + 9); - let first = match lx.object() { - Ok(Object::Int(n)) if n >= 0 => n as u64, - _ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }), - }; - let mut sections = Vec::new(); - let mut retained_rows = 0usize; - let mut retained_bytes = 0usize; - let mut trailer: Option = None; - let mut revisions = Vec::new(); - let mut visited = HashSet::new(); - let mut next = Some(first); - while let Some(off) = next.take() { - if !visited.insert(off) || visited.len() > 10_000 { - log.push(format!("cross-reference chain loops at offset {off}; stopped")); - break; - } - let ((primary, sect_trailer, is_stream), header_relative) = match self.read_compact_section(off as usize) { - Ok(Some(v)) => (v, false), - Ok(None) => return Ok(None), - Err(e) if self.header_offset > 0 => { - log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); - match self.read_compact_section(off as usize + self.header_offset)? { - Some(v) => (v, true), - None => return Ok(None), - } - } - // An unreadable older section loses every object only it locates: the - // materialized reader logs it and the file is rebuilt from object headers (#1656). - Err(e) => return Err(e), - }; - revisions.push(Revision { xref_offset: off, is_stream, header_relative }); - if primary.renumbered { - log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); - } - let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; - let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else { - return Ok(None); - }; - retained_rows = rows; - retained_bytes = bytes; - let hybrid = if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { - match self.read_compact_section(x as usize) { - Ok(Some((section, _, _))) => { - let Some(rows) = retained_rows.checked_add(section.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { - return Ok(None); - }; - let Some(bytes) = retained_bytes.checked_add(section.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) - else { - return Ok(None); - }; - retained_rows = rows; - retained_bytes = bytes; - Some(section) - } - Ok(None) => return Ok(None), - Err(e) => { - log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")); - None - } - } - } else { - None - }; - if let Some(hybrid) = &hybrid { - // A hybrid entry that disagrees with the table is set aside by the materialized reader. - let mut numbers = Vec::new(); - hybrid.extend_numbers(&mut numbers); - for num in numbers { - let Some(entry) = hybrid.lookup(num, &self.data) else { continue }; - if primary.lookup(num, &self.data).is_some_and(|old| !matches!(old, XrefEntry::Free { .. }) && old != entry) { - return Ok(None); - } - } - } - sections.push(XrefRevisionSection { primary, hybrid }); - next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64); - if trailer.is_none() { - trailer = Some(sect_trailer); - } - } - revisions.reverse(); - let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?; - for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { - trailer.remove(k); - } - let mut max_num = 0; - let mut has_entries = false; - for revision in §ions { - max_num = max_num.max(revision.primary.max_num); - has_entries |= revision.primary.has_entries; - if let Some(hybrid) = &revision.hybrid { - max_num = max_num.max(hybrid.max_num); - has_entries |= hybrid.has_entries; - } - } - Ok(Some((XrefIndex::Compact { revisions: sections, max_num, has_entries }, trailer, revisions))) - } - - fn read_compact_section(&self, off: usize) -> Result, CosError> { - let data = &self.data; - let mut lx = Lexer::new(data, off); - if lx.eat_keyword(b"xref") { - let mut ranges = Vec::new(); - let mut layout_ok = true; - let mut max_num = 0; - let mut has_entries = false; - let mut previous_end = None; - let mut row_count = 0usize; - let mut renumbered = false; - loop { - lx.skip_ws(); - if lx.eat_keyword(b"trailer") { - let t = lx.object()?; - let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?; - if !layout_ok { - return Ok(None); - } - return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries, renumbered }, d, false))); - } - let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { - return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() }); - }; - if !valid_xref_range(start, count) || count > 10_000_000 { - return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); - } - let shifted = subsection_start(data, lx.pos, start, count); - renumbered |= shifted != start; - let start = shifted; - let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) }; - if next_rows > MAX_COMPACT_XREF_ROWS { - return Ok(None); - } - row_count = next_rows; - let mut range_offset = None; - let mut stride = None; - let mut previous_record = None; - for i in 0..count { - lx.skip_ws(); - let record = lx.pos; - if range_offset.is_none() { - range_offset = Some(record); - } - if let Some(previous) = previous_record { - let Some(delta) = record.checked_sub(previous) else { - layout_ok = false; - continue; - }; - if stride.is_some_and(|known| known != delta) { - layout_ok = false; - } - stride.get_or_insert(delta); - } - previous_record = Some(record); - let o = lx.token(); - lx.skip_ws(); - let _generation = lx.token(); - lx.skip_ws(); - let kind = lx.token(); - let num = (start + i) as u32; - let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::().ok()); - match (kind, offset) { - (b"n", Some(offset)) if offset != 0 => { - has_entries = true; - max_num = max_num.max(num); - } - (b"n", Some(_)) => { - // Set aside by the materialized reader, which records it. - if num != 0 { - return Ok(None); - } - } - (b"f", _) => { - has_entries = true; - max_num = max_num.max(num); - } - _ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }), - } - } - if count > 0 { - let range_start = start as u32; - let range_count = count as u32; - let end = u64::from(range_start) + u64::from(range_count); - if previous_end.is_some_and(|previous| u64::from(range_start) < previous) { - layout_ok = false; - } - previous_end = Some(end); - ranges.push(XrefRange { - start: range_start, - count: range_count, - offset: range_offset.unwrap_or(lx.pos), - stride: stride.unwrap_or(1), - }); - } - } - } - let (_, obj) = parse_indirect(data, off, &|_| None)?; - let Object::Stream(stream) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) }; - if stream.dict.name(b"Type") != Some(b"XRef") { - return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() }); - } - let w: Vec = stream - .dict - .get(b"W") - .and_then(Object::as_array) - .map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect()) - .unwrap_or_default(); - if w.len() < 3 { - return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() }); - } - let widths = [w[0], w[1], w[2]]; - let row = widths.iter().sum::(); - if row == 0 { - return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() }); - } - let size = stream.dict.int(b"Size").unwrap_or(0).max(0); - let index: Vec = match stream.dict.get(b"Index").and_then(Object::as_array) { - Some(a) => a.iter().filter_map(Object::as_int).collect(), - None => vec![0, size], - }; - let mut requested_rows = 0usize; - let mut previous_end = None; - for pair in index.chunks(2) { - let [start, count] = pair else { break }; - if !valid_xref_range(*start, *count) { - return Err(CosError::Syntax { offset: off, detail: "invalid xref object range".into() }); - } - let Ok(count) = usize::try_from(*count) else { return Ok(None) }; - let Some(next) = requested_rows.checked_add(count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; - if count > 0 { - let end = (*start as u64).saturating_add(count as u64); - if previous_end.is_some_and(|previous| (*start as u64) < previous) { - return Ok(None); - } - previous_end = Some(end); - } - requested_rows = next; - } - if requested_rows.checked_mul(row).is_none_or(|bytes| bytes > MAX_COMPACT_XREF_BYTES) { - return Ok(None); - } - let raw = stream.decoded_within(MAX_XREF_OBJECTS.saturating_mul(row).min(self.stream_limit).min(MAX_COMPACT_XREF_BYTES))?; - if raw.capacity() > MAX_COMPACT_XREF_BYTES { - return Ok(None); - } - let mut ranges = Vec::new(); - let mut row_start = 0usize; - let available_rows = raw.len() / row; - for pair in index.chunks(2) { - let [start, count] = pair else { break }; - let range_start = *start as u32; - let Ok(requested) = usize::try_from(*count) else { return Ok(None) }; - let range_count = requested.min(available_rows.saturating_sub(row_start)); - if range_count > 0 { - let Ok(range_count_u32) = u32::try_from(range_count) else { return Ok(None) }; - let Some(byte_offset) = row_start.checked_mul(row) else { return Ok(None) }; - ranges.push(XrefRange { start: range_start, count: range_count_u32, offset: byte_offset, stride: row }); - } - let Some(next) = row_start.checked_add(range_count) else { return Ok(None) }; - row_start = next; - } - // Rows the materialized reader sets aside (reserved types, offset 0) are left to it. - for range in &ranges { - for i in 0..range.count as usize { - let at = range.offset + i * row; - let Some(r) = raw.get(at..at + row) else { break }; - let field = |from: usize, width: usize, default: u64| -> u64 { - if width == 0 { - return default; - } - r[from..from + width].iter().fold(0u64, |acc, byte| acc << 8 | u64::from(*byte)) - }; - let kind = field(0, widths[0], 1); - let offset = field(widths[0], widths[1], 0); - if kind > 2 || (kind == 1 && offset == 0 && range.start + i as u32 != 0) { - return Ok(None); - } - } - } - let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0); - let row_count = row_start; - let section = XrefSection { - data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, - ranges, - row_count, - max_num, - has_entries: false, - renumbered: false, - }; - let has_entries = section.has_any_entry(); - let section = XrefSection { has_entries, ..section }; - Ok(Some((section, stream.dict.clone(), true))) - } - - #[allow(clippy::type_complexity)] - fn read_xref_chain_materialized( - &self, - log: &mut Vec, - ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), CosError> { - let data = &self.data; - let tail_start = data.len().saturating_sub(4096); - let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; - let mut lx = Lexer::new(data, sx + 9); - let first = match lx.object() { - Ok(Object::Int(n)) if n >= 0 => n as u64, - _ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }), - }; - let mut entries = BTreeMap::new(); - let mut trailer: Option = None; - let mut revisions = Vec::new(); - let mut oddities = Vec::new(); - let mut visited = HashSet::new(); - let mut next = Some(first); - while let Some(off) = next.take() { - if !visited.insert(off) || visited.len() > 10_000 { - log.push(format!("cross-reference chain loops at offset {off}; stopped")); - break; - } - // This section's own entries; older sections only fill what newer ones left out. - let mut sect = BTreeMap::new(); - // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). - let mut odd = Vec::new(); - let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, log) { - Ok(v) => (v, false), - Err(e) if self.header_offset > 0 => { - log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); - sect.clear(); - odd.clear(); - (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true) - } - Err(e) => { - // An unreadable older section loses every object only it locates (the page - // tree, often): rebuild from object headers, as readers do (#1656). - if !revisions.is_empty() { - log.push(format!("older cross-reference section at {off} is unreadable: {e}")); - } - return Err(e); - } - }; - revisions.push(Revision { xref_offset: off, is_stream, header_relative }); - // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the - // free entries it gives objects only a cross-reference stream can locate. - if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { - let mut stm = BTreeMap::new(); - match self.read_section(x as usize, &mut stm, &mut odd, log) { - Ok(_) => { - for (num, e) in stm { - match sect.get(&num) { - None | Some(XrefEntry::Free { .. }) => { - sect.insert(num, e); - } - // The table already places the object somewhere else. - Some(old) if *old != e => odd.push(num), - Some(_) => {} - } - } - } - Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")), - } - } - oddities.extend(odd.into_iter().map(|num| (off, num))); - for (num, e) in sect { - entries.entry(num).or_insert(e); - } - next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64); - if trailer.is_none() { - trailer = Some(sect_trailer); - } - } - revisions.reverse(); - let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?; - for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { - trailer.remove(k); - } - Ok((entries, trailer, revisions, oddities)) - } - - /// Read one section at `off`, adding entries not already known (newer sections win). `odd` - /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]). - fn read_section( - &self, - off: usize, - entries: &mut BTreeMap, - odd: &mut Vec, - log: &mut Vec, - ) -> Result<(Dict, bool), CosError> { - let data = &self.data; - let mut lx = Lexer::new(data, off); - if lx.eat_keyword(b"xref") { - loop { - lx.skip_ws(); - if lx.eat_keyword(b"trailer") { - let t = lx.object()?; - let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?; - return Ok((d, false)); - } - let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { - return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() }); - }; - if !valid_xref_range(start, count) || count > 10_000_000 { - return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); - } - let shifted = subsection_start(data, lx.pos, start, count); - if shifted != start { - log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); - } - let start = shifted; - for i in 0..count { - lx.skip_ws(); - let o = lx.token(); - lx.skip_ws(); - let g = lx.token(); - lx.skip_ws(); - let kind = lx.token(); - let num = (start + i) as u32; - let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::().ok()); - let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().ok()).unwrap_or(0).min(u16::MAX as u32) as u16; - let entry = match (kind, offset) { - (b"n", Some(offset)) => XrefEntry::InFile { offset, generation }, - (b"f", _) => XrefEntry::Free { next_generation: generation }, - _ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }), - }; - // Offset 0 "in use" entries are a known producer bug: ignore them. (Object 0 - // is never a real object, so its entry cannot mislead anyone.) - if matches!(entry, XrefEntry::InFile { offset: 0, .. }) { - if num != 0 { - odd.push(num); - } - continue; - } - if *entries.entry(num).or_insert(entry) != entry { - odd.push(num); - } - } - } - } - // Cross-reference stream. - let (_, obj) = parse_indirect(data, off, &|_| None)?; - let Object::Stream(s) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) }; - if s.dict.name(b"Type") != Some(b"XRef") { - return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() }); - } - let w: Vec = s - .dict - .get(b"W") - .and_then(Object::as_array) - .map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect()) - .unwrap_or_default(); - if w.len() < 3 { - return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() }); - } - let size = s.dict.int(b"Size").unwrap_or(0).max(0); - let index: Vec = match s.dict.get(b"Index").and_then(Object::as_array) { - Some(a) => a.iter().filter_map(Object::as_int).collect(), - None => vec![0, size], - }; - let row = w[0] + w[1] + w[2]; - if row == 0 { - return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() }); - } - // One row per object, and a document has at most 8,388,607 indirect objects (the classic - // implementation limit): decoding more than that (about 200 MB at the widest rows) is a - // decompression bomb, not cross-reference data. The section is then reconstructed. - let raw = s.decoded_within(MAX_XREF_OBJECTS.saturating_mul(row).min(self.stream_limit))?; - let field = |r: &[u8], from: usize, len: usize, default: u64| -> u64 { - if len == 0 { - return default; - } - r[from..from + len].iter().fold(0u64, |acc, b| acc << 8 | *b as u64) - }; - let mut rows = raw.chunks_exact(row); - for pair in index.chunks(2) { - let [start, count] = pair else { break }; - if !valid_xref_range(*start, *count) { - return Err(CosError::Syntax { offset: off, detail: "invalid xref object range".into() }); - } - for i in 0..*count { - let Some(r) = rows.next() else { break }; - let t = field(r, 0, w[0], 1); - let a = field(r, w[0], w[1], 0); - let b = field(r, w[0] + w[1], w[2], 0); - let num = (*start + i) as u32; - let entry = match t { - 0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 }, - 1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 }, - 2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 }, - // Reserved types are treated as null references (§7.5.8.3). - _ => { - odd.push(num); - continue; - } - }; - if matches!(entry, XrefEntry::InFile { offset: 0, .. }) { - if num != 0 { - odd.push(num); - } - continue; - } - if *entries.entry(num).or_insert(entry) != entry { - odd.push(num); - } - } - } - Ok((s.dict.clone(), true)) - } - - /// Rebuild the cross-reference index by scanning for `N G obj` headers (§7.5.4 recovery). - fn reconstruct(&mut self, log: &mut Vec) -> Result<(), CosError> { - let data = self.data.clone(); - let mut entries: BTreeMap = BTreeMap::new(); - let mut i = 0; - let mut objstms = Vec::new(); - while i < data.len() { - // Object headers start at a line start (or after whitespace). - let at_start = i == 0 || is_whitespace(data[i - 1]) || data[i - 1] == b'>'; - if at_start && data[i].is_ascii_digit() { - let mut lx = Lexer::new(&data, i); - let n = lx.token(); - lx.skip_ws(); - let g = lx.token(); - if !n.is_empty() && !g.is_empty() && n.iter().all(u8::is_ascii_digit) && g.iter().all(u8::is_ascii_digit) && lx.eat_keyword(b"obj") { - let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::().ok()); - let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().ok()).unwrap_or(0); - if let Some(num) = num { - entries.insert(num, XrefEntry::InFile { offset: i as u64, generation }); - objstms.push(num); - } - i = lx.pos; - continue; - } - } - i += 1; - } - if entries.is_empty() { - return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() }); - } - self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()))); - self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); - // Objects inside object streams. - for num in objstms { - if let Ok(o) = self.try_get(num) - && let Object::Stream(s) = &*o - && s.dict.name(b"Type") == Some(b"ObjStm") - && let Ok(stm) = self.objstm(num) - { - for (idx, (inner, _)) in stm.index.iter().enumerate() { - entries.entry(*inner).or_insert(XrefEntry::InStream { stream: num, index: idx as u32 }); - } - } - } - let object_count = entries.len(); - self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries))); - self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); - // Trailer: the last `trailer` dictionary with a usable /Root, else the catalog itself. - let mut trailer = None; - let mut from = 0; - while let Some(p) = find(&data, b"trailer", from, data.len()) { - from = p + 7; - let mut lx = Lexer::new(&data, from); - if let Ok(Object::Dict(d)) = lx.object() - && d.reference(b"Root").is_some_and(|r| matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(_)))) - { - trailer = Some(d); - } - } - let trailer = match trailer { - Some(t) => t, - None => { - let catalog = self - .entries - .object_numbers(&self.data) - .into_iter() - .find(|n| matches!(self.try_get(*n).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog"))); - let Some(c) = catalog else { return Err(CosError::Syntax { offset: 0, detail: "no document catalog found".into() }) }; - let mut t = Dict::new(); - t.set(b"Root".to_vec(), Object::Ref(ObjRef::new(c, self.generation(c)))); - t - } - }; - let mut trailer = trailer; - for k in [&b"Prev"[..], b"XRefStm"] { - trailer.remove(k); - } - self.trailer = trailer; - self.revisions = Arc::new(Vec::new()); - log.push(format!("rebuilt cross-reference index from {object_count} object headers")); - Ok(()) - } -} - -pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option { - let to = to.min(hay.len()); - if from >= to || needle.is_empty() || to - from < needle.len() { - return None; - } - hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from) -} - -fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option { - if hay.len() < needle.len() { - return None; - } - (from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle) -} - -/// A file identifier derived from entropy supplied by the caller. -fn generated_id(seed: &[u8; 32]) -> Vec { - use std::hash::{Hash, Hasher}; - let mut out = Vec::new(); - for i in 0..2u8 { - let mut h = std::collections::hash_map::DefaultHasher::new(); - (seed, i, b"pdfcraft id").hash(&mut h); - out.extend_from_slice(&h.finish().to_be_bytes()); - } - out -} - -/// The object number a classic xref subsection really starts at. A common producer bug heads the -/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so -/// every row would be read one number too high and object 1 as free. Such a subsection is read -/// from 0 when its second row, read that way, locates an object that is indeed numbered 1. `rows` -/// is the position just after the subsection header. -fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 { - if start != 1 || count < 2 { - return start; - } - let mut lx = Lexer::new(data, rows); - let mut row = || { - lx.skip_ws(); - let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); - lx.skip_ws(); - let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); - lx.skip_ws(); - (offset, generation, lx.token() == b"f") - }; - let (Some(0), Some(65535), true) = row() else { return start }; - let (Some(second), _, false) = row() else { return start }; - let Some(at) = usize::try_from(second).ok().filter(|at| *at < data.len()) else { return start }; - let mut obj = Lexer::new(data, at); - match (obj.object(), obj.object()) { - (Ok(Object::Int(1)), Ok(Object::Int(_))) if obj.eat_keyword(b"obj") => 0, - _ => start, - } -} - -/// Object numbers must fit the representation used by references and the xref map. -/// Validate the whole subsection before adding entries, so neither arithmetic overflow nor -/// truncation can turn a damaged range into entries for unrelated objects. -fn valid_xref_range(start: i64, count: i64) -> bool { - u32::try_from(start).is_ok() && count >= 0 && start.checked_add(count).is_some_and(|end| end <= i64::from(u32::MAX) + 1) -} - -/// The most indirect objects a document can have (the classic implementation limit, ISO -/// 32000-1 Annex C): it bounds how much data a cross-reference stream can hold. -const MAX_XREF_OBJECTS: usize = 8_388_607; - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_read_state_matches_a_clone_and_no_changed_document() { - let mut doc = Document::new_empty(); - let a = doc.add(Object::Int(1)); - let state = doc.read_state(); - let snapshot = doc.clone(); - assert!(state.matches(&doc) && state.matches(&snapshot)); - // The same value set again is still a change: another allocation. - doc.set(a, Object::Int(1)); - assert!(!state.matches(&doc) && state.matches(&snapshot)); - let edited = doc.read_state(); - doc.free(a); - assert!(!edited.matches(&doc)); - let mut trailer_changed = snapshot.clone(); - trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null); - assert!(!state.matches(&trailer_changed)); - // Another document's state never matches, even with the same changes. - let mut other = Document::new_empty(); - other.add(Object::Int(1)); - assert!(!other.read_state().matches(&snapshot)); - } - - /// Build a classic-xref file from object bodies (object i+1 = bodies[i]). - pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec { - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, b) in bodies.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size {} {trailer} >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); - out - } - - fn assert_compact_matches_materialized(doc: &Document) { - assert!(matches!(doc.entries.as_ref(), XrefIndex::Compact { .. }), "valid xrefs should not retain a per-entry map"); - let (entries, _, revisions, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); - assert_eq!(revisions.as_slice(), doc.revisions()); - let max = entries.keys().next_back().copied().unwrap_or(0); - assert_eq!(doc.entries.max_num(), max); - for num in 0..=max { - assert_eq!(doc.xref_entry(num), entries.get(&num).copied(), "object {num}"); - } - let expected: Vec<_> = entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect(); - assert_eq!(doc.object_numbers(), expected); - } - - #[test] - fn compact_xref_tables_streams_and_revisions_match_the_materialized_reader() { - let classic = Document::open(Arc::new(build( - &["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"], - "/Root 1 0 R", - ))) - .unwrap(); - assert_compact_matches_materialized(&classic); - - let mut sparse = b"%PDF-1.7\n".to_vec(); - let root_offset = sparse.len(); - sparse.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n"); - let pages_offset = sparse.len(); - sparse.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); - let xref = sparse.len(); - sparse.extend_from_slice( - format!( - "xref\n0 1\n0000000000 65535 f \n1 1\n{root_offset:010} 00000 n \n5 1\n{pages_offset:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n" - ) - .as_bytes(), - ); - let sparse = Document::open(Arc::new(sparse)).unwrap(); - assert_eq!(sparse.object_numbers(), vec![1, 5]); - assert_compact_matches_materialized(&sparse); - - let mut sparse_stream = b"%PDF-1.7\n".to_vec(); - let mut offsets = [0u32; 8]; - offsets[1] = sparse_stream.len() as u32; - sparse_stream.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n"); - offsets[5] = sparse_stream.len() as u32; - sparse_stream.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); - offsets[6] = sparse_stream.len() as u32; - sparse_stream.extend_from_slice(b"6 0 obj\n<< /Unused true >>\nendobj\n"); - offsets[7] = sparse_stream.len() as u32; - let xref_row = |kind: u8, offset: u32, generation: u16| { - let mut row = Vec::with_capacity(7); - row.push(kind); - row.extend_from_slice(&offset.to_be_bytes()); - row.extend_from_slice(&generation.to_be_bytes()); - row - }; - let mut rows = Vec::new(); - rows.extend(xref_row(0, 0, u16::MAX)); - rows.extend(xref_row(1, offsets[1], 0)); - rows.extend(xref_row(1, offsets[5], 0)); - rows.extend(xref_row(1, offsets[6], 0)); - rows.extend(xref_row(1, offsets[7], 0)); - sparse_stream.extend_from_slice( - format!("7 0 obj\n<< /Type /XRef /Size 8 /Root 1 0 R /W [1 4 2] /Index [0 2 5 3] /Length {} >>\nstream\n", rows.len()).as_bytes(), - ); - sparse_stream.extend_from_slice(&rows); - sparse_stream.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{}\n%%EOF\n", offsets[7]).as_bytes()); - let sparse_stream = Document::open(Arc::new(sparse_stream)).unwrap(); - assert_eq!(sparse_stream.object_numbers(), vec![1, 5, 6, 7]); - assert_compact_matches_materialized(&sparse_stream); - - let stream_bytes = crate::write_full(&classic, &crate::SaveOptions::default()).unwrap(); - let stream = Document::open(Arc::new(stream_bytes)).unwrap(); - assert!(stream.revisions().last().is_some_and(|revision| revision.is_stream)); - assert_compact_matches_materialized(&stream); - - let mut edited = classic.clone(); - edited.update_dict(ObjRef::new(1, 0), |dict| dict.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap(); - edited.free(ObjRef::new(3, 0)); - let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); - let updated = Document::open(Arc::new(incremental)).unwrap(); - assert_eq!(updated.revisions().len(), 2); - assert_compact_matches_materialized(&updated); - } - - #[test] - fn malformed_size_uses_the_materialized_compatibility_path() { - let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"], "/Root 1 0 R"); - let marker = b"/Size 4"; - let at = bytes.windows(marker.len()).position(|window| window == marker).unwrap(); - bytes[at..at + marker.len()].copy_from_slice(b"/Size 2"); - let doc = Document::open(Arc::new(bytes)).unwrap(); - assert!(matches!(doc.entries.as_ref(), XrefIndex::Materialized(_))); - assert_eq!(doc.next_num, 4); - assert_eq!(doc.object_numbers(), vec![1, 2, 3]); - } - - /// Entries the reader sets aside are remembered, so a caller comparing revisions can tell - /// that another reader may resolve the object differently. - #[test] - fn cross_reference_entries_that_are_set_aside_are_recorded() { - let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R"); - let plain = Document::open(Arc::new(base.clone())).unwrap(); - assert!(plain.xref_oddities().is_empty()); - assert!((1..=3).all(|n| plain.xref_entry_is_exact(n))); - let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap(); - let third = plain.xref_entry(3).unwrap(); - - // A later section: object 3 "in use" at offset 0, object 2 listed twice with different - // offsets, and object 0 at offset 0 (the usual producer slip, which names no object). - let mut bytes = base.clone(); - let section = bytes.len(); - bytes.extend_from_slice( - format!("xref\n0 1\n0000000000 65535 n \n3 1\n0000000000 00000 n \n2 1\n0000000058 00000 n \n2 1\n0000000009 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(), - ); - let doc = Document::open(Arc::new(bytes)).unwrap(); - assert_eq!(doc.xref_oddities(), [(section as u64, 3), (section as u64, 2)]); - // What the reader resolves is what it resolved before: the first entry it kept. - assert_eq!(doc.xref_entry(3), Some(third)); - assert_eq!(doc.xref_entry(2), Some(XrefEntry::InFile { offset: 58, generation: 0 })); - } - - /// An entry that misses its object still resolves (by searching) but is not exact. - #[test] - fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() { - let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R"); - let text = String::from_utf8_lossy(&base).into_owned(); - let at = text.find("3 0 obj").unwrap(); - let bytes = text.replacen(&format!("{at:010} 00000 n"), &format!("{:010} 00000 n", at + 2), 1).into_bytes(); - let doc = Document::open(Arc::new(bytes)).unwrap(); - assert_eq!(doc.get(ObjRef { num: 3, generation: 0 }).as_string().map(|s| s.bytes.clone()), Some(b"three".to_vec())); - assert!(!doc.xref_entry_is_exact(3)); - assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); - } - - /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered - /// `1 n` instead of `0 n` (#2676): every row is one number off. - fn misnumbered_table(start: u32) -> Vec { - let bodies = ["<< /Marker (first object) >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Type /Catalog /Pages 2 0 R >>"]; - let mut out = b"%PDF-1.7 -" - .to_vec(); - let mut offsets = Vec::new(); - for (i, body) in bodies.iter().enumerate() { - offsets.push(out.len()); - out.extend_from_slice( - format!( - "{} 0 obj -{body} -endobj -", - i + 1 - ) - .as_bytes(), - ); - } - let xref = out.len(); - out.extend_from_slice(format!("xref\n{start} 4\n0000000000 65535 f \n").as_bytes()); - for o in offsets { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); - out - } - - #[test] - fn a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0() { - let doc = Document::open(Arc::new(misnumbered_table(1))).unwrap(); - let first = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("object 1 is not free"); - assert!(first.contains(b"Marker")); - assert_eq!(doc.root(), Some(ObjRef::new(3, 0))); - assert!(doc.repair_log().iter().any(|l| l.contains("numbered from 1")), "{:?}", doc.repair_log()); - // A correct table reads the same and logs nothing. - let fine = Document::open(Arc::new(misnumbered_table(0))).unwrap(); - assert!(fine.get(ObjRef::new(1, 0)).as_dict().is_some_and(|d| d.contains(b"Marker"))); - assert!(!fine.repair_log().iter().any(|l| l.contains("numbered from 1"))); - } - - #[test] - fn a_table_that_really_starts_at_1_keeps_its_numbers() { - // Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its - // second row locates object 2, so nothing is shifted. - let mut bytes = b"%PDF-1.7\n".to_vec(); - let pages = bytes.len(); - bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); - let catalog = bytes.len(); - bytes.extend_from_slice(b"3 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); - let xref = bytes.len(); - bytes.extend_from_slice( - format!( - "xref\n0 1\n0000000000 65535 f \n1 3\n0000000000 65535 f \n{pages:010} 00000 n \n{catalog:010} 00000 n \ntrailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n" - ) - .as_bytes(), - ); - let doc = Document::open(Arc::new(bytes)).unwrap(); - assert!(doc.get(ObjRef::new(2, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Pages"))); - assert!(doc.get(ObjRef::new(1, 0)).as_dict().is_none(), "object 1 stays free"); - assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1"))); - } - - #[test] - fn opening_an_exhausted_classic_xref_repairs_and_allocates() { - // The extra in-use row need not be loaded to exhaust the next object number. - let bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n4294967295 1\n0000000058 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n110\n%%EOF\n"; - assert_eq!(bytes.len(), 266); - let mut doc = Document::open(Arc::new(bytes.to_vec())).unwrap(); - assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); - assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref"))); - assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); - } - - #[test] - fn opening_an_exhausted_xref_stream_repairs_and_allocates() { - let mut bytes = - b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n".to_vec(); - let offset = bytes.len(); - bytes.extend_from_slice(b"3 0 obj\n<< /Type /XRef /Size 4 /Root 1 0 R /W [1 4 2] /Index [0 3 4294967295 1] /Length 28 >>\nstream\n"); - for (kind, position, generation) in [(0_u8, 0_u32, 65535_u16), (1, 9, 0), (1, 58, 0), (1, 58, 0)] { - bytes.push(kind); - bytes.extend_from_slice(&position.to_be_bytes()); - bytes.extend_from_slice(&generation.to_be_bytes()); - } - bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); - let mut doc = Document::open(Arc::new(bytes)).unwrap(); - assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); - assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref"))); - assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0)); - } - - #[test] - fn exhausted_size_and_high_edits_allocate_without_overwriting_objects() { - let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); - for size in ["4294967295", "4294967296", "9223372036854775807"] { - let bytes = String::from_utf8(ordinary.clone()).unwrap().replace("/Size 3", &format!("/Size {size}")); - let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); - assert!(doc.repair_log().iter().any(|line| line.contains("exhausted trailer"))); - assert_eq!(doc.add(Object::Int(42)), ObjRef::new(3, 0)); - doc.set(ObjRef::new(u32::MAX, 0), Object::Int(99)); - doc.set(ObjRef::new(u32::MAX - 1, 0), Object::Int(98)); - assert_eq!(doc.add(Object::Int(43)), ObjRef::new(4, 0)); - assert_eq!(doc.get(ObjRef::new(3, 0)).as_int(), Some(42)); - assert_eq!(doc.get(ObjRef::new(u32::MAX, 0)).as_int(), Some(99)); - assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice())); - } - let mut doc = Document::new_empty(); - doc.set(ObjRef::new(u32::MAX - 2, 0), Object::Null); - assert_eq!(doc.add(Object::Null), ObjRef::new(u32::MAX - 1, 0)); - assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); - assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0)); - } - - #[test] - fn exhausted_xref_controls_preserve_ordinary_ignored_and_repaired_entries() { - let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); - for (extra, repaired) in - [("", false), ("4294967295 1\n0000000000 00000 n \n", false), ("4294967295 2\n0000000058 00000 n \n0000000058 00000 n \n", true)] - { - let bytes = String::from_utf8(ordinary.clone()).unwrap().replacen("trailer\n", &format!("{extra}trailer\n"), 1); - let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); - assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); - assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice())); - assert_eq!(!doc.repair_log().is_empty(), repaired); - if repaired { - assert!(doc.repair_log().iter().any(|line| line.contains("xref") && line.contains("range"))); - } - assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); - } - } - - #[test] - fn invalid_xref_object_ranges_are_reconstructed() { - // Original in-memory PDF: an intact catalog followed by a damaged xref section. - for stream in [false, true] { - for (start, count) in [(i64::MAX, 2), (-1, 2), (4_294_967_296, 1), (4_294_967_295, 2)] { - let mut bytes = - b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n".to_vec(); - let offset = bytes.len(); - if stream { - bytes.extend_from_slice( - format!("3 0 obj\n<< /Type /XRef /Root 1 0 R /Size 4 /W [1 1 1] /Index [{start} {count}] /Length 6 >>\nstream\n").as_bytes(), - ); - bytes.extend_from_slice(&[0, 0, 0, 0, 0, 0]); - bytes.extend_from_slice(b"\nendstream\nendobj\n"); - } else { - bytes.extend_from_slice( - format!("xref\n{start} {count}\n0000000000 65535 f \n0000000000 65535 f \ntrailer\n<< /Root 1 0 R /Size 3 >>\n").as_bytes(), - ); - } - bytes.extend_from_slice(format!("startxref\n{offset}\n%%EOF\n").as_bytes()); - let doc = Document::open(Arc::new(bytes)).unwrap(); - assert!( - doc.repair_log().iter().any(|line| line.contains("xref") && line.contains("range")), - "stream={stream}, start={start}, count={count}: {:?}", - doc.repair_log() - ); - assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice())); - } - } - } - - #[test] - fn objects_only_an_unreadable_older_section_locates_are_found_by_scanning() { - // The newest section lists the catalog only; its /Prev points past the end of the file, - // so the page tree is reachable only by scanning object headers, as readers do (#1656). - let mut bytes = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (num, body) in - [(1, "<< /Type /Catalog /Pages 2 0 R >>"), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), (3, "<< /Type /Page /Parent 2 0 R >>")] - { - offsets.push(bytes.len()); - bytes.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); - } - let xref = bytes.len(); - bytes.extend_from_slice( - format!( - "xref\n0 2\n0000000000 65535 f \n{:010} 00000 n \ntrailer\n<< /Root 1 0 R /Size 4 /Prev 999999 >>\nstartxref\n{xref}\n%%EOF\n", - offsets[0] - ) - .as_bytes(), - ); - let doc = Document::open(Arc::new(bytes)).unwrap(); - assert_eq!(doc.get(ObjRef::new(2, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Pages".as_slice())); - assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Page".as_slice())); - assert!(doc.repair_log().iter().any(|l| l.contains("999999")), "{:?}", doc.repair_log()); - } - - #[test] - fn self_referential_length_does_not_overflow() { - let bytes = build( - &["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Length 3 0 R >>\nstream\nabc\nendstream"], - "/Root 1 0 R", - ); - let doc = Document::open(Arc::new(bytes)).unwrap(); - // Either recovered via the endstream search or reported — but never a stack overflow. - let o = doc.get(ObjRef::new(3, 0)); - if let Object::Stream(s) = o.as_ref() { - assert_eq!(&s.raw[..], b"abc"); - } - } - - #[test] - fn mutually_referential_lengths_do_not_overflow() { - let bytes = build( - &[ - "<< /Type /Catalog /Pages 2 0 R >>", - "<< /Type /Pages /Kids [] /Count 0 >>", - "<< /Length 4 0 R >>\nstream\nabc\nendstream", - "<< /Length 3 0 R >>\nstream\nxyz\nendstream", - ], - "/Root 1 0 R", - ); - let doc = Document::open(Arc::new(bytes)).unwrap(); - let _ = doc.get(ObjRef::new(3, 0)); - let _ = doc.get(ObjRef::new(4, 0)); - } - - #[test] - fn reads_classic_revision_and_edits_are_copy_on_write() { - let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); - let doc = Document::open(Arc::new(bytes)).unwrap(); - assert_eq!(doc.revisions().len(), 1); - assert!(doc.repair_log().is_empty()); - let mut edited = doc.clone(); - edited.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap(); - assert!(edited.is_modified() && !doc.is_modified(), "clone is an independent snapshot"); - assert!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); - } - - #[test] - fn object_numbers_merge_source_and_overlay_in_order() { - let mut doc = Document::new_empty(); - doc.overlay.clear(); - doc.entries = Arc::new(XrefIndex::Materialized(Arc::new(BTreeMap::from([ - (0, XrefEntry::Free { next_generation: 65535 }), - (2, XrefEntry::InFile { offset: 10, generation: 7 }), - (4, XrefEntry::Free { next_generation: 9 }), - (6, XrefEntry::InStream { stream: 80, index: 1 }), - (8, XrefEntry::InFile { offset: 20, generation: 5 }), - (10, XrefEntry::InFile { offset: 30, generation: 3 }), - (u32::MAX, XrefEntry::InFile { offset: 40, generation: 1 }), - ])))); - doc.set(ObjRef::new(1, 2), Object::Int(1)); // before the first live source slot - doc.set(ObjRef::new(2, 8), Object::Int(2)); // override, without duplication - doc.set(ObjRef::new(4, 9), Object::Int(4)); // reuse a source free slot - doc.free(ObjRef::new(6, 0)); // remove a compressed source object - doc.free(ObjRef::new(7, 2)); // free a number absent from the source - doc.free(ObjRef::new(10, 3)); - doc.set(ObjRef::new(11, 4), Object::Int(11)); - assert_eq!(doc.object_numbers(), vec![1, 2, 4, 8, 11, u32::MAX]); - assert_eq!(doc.generation(2), 8); - assert_eq!(doc.generation(4), 9); - assert_eq!(doc.generation(6), 1); - assert_eq!(doc.generation(8), 5); - assert_eq!(doc.generation(10), 4); - doc.overlay.clear(); - assert_eq!(doc.object_numbers(), vec![2, 6, 8, 10, u32::MAX]); - doc.entries = Arc::new(XrefIndex::empty()); - assert!(doc.object_numbers().is_empty()); - doc.set(ObjRef::new(5, 7), Object::Int(5)); - assert_eq!(doc.object_numbers(), vec![5]); - } - - #[test] - fn full_object_scans_preserve_edits_without_retaining_the_scan() { - let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_owned(), "<< /Type /Pages /Kids [] /Count 0 >>".to_owned()]; - bodies.extend((0..400).map(|i| format!("<< /Value {i} >>"))); - let refs: Vec<_> = bodies.iter().map(String::as_str).collect(); - let mut doc = Document::open(Arc::new(build(&refs, "/Root 1 0 R"))).unwrap(); - doc.set(ObjRef::new(3, 0), Object::Int(900)); - doc.free(ObjRef::new(4, 0)); - let added = doc.add(Object::Int(901)); - let cached = doc.cache.lock().unwrap().len(); - let held: Vec<_> = doc.scan_objects().collect(); - assert_eq!(held.len(), 402); - assert_eq!(doc.cache.lock().unwrap().len(), cached, "scanning must not populate the editor cache"); - assert!(doc.objstms.lock().unwrap().is_empty()); - assert!(!held.iter().any(|(r, _)| r.num == 4)); - assert_eq!(held.iter().find(|(r, _)| r.num == 3).unwrap().1.as_int(), Some(900)); - assert_eq!(held.iter().find(|(r, _)| *r == added).unwrap().1.as_int(), Some(901)); - assert_eq!(held.iter().find(|(r, _)| r.num == 400).unwrap().1.as_dict().unwrap().int(b"Value"), Some(397)); - // Previously returned objects survive cache eviction and dropping the source. - drop(doc); - assert_eq!(held.iter().find(|(r, _)| r.num == 128).unwrap().1.as_dict().unwrap().int(b"Value"), Some(125)); - } - - #[test] - fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() { - // A stream read from the file is a view into the input: caching it costs no copy, so - // even a large one stays cached (its /Length object too) instead of being re-parsed. - let body = "x".repeat(ObjectReader::STREAM_BYTES + 1); - let stream = format!("<< /Length 4 0 R >>\nstream\n{body}\nendstream"); - let length = body.len().to_string(); - let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream, &length], "/Root 1 0 R"); - let doc = Document::open(Arc::new(bytes)).unwrap(); - let mut reader = doc.object_reader(); - let read = reader.get(ObjRef::new(3, 0)); - let Object::Stream(s) = &*read else { panic!("stream") }; - assert!(s.raw.shares(&reader.document.data), "no copy of the file's bytes"); - assert!(reader.document.cache.lock().unwrap().contains_key(&3), "a view costs nothing to keep"); - // Data of its own (decrypted streams, strings) counts: past the budget the cache is - // cleared after the next read, including a failed one. - let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1])); - reader.document.cache.lock().unwrap().insert(3, Arc::new(owned)); - assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null); - assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted"); - } - - #[test] - fn temporary_reader_and_full_save_do_not_fill_snapshot_caches() { - let mut source = Document::new_empty(); - let objects: Vec<_> = (0..260) - .map(|i| { - let mut value = vec![b'x'; 48 * 1024]; - value[..4].copy_from_slice(&(i as u32).to_le_bytes()); - Object::Ref(source.add(Object::String(crate::PdfString::literal(value)))) - }) - .collect(); - source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); - let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); - let doc = Document::open(bytes).unwrap(); - let root = doc.get(doc.root().unwrap()); - let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap(); - let snapshot = doc.clone(); - let cached = doc.cache.lock().unwrap().len(); - let streams = doc.objstms.lock().unwrap().len(); - let mut reader = doc.object_reader(); - let mut held = None; - for (i, reference) in references.iter().enumerate() { - let Object::Ref(reference) = reference else { panic!("indirect object") }; - let object = reader.get(*reference); - assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes()); - if i == 0 { - held = Some(object); - } - assert!(reader.document.cache.lock().unwrap().len() < ObjectReader::OBJECTS); - let decoded = reader.document.objstms.lock().unwrap(); - assert!(decoded.len() < ObjectReader::OBJECTS); - let bytes: usize = decoded.values().map(|s| s.data.capacity() + s.index.capacity() * std::mem::size_of::<(u32, usize)>()).sum(); - assert!(bytes <= ObjectReader::STREAM_BYTES || decoded.len() == 1, "only one oversized stream may remain"); - } - drop(reader); - assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes(), "owned results survive evictions"); - for object_streams in [false, true] { - let saved = crate::write_full(&doc, &crate::SaveOptions { object_streams, ..Default::default() }).unwrap(); - assert!(Document::open(Arc::new(saved)).unwrap().repair_log().is_empty()); - assert_eq!(doc.cache.lock().unwrap().len(), cached, "full saves must not fill the editor cache"); - assert_eq!(doc.objstms.lock().unwrap().len(), streams); - assert!(Arc::ptr_eq(&doc.cache, &snapshot.cache), "do not detach or clear caller snapshots"); - } - } - - #[test] - fn editor_cache_stays_bounded() { - let mut source = Document::new_empty(); - let objects: Vec<_> = (0..(Document::EDITOR_CACHE_OBJECTS + 64)) - .map(|i| { - let mut value = vec![b'x'; 1024]; - value[..4].copy_from_slice(&(i as u32).to_le_bytes()); - Object::Ref(source.add(Object::String(crate::PdfString::literal(value)))) - }) - .collect(); - source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); - let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); - let doc = Document::open(bytes).unwrap(); - let root = doc.get(doc.root().unwrap()); - let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap().clone(); - let mut held = None; - for (i, reference) in references.iter().enumerate() { - let Object::Ref(reference) = reference else { panic!("indirect object") }; - let object = doc.get(*reference); - assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes()); - if i == 0 { - held = Some(object); - } - assert!(doc.cache.lock().unwrap().len() <= Document::EDITOR_CACHE_OBJECTS, "editing must not pin the whole object graph"); - // Eviction keeps the newcomer. - assert!(doc.cache.lock().unwrap().contains_key(&reference.num), "eviction must keep the newcomer"); - } - // Returned objects survive eviction, through both handles of the shared cache. - assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes()); - let snapshot = doc.clone(); - let first = references[0].as_ref().unwrap(); - assert_eq!(&snapshot.get(first).as_string().unwrap().bytes[..4], &0u32.to_le_bytes()); - } - - #[test] - fn editor_cache_byte_bound_fires() { - // 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget. Each - // newcomer exceeds EDITOR_LARGE_OBJECT, so every insert takes a full accounting pass. - let mut source = Document::new_empty(); - let objects: Vec<_> = (0..40) - .map(|i| { - let mut value = vec![b'y'; 1024 * 1024]; - value[..4].copy_from_slice(&(i as u32).to_le_bytes()); - Object::Ref(source.add(Object::String(crate::PdfString::literal(value)))) - }) - .collect(); - source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); - let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); - let doc = Document::open(bytes).unwrap(); - let root = doc.get(doc.root().unwrap()); - let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap().clone(); - let mut held = None; - for (i, reference) in references.iter().enumerate() { - let Object::Ref(reference) = reference else { panic!("indirect object") }; - let object = doc.get(*reference); - assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes()); - if i == 0 { - held = Some(object); - } - // Eviction keeps the newcomer. - assert!(doc.cache.lock().unwrap().contains_key(&reference.num), "eviction must keep the newcomer"); - } - assert!(doc.cache.lock().unwrap().len() < references.len(), "the byte budget must evict even below the count cap"); - let last = references[references.len() - 1].as_ref().unwrap(); - assert!(doc.cache.lock().unwrap().contains_key(&last.num), "eviction must keep the newcomer"); - assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes()); - } - - #[test] - fn a_stream_limited_document_cannot_be_saved() { - let bytes = Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap()); - let doc = Document::open_with_stream_limit(bytes.clone(), None, 1 << 20).unwrap(); - for saved in [crate::write_full(&doc, &crate::SaveOptions::default()), crate::write_incremental(&doc, &crate::SaveOptions::default())] { - assert!(matches!(saved, Err(CosError::ReadOnlyLimit)), "{saved:?}"); - } - let doc = Document::open(bytes).unwrap(); - assert!(crate::write_full(&doc, &crate::SaveOptions::default()).is_ok()); - } - - #[test] - fn inspection_stream_limit_applies_before_loading_compressed_objects() { - let mut doc = Document::new_empty(); - let large = doc.add(Object::String(crate::PdfString::literal(vec![b'x'; 4096]))); - doc.update_dict(doc.root().unwrap(), |catalog| catalog.set(b"Extension".to_vec(), Object::Ref(large))).unwrap(); - let bytes = Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap()); - assert!(Document::open(bytes.clone()).is_ok()); - assert!(Document::open_with_stream_limit(bytes, None, 512).is_err()); - } - - /// A /Prev chain that loops back on itself stops at the loop (and says so). - #[test] - fn looping_revision_chains_stop() { - let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); - // An update whose /Prev points at itself. - let at = bytes.len(); - bytes.extend_from_slice( - format!("xref\n0 1\n0000000000 65535 f \ntrailer << /Size 3 /Root 1 0 R /Prev {at} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), - ); - let doc = Document::open(Arc::new(bytes)).unwrap(); - assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); - assert!(doc.repair_log().iter().any(|l| l.contains("loops")), "{:?}", doc.repair_log()); - } - - /// A hybrid-reference file: the classic table leaves object 3 out; the `/XRefStm` stream - /// says it lives in object stream 4. - #[test] - fn hybrid_reference_files_read_the_xref_stream_too() { - let mut out = b"%PDF-1.5\n".to_vec(); - let mut offs = [0usize; 6]; - let mut obj = |out: &mut Vec, n: usize, body: &[u8]| { - offs[n] = out.len(); - out.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); - out.extend_from_slice(body); - out.extend_from_slice(b"\nendobj\n"); - }; - obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>"); - obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>"); - let inner = b"3 0 << /Hidden (yes) >>"; - let mut stm = format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n", inner.len()).into_bytes(); - stm.extend_from_slice(inner); - stm.extend_from_slice(b"\nendstream"); - obj(&mut out, 4, &stm); - let data = [2u8, 0, 4, 0]; - let mut xs = format!("<< /Type /XRef /Size 6 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes(); - xs.extend_from_slice(&data); - xs.extend_from_slice(b"\nendstream"); - obj(&mut out, 5, &xs); - let table = out.len(); - out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); - for (n, off) in offs.iter().enumerate().skip(1) { - let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") }; - out.extend_from_slice(line.as_bytes()); - } - out.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); - let doc = Document::open(Arc::new(out)).unwrap(); - assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); - assert_compact_matches_materialized(&doc); - let hidden = doc.get(ObjRef::new(3, 0)); - assert_eq!(hidden.as_dict().and_then(|d| d.get(b"Hidden").and_then(|h| h.as_string().map(|s| s.to_text()))), Some("yes".into())); - } - - #[test] - fn revision_ends_split_incremental_updates() { - let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); - let first = bytes.len(); - let doc = Document::open(Arc::new(bytes.clone())).unwrap(); - assert_eq!(doc.revision_ends(), vec![first]); - let mut edited = doc.clone(); - edited.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap(); - bytes = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); - let doc = Document::open(Arc::new(bytes.clone())).unwrap(); - let ends = doc.revision_ends(); - assert_eq!(ends.len(), 2, "{ends:?}"); - assert!(ends[0] >= first && ends[0] < bytes.len() && ends[1] == bytes.len(), "{ends:?} of {}", bytes.len()); - // The first revision opens on its own, without the update. - let old = Document::open(Arc::new(bytes[..ends[0]].to_vec())).unwrap(); - assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); - } - - /// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream - /// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99. - fn compressed_in_compressed_fixture() -> Document { - let mut out = b"%PDF-1.5\n".to_vec(); - let at = |out: &mut Vec, num: u32, body: &str| { - let off = out.len(); - out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); - off as u16 - }; - let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>"); - let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>"); - let inner = "<< /Type /Outlines /Count 0 >>"; - at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len())); - // /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3. - let rows: Vec<[u8; 4]> = vec![ - [0, 0, 0, 255], - [1, 0, o1 as u8, 0], - [1, 0, o2 as u8, 0], - [2, 0, 99, 0], - [2, 0, 3, 0], - [1, (out.len() >> 8) as u8, out.len() as u8, 0], - ]; - let rows: Vec = rows.concat(); - let xref = out.len(); - out.extend_from_slice( - format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(), - ); - out.extend_from_slice(&rows); - out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes()); - Document::open(Arc::new(out)).unwrap() - } - - /// An object stream whose own cross-reference entry says it is compressed inside another - /// stream (which the format forbids, and which does not exist here: qpdf's - /// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it - /// read as null and a full save wrote `/Outlines null`. The stream is now found by its own - /// header, as a plain object with a wrong offset is. - #[test] - fn an_object_stream_marked_as_compressed_is_found_by_its_header() { - let doc = compressed_in_compressed_fixture(); - assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log()); - let outlines = doc.get(ObjRef::new(4, 0)); - assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}"); - let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); - let back = Document::open(Arc::new(saved)).unwrap(); - let catalog = back.get(back.root().unwrap()); - let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap()); - assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}"); - } - - /// The header fallback is for file entries only: once the stream is freed (or replaced) in - /// the overlay, its objects must not come back from the file's copy of it. - #[test] - fn a_freed_object_stream_is_not_found_by_its_header() { - let doc = compressed_in_compressed_fixture(); - assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some()); - let mut edited = doc.clone(); - edited.free(ObjRef::new(3, 0)); - let fresh = edited.object_reader().document; - assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); - let mut replaced = doc.clone(); - replaced.set(ObjRef::new(3, 0), Object::Int(0)); - let fresh = replaced.object_reader().document; - assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); - } - - /// The same once the free is saved: the revision's table says the stream is free, and - /// neither reading the file back nor rewriting it in full brings its objects back. - #[test] - fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() { - let mut edited = compressed_in_compressed_fixture(); - edited.free(ObjRef::new(3, 0)); - let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); - let reopened = Document::open(Arc::new(incremental)).unwrap(); - assert_eq!(reopened.revisions().len(), 2); - assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. }))); - assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0))); - for object_streams in [true, false] { - let opts = crate::SaveOptions { object_streams, ..Default::default() }; - let full = crate::write_full(&reopened, &opts).unwrap(); - let back = Document::open(Arc::new(full)).unwrap(); - let catalog = back.get(back.root().unwrap()); - let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o)); - assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}"); - } - } - - #[test] - fn damaged_xref_is_reconstructed() { - let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); - let n = bytes.len(); - bytes[n - 12..n - 6].copy_from_slice(b"999999"); // point startxref into nowhere - let doc = Document::open(Arc::new(bytes)).unwrap(); - assert!(doc.revisions().is_empty()); - assert!(!doc.repair_log().is_empty()); - assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); - } - - #[test] - fn a_decompression_bomb_in_an_xref_stream_is_refused_and_the_file_reconstructed() { - // 32 MiB of rows behind two FlateDecode filters (a few hundred bytes in the file) for - // /W [1 1 1]: at 3 bytes a row that is more than 8,388,607 objects' worth, so it is not - // decoded (in full: the limit applies while inflating) and the objects are found by - // reconstruction instead. - let bomb = pdfcraft_filters::encode_flate(&pdfcraft_filters::encode_flate(&vec![0u8; 32 << 20])); - let mut bytes = b"%PDF-1.7\n".to_vec(); - let o1 = bytes.len(); - bytes.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); - let o2 = bytes.len(); - bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); - let x = bytes.len(); - bytes.extend_from_slice( - format!("3 0 obj\n<< /Type /XRef /Size 4 /W [1 1 1] /Root 1 0 R /Filter [/FlateDecode /FlateDecode] /Length {} >>\nstream\n", bomb.len()) - .as_bytes(), - ); - bytes.extend_from_slice(&bomb); - bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{x}\n%%EOF\n").as_bytes()); - assert!(o1 < o2 && o2 < x); - let doc = Document::open(Arc::new(bytes)).unwrap(); - assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); - assert!(doc.repair_log().iter().any(|l| l.contains("limit")), "{:?}", doc.repair_log()); - } - - #[test] - fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() { - // From the nightly fuzz job: `6 0 obj << /Length 6 0 R >> stream` with no `endstream` - // after it. Every parse attempt failed, and each attempt resolved the length by loading - // object 6 again, retrying the same ways down to the nesting limit: about 2^32 parses. - // Objects being loaded are now a seen-set, lengths are memoised, and the second parse - // only runs when the header is shifted. - let mut bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n".to_vec(); - bytes.extend_from_slice(b"6 0 obj\n<< /Length 6 0 R >>\nstream\nno end marker follows\n"); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - if let Ok(doc) = Document::open(Arc::new(bytes)) { - let _ = doc.try_get(6); - } - let _ = tx.send(()); - }); - rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially"); - } -} +mod tests; diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs new file mode 100644 index 0000000000..4f05d03ae0 --- /dev/null +++ b/crates/cos/src/document/objects.rs @@ -0,0 +1,746 @@ +use super::reader::find; +use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; +use super::xref::{Revision, XrefEntry}; +use super::{Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared}; + +impl Document { + pub fn bytes(&self) -> &Arc> { + &self.data + } + + /// Identity of the original bytes, xref and authenticated decode context. Edits and + /// clones preserve it; reopening (including after any save) produces a new identity. + pub fn source_identity(&self) -> SourceIdentity { + SourceIdentity(Arc::downgrade(&self.source_identity)) + } + + /// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it + /// changes with every edit, so a value derived from the document can be cached against it. + pub fn read_state(&self) -> ReadState { + ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() } + } + + pub fn version(&self) -> &str { + &self.version + } + + /// Set the PDF header version (`"1.4"`, `"1.7"`, `"2.0"`, …) written by a full save. + pub fn set_version(&mut self, version: impl Into) { + self.version = version.into(); + } + + pub fn trailer(&self) -> &Dict { + &self.trailer + } + + pub fn trailer_mut(&mut self) -> &mut Dict { + &mut self.trailer + } + + pub fn revisions(&self) -> &[Revision] { + &self.revisions + } + + /// Position of the `%PDF-` header in the file: 0 unless bytes come before it. + pub fn header_offset(&self) -> usize { + self.header_offset + } + + /// There is no cross-reference chain to append to: the index was rebuilt from object + /// headers because the file's own was unusable (see [`Self::repair_log`]), or the document + /// was built from scratch ([`Self::new_empty`]). A save writes such a document in full, even + /// when nothing was edited: for a repaired file that is what puts the repair on disk. + pub fn reconstructed(&self) -> bool { + self.revisions.is_empty() + } + + /// Where each revision of the file ends (oldest first): the byte after the `%%EOF` (and its + /// end-of-line) that closes its cross-reference section. `data[..end]` is that revision as it + /// was saved. A linearized file's first-page section belongs to the revision it precedes, so + /// ends only ever increase. Empty for a reconstructed file. + pub fn revision_ends(&self) -> Vec { + let d = &self.data[..]; + let mut ends: Vec = Vec::with_capacity(self.revisions.len()); + for r in self.revisions.iter() { + let from = (r.xref_offset as usize).saturating_add(self.header_offset).min(d.len()); + let Some(at) = d[from..].windows(5).position(|w| w == b"%%EOF") else { continue }; + let mut end = from + at + 5; + while end < d.len() && matches!(d[end], b'\r' | b'\n') && end - (from + at + 5) < 2 { + end += 1; + } + match ends.last_mut() { + Some(last) if end <= *last => {} + _ => ends.push(end), + } + } + ends + } + + pub fn repair_log(&self) -> &[String] { + &self.repair_log + } + + /// Add a line to [`Self::repair_log`]: a leniency applied while reading that the user should + /// know about (fidelity: never silent). + pub fn note_repair(&mut self, line: String) { + let mut log = self.repair_log.as_ref().clone(); + log.push(line); + self.repair_log = Arc::new(log); + } + + /// `true` when there are unsaved edits. Removing an `/Encrypt` dictionary written inline in + /// the trailer (as MuPDF writes it) changes no object, and is an edit all the same. + pub fn is_modified(&self) -> bool { + !self.overlay.is_empty() || self.encryption_changed + } + + /// Object numbers changed since the document was opened (or last saved). + pub fn modified_objects(&self) -> Vec { + self.overlay.keys().copied().collect() + } + + /// Where object `num` is stored in the file (ignoring unsaved edits). + pub fn xref_entry(&self, num: u32) -> Option { + self.entries.lookup(num, &self.data) + } + + /// Cross-reference entries this reader did not take at face value, as (byte offset of the + /// section, object number): an in-use entry at offset 0, a second and different entry for + /// one object in the same section, a reserved cross-reference stream entry type, or a + /// hybrid /XRefStm entry that disagrees with the table. The reader sets such an entry aside + /// and keeps what it already had for the object. Another reader, the page renderer among + /// them, may follow it instead, so the two can then show different objects for one number. + /// Code that compares revisions (signature validation) must not call such an object + /// unchanged. + pub fn xref_oddities(&self) -> &[(u64, u32)] { + &self.xref_oddities + } + + /// Whether the cross-reference entry of `num` locates that object itself: its `num G obj` + /// header at exactly the recorded offset, or the member with that number at the recorded + /// position of its object stream. When it does not, [`Self::get`] still finds the object by + /// searching, but another reader may resolve the number differently. Ignores unsaved edits. + pub fn xref_entry_is_exact(&self, num: u32) -> bool { + match self.xref_entry(num) { + None | Some(XrefEntry::Free { .. }) => true, + Some(XrefEntry::InFile { offset, .. }) => { + let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id.num == num); + // Offsets may count from a header that does not start the file. + usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset)))) + } + Some(XrefEntry::InStream { stream, index }) => self + .objstm(stream) + .is_ok_and(|stm| usize::try_from(index).ok().and_then(|i| stm.index.get(i)).is_some_and(|m| m.is_some_and(|(n, _)| n == num))), + } + } + + /// Object `num` has an unsaved edit. + pub fn is_edited(&self, num: u32) -> bool { + self.overlay.contains_key(&num) + } + + pub fn root(&self) -> Option { + self.trailer.reference(b"Root") + } + + // ── object access ─────────────────────────────────────────────────────────────────────── + + /// Fetch an object by reference. Missing objects are `Null` (ISO 32000-2 §7.3.10). + pub fn get(&self, r: ObjRef) -> Arc { + self.try_get(r.num).unwrap_or_else(|_| Arc::new(Object::Null)) + } + + /// Follow a reference if `o` is one; otherwise return `o` itself. + pub fn resolve(&self, o: &Object) -> Arc { + match o { + Object::Ref(r) => self.get(*r), + other => Arc::new(other.clone()), + } + } + + /// `s` with the `/DecodeParms` its filters use made direct, so [`Stream::decoded`] reads them: + /// an indirect dictionary resolved, and of an array (itself perhaps indirect), the entries at + /// the filters' places (`/Crypt` counts, ISO 32000-2 §7.4.1) resolved, the rest dropped. Of + /// each dictionary only the entries the decoders read are kept, numbers and flags given by + /// reference (`/Predictor 7 0 R`) resolved; `/JBIG2Globals` stays a reference. A `/Crypt` + /// entry, which no decoder reads ([`Stream::filters`] leaves it out), is null. `s` itself + /// when none of those is indirect, or when the other filters are more than any decoder takes + /// ([`pdfcraft_filters::MAX_CHAIN`]). Only small values are copied, so a file can't have a + /// large object copied once for each key and filter. + pub fn with_direct_parms<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> { + let Some(p) = s.dict.get(b"DecodeParms") else { return Cow::Borrowed(s) }; + let crypt = |f: &Object| matches!(f.as_name(), Some(b"Crypt")); + let filters: Vec = match s.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().map(crypt).collect(), + Some(f) => vec![crypt(f)], + None => Vec::new(), + }; + if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN { + return Cow::Borrowed(s); + } + let mut changed = matches!(p, Object::Ref(_)); + // Its numbers and flags kept, resolved; anything else is no filter's parameters (null). + let one = |e: &Object, changed: &mut bool| -> Object { + *changed |= matches!(e, Object::Ref(_)); + let Object::Dict(d) = &*self.held(e) else { return Object::Null }; + let mut made = Dict::new(); + for key in DECODE_PARMS_KEYS { + let Some(v) = d.get(key) else { continue }; + *changed |= matches!(v, Object::Ref(_)); + let v = self.held(v); + if matches!(*v, Object::Int(_) | Object::Real(_) | Object::Bool(_)) { + made.set(key.to_vec(), (*v).clone()); + } + } + if let Some(g @ Object::Ref(_)) = d.get(b"JBIG2Globals") { + made.set(b"JBIG2Globals".to_vec(), g.clone()); + } + Object::Dict(made) + }; + let made = match &*self.held(p) { + Object::Array(a) => { + Object::Array(a.iter().zip(&filters).map(|(e, crypt)| if *crypt { Object::Null } else { one(e, &mut changed) }).collect()) + } + _ => one(p, &mut changed), + }; + if !changed { + return Cow::Borrowed(s); + } + let mut s = s.clone(); + s.dict.set(b"DecodeParms".to_vec(), made); + Cow::Owned(s) + } + + /// `o`, or the object it refers to, without copying either. + fn held<'o>(&self, o: &'o Object) -> Held<'o> { + match o { + Object::Ref(_) => Held::Resolved(self.resolve(o)), + o => Held::Direct(o), + } + } + + /// Resolve and return a dictionary (a stream's dictionary counts). + pub fn dict(&self, o: &Object) -> Option { + self.resolve(o).as_dict().cloned() + } + + /// The CCITT parameters of image `s` when its last filter is `CCITTFaxDecode`: its + /// `/DecodeParms` (resolved; the dictionary of a lone filter, or its entry in an array), with the + /// defaults of ISO 32000-2 Table 11 for what that leaves out. `None` for another image, and for + /// one whose `/Filter` isn't a name or an array of names. + pub fn fax_params(&self, s: &Stream) -> Option { + let chain = s.readable_filters().ok()?; + if !matches!(chain.last(), Some((pdfcraft_filters::Filter::CcittFax, _))) { + return None; + } + // Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the + // last filter but a /Crypt, which `filters` leaves out wherever it stands. + let index = match s.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0), + _ => 0, + }; + let parms = match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() { + Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)), + Some(p) if index == 0 => p.as_dict().cloned(), + _ => None, + } + .unwrap_or_default(); + let int = |key: &[u8], default: i64| parms.get(key).and_then(|v| self.resolve(v).as_int()).unwrap_or(default); + let flag = |key: &[u8], default: bool| match parms.get(key).map(|v| self.resolve(v)).as_deref() { + Some(Object::Bool(b)) => *b, + _ => default, + }; + let d = pdfcraft_filters::CcittParams::default(); + Some(pdfcraft_filters::CcittParams { + k: int(b"K", d.k), + columns: int(b"Columns", d.columns), + rows: int(b"Rows", d.rows), + end_of_line: flag(b"EndOfLine", d.end_of_line), + encoded_byte_align: flag(b"EncodedByteAlign", d.encoded_byte_align), + end_of_block: flag(b"EndOfBlock", d.end_of_block), + black_is_1: flag(b"BlackIs1", d.black_is_1), + }) + } + + /// The samples of image `s`, decoded: through its filters ([`Stream::decoded`]), or, for a + /// CCITT fax image (its last filter), through the filters before it and then + /// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], as `CCITTFaxDecode` + /// gives them (ISO 32000-2 §7.4.6). Parameters given by reference count, for every filter + /// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`. Up to + /// its `/Height` rows are decoded, whatever `/Rows` says, within [`MAX_DECODED`] bytes; damaged + /// data gives fewer, so a caller checks it has the rows it needs. + pub fn decoded_image(&self, s: &Stream) -> Result, CosError> { + self.decoded_image_within(s, MAX_DECODED) + } + + /// [`Document::decoded_image`] for an image whose decoding has a tighter bound than + /// [`MAX_DECODED`]: no stage, the filters before a fax decoder included, may produce more than + /// `max` bytes. + pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> { + let max = max.min(MAX_DECODED); + let s = &*self.with_direct_parms(s); + let Some(p) = self.fax_params(s) else { return s.decoded_within(max) }; + // As many rows as the image has: a /Rows below /Height would cut the picture short, and + // rows past /Height aren't drawn. + let p = pdfcraft_filters::CcittParams { rows: 0, ..p }; + if s.dict.int(b"Width") != Some(p.columns) { + return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into())); + } + let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0); + let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1); + let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height); + // The filters before it, such as ASCII85Decode, are undone first. + let chain = s.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?; + let data = match chain.split_last() { + Some((_, before)) if !before.is_empty() => { + Cow::Owned(pdfcraft_filters::decode_tolerant(before, &s.raw, max).map_err(|e| CosError::Filter(e.to_string()))?.0) + } + _ => Cow::Borrowed(&s.raw[..]), + }; + pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(|e| CosError::Filter(e.to_string())) + } + + pub fn try_get(&self, num: u32) -> Result, CosError> { + match self.overlay.get(&num) { + Some(Slot::Set(_, o)) => return Ok(o.clone()), + Some(Slot::Freed(_)) => return Ok(Arc::new(Object::Null)), + None => {} + } + if let Some(o) = self.cache.lock().map_err(|_| CosError::Poisoned)?.get(&num) { + return Ok(o.clone()); + } + // Loading can re-enter (indirect stream `/Length`, object streams); damaged files make + // cycles such as `6 0 obj << /Length 6 0 R >>`. Bound the nesting on every path. + let _guard = + LoadGuard::enter(num).ok_or_else(|| CosError::Syntax { offset: 0, detail: format!("reference cycle while loading object {num}") })?; + let obj = self.load(num, 0)?; + let obj = Arc::new(obj); + // The editing cache is bounded like the scan reader's: re-parsing a file-backed view + // is cheap, and returned objects outlive eviction. + self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone()); + self.evict_editor_cache_if_over_budget(num, &obj)?; + Ok(obj) + } + + /// Bounds for the editing cache: roomier than the scan reader's (128 objects / 8 MB) + /// so interactive editing does not thrash, but still capped so touching a large + /// document cannot pin its whole object graph. Only owned (decrypted/copied) bytes + /// count; file-backed views re-parse for free. + /// + /// Worst case per document: input bytes + up to `EDITOR_CACHE_OBJECTS` × + /// `EDITOR_LARGE_OBJECT` (1024 × 256 KB = 256 MB) of small objects between full + /// accounting passes + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. + /// The count cap always holds; byte accounting between passes is approximate. + /// Clones share these caches, so one clone's eviction drops another's hot set; + /// that only costs re-parsing, never correctness. Eviction keeps just the newcomer: + /// file-backed views re-parse cheaply and editor working sets stay far below 1024. + pub(super) const EDITOR_CACHE_OBJECTS: usize = 1024; + + pub(super) const EDITOR_CACHE_BYTES: usize = 32 * 1024 * 1024; + + /// Object streams decode to megabytes each, so fewer are kept than plain objects. + const EDITOR_OBJSTM_OBJECTS: usize = 256; + + const EDITOR_OBJSTM_BYTES: usize = 32 * 1024 * 1024; + + /// Newcomers above this force a full accounting pass even below the count cap: + /// `EDITOR_CACHE_BYTES / 128`, so 128 large newcomers always trigger a pass before the + /// byte budget can double. + const EDITOR_LARGE_OBJECT: usize = 256 * 1024; + + fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> { + // Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer + // below the count cap. `len` is copied out first; nothing here holds the lock. + let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len(); + if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT { + return Ok(()); + } + // Walk a snapshot off-lock; re-lock only to evict. + let live: Vec<(u32, Arc)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect(); + let data = &self.data; + let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0))); + if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES { + return Ok(()); + } + // Re-check under the final lock: another thread may have inserted or evicted since + // the snapshot. Keep its newcomers, and skip the eviction if back within budget. + let snapshot: std::collections::HashSet = live.into_iter().map(|(n, _)| n).collect(); + let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?; + if cache.len() <= Self::EDITOR_CACHE_OBJECTS { + let data = &self.data; + let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))); + if owned <= Self::EDITOR_CACHE_BYTES { + return Ok(()); + } + } + cache.retain(|n, _| *n == fresh_num || !snapshot.contains(n)); + Ok(()) + } + + fn evict_editor_streams_if_over_budget(&self, current: u32) -> Result<(), CosError> { + // The caller inserts `current` first, so retaining it keeps a live entry. + let over = { + let streams = self.objstms.lock().map_err(|_| CosError::Poisoned)?; + let bytes = streams.values().fold(0usize, |bytes, stream| { + bytes + .saturating_add(stream.data.capacity()) + .saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::>())) + }); + bytes > Self::EDITOR_OBJSTM_BYTES || streams.len() > Self::EDITOR_OBJSTM_OBJECTS + }; + if over { + self.objstms.lock().map_err(|_| CosError::Poisoned)?.retain(|n, _| *n == current); + } + Ok(()) + } + + /// Visit every current object without filling this document's editing cache. + /// + /// Full-document searches (for example, standalone timestamps) need to inspect objects + /// that may never be used again. Keep their parse caches private and release them in + /// batches. Returned objects own their data and remain valid after the next batch. + pub fn scan_objects(&self) -> impl Iterator)> + use<> { + let numbers = self.object_numbers(); + let mut reader = self.object_reader(); + numbers.into_iter().map(move |num| { + let reference = ObjRef::new(num, reader.document.generation(num)); + (reference, reader.get(reference)) + }) + } + + /// Like [`Self::scan_objects`], preserving read errors for callers that must distinguish + /// incomplete discovery from an actual null object. Caches remain private and bounded. + pub fn scan_objects_checked(&self) -> impl Iterator, CosError>)> + use<> { + self.scan_objects_subset(self.object_numbers()) + } + + /// Inspect selected current objects with the same bounded cache as a whole-file scan. + /// Missing/freed numbers yield `Null`; malformed objects return their read error. + pub fn scan_objects_subset(&self, numbers: Vec) -> impl Iterator, CosError>)> + use<> { + let mut reader = self.object_reader(); + numbers.into_iter().map(move |num| { + let reference = ObjRef::new(num, reader.document.generation(num)); + (reference, reader.try_get(reference)) + }) + } + + /// Inspect selected objects in the immutable source, ignoring all unsaved edits. This + /// allows a consumer to check an edit's dependencies without retaining an old document. + pub fn scan_original_objects_subset(&self, numbers: Vec) -> impl Iterator, CosError>)> + use<> { + let mut reader = self.object_reader(); + reader.document.overlay.clear(); + numbers.into_iter().map(move |num| { + let reference = ObjRef::new(num, reader.document.generation(num)); + (reference, reader.try_get(reference)) + }) + } + + /// Opened with a tighter stream limit than editing uses ([`Document::open_with_stream_limit`]). + pub(crate) fn stream_limited(&self) -> bool { + self.stream_limit < crate::object::MAX_DECODED + } + + pub(crate) fn object_reader(&self) -> ObjectReader { + let mut document = self.clone(); + document.cache = Arc::default(); + document.objstms = Arc::default(); + ObjectReader { document, decoded_count: 0 } + } + + fn load(&self, num: u32, depth: u32) -> Result { + if depth > 16 { + return Err(CosError::Syntax { offset: 0, detail: "reference cycle while loading".into() }); + } + match self.entries.lookup(num, &self.data) { + None | Some(XrefEntry::Free { .. }) => Ok(Object::Null), + Some(XrefEntry::InFile { offset, .. }) => { + let resolve = |r: ObjRef| { + if let Some(known) = LENGTHS.with(|m| m.borrow().get(&r.num).copied()) { + return known; + } + let length = self.try_get(r.num).ok().and_then(|o| o.as_int()); + LENGTHS.with(|m| m.borrow_mut().insert(r.num, length)); + length + }; + let off = offset as usize; + let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) { + Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), + _ => None, + }; + // Offsets relative to a shifted header, or simply wrong: try both fixes. + at(off) + .or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) }) + .or_else(|| self.located.get(&num).and_then(|(o, _)| usize::try_from(*o).ok()).and_then(at)) + .or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o))) + .ok_or(CosError::MissingObject(num)) + } + Some(XrefEntry::InStream { stream, index }) => { + let stm = self.objstm(stream)?; + // The member at the entry's position, else wherever the stream lists `num`. + let at = usize::try_from(index).ok().and_then(|i| stm.index.get(i)).copied().flatten().filter(|(n, _)| *n == num); + let (_, off) = at.or_else(|| stm.index.iter().flatten().find(|(n, _)| *n == num).copied()).ok_or(CosError::MissingObject(num))?; + Lexer::new(&stm.data, off).object() + } + } + } + + pub(super) fn objstm(&self, num: u32) -> Result, CosError> { + if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { + return Ok(s.clone()); + } + // A file entry that cannot place the stream (one that says it is itself compressed + // inside another stream, as in #2367, or points nowhere) is tried by the stream's own + // header, as a plain object with a wrong offset is. An edited or freed stream, in the + // overlay or in a saved revision's table, is what that says it is: the file's copy + // must not come back. + let loaded = match self.try_get(num) { + Ok(o) if matches!(*o, Object::Stream(_)) => o, + _ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => { + return Err(CosError::MissingObject(num)); + } + _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?), + }; + let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) }; + let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize; + let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok(); + let data = s.decoded_within(self.stream_limit)?; + let mut lx = Lexer::new(&data, 0); + let mut index = Vec::with_capacity(n); + for _ in 0..n { + let (Some(Object::Int(onum)), Some(Object::Int(off))) = (lx.object().ok(), lx.object().ok()) else { break }; + index.push(objstm_member(onum, off, first, data.len())); + } + let stm = Arc::new(ObjStm { index, data: Arc::new(data) }); + // Same bound as the object cache: decoded object streams can be megabytes each. + // Insert first so the retained `num` is actually in the map. + self.objstms.lock().map_err(|_| CosError::Poisoned)?.insert(num, stm.clone()); + self.evict_editor_streams_if_over_budget(num)?; + Ok(stm) + } + + /// Last-resort lookup: scan the file for `num G obj`. + fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> { + let needle = format!("{num} "); + let data = &self.data; + let mut found = None; + let mut i = 0; + while let Some(p) = find(data, needle.as_bytes(), i, data.len()) { + i = p + 1; + if p > 0 && !is_whitespace(data[p - 1]) { + continue; + } + if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams) + && id.num == num + { + found = Some((id, o)); // keep the last (newest) definition + } + } + found + } + + // ── editing ───────────────────────────────────────────────────────────────────────────── + + /// Stream data still pointing into another document's file would keep that whole file + /// alive for as long as this edit exists: give it a buffer of its own (just its bytes). + fn adopt(&self, obj: Object) -> Object { + match obj { + Object::Stream(mut s) if s.raw.borrows_other_than(&self.data) => { + s.raw = s.raw.detached(); + Object::Stream(s) + } + other => other, + } + } + + /// Replace an object (keeping its generation). + pub fn set(&mut self, r: ObjRef, obj: impl Into) { + let obj = self.adopt(obj.into()); + self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj))); + if r.num >= self.next_num { + self.next_num = r.num.saturating_add(1); + } + } + + /// Add a new indirect object and return its reference. + pub fn add(&mut self, obj: impl Into) -> ObjRef { + let r = ObjRef::new(self.allocate_num(), 0); + let obj = self.adopt(obj.into()); + self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj))); + r + } + + /// Find a never-used number, retaining the high-water mark for incremental /Size. + /// Exhausted hints fall back to gaps without replacing source or overlay objects. + fn allocate_num(&mut self) -> u32 { + let mut num = if self.next_num == u32::MAX { 1 } else { self.next_num.max(1) }; + while self.entries.lookup(num, &self.data).is_some() || self.overlay.contains_key(&num) { + num = if num == u32::MAX - 1 { 1 } else { num + 1 }; + } + self.next_num = self.next_num.max(num.saturating_add(1)); + num + } + + /// Delete an object (its number becomes free with the next generation). + pub fn free(&mut self, r: ObjRef) { + self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); + } + + /// Free what was added since the file was read and nothing refers to any more: the objects + /// [`SaveOptions::drop_unlinked`](crate::SaveOptions::drop_unlinked) leaves out of an + /// incremental save, freed as it writes them (the generation stays). For a writer about to + /// add an object that nothing refers to either, which that option would leave out too. + pub fn free_unlinked_additions(&mut self) { + for num in crate::writer::unlinked_additions(self) { + let generation = self.generation(num); + self.overlay.insert(num, Slot::Freed(generation)); + } + } + + /// Record `current`'s object graph as unsaved edits of this document. + /// + /// Used when the working snapshot was restored from an earlier revision and the next + /// incremental save must still append to this file (the last saved revision). Objects + /// that already match are left untouched. Reads go through private readers so neither + /// document's editing cache is filled. + pub fn overlay_changes_from(&mut self, current: &Document) -> Result<(), CosError> { + let saved_nums = self.object_numbers(); + let current_nums = current.object_numbers(); + let mut freed = Vec::new(); + let mut changed = Vec::new(); + { + let mut saved_reader = self.object_reader(); + let mut current_reader = current.object_reader(); + for &num in &saved_nums { + if current_nums.binary_search(&num).is_err() { + freed.push(num); + } + } + for &num in ¤t_nums { + let generation = current.generation(num); + let same = saved_nums.binary_search(&num).is_ok() + && self.generation(num) == generation + && saved_reader.try_get(ObjRef::new(num, 0))? == current_reader.try_get(ObjRef::new(num, 0))?; + if !same { + changed.push(num); + } + } + } + for num in freed { + let generation = self.generation(num); + self.free(ObjRef::new(num, generation)); + } + let mut current_reader = current.object_reader(); + for num in changed { + let generation = current.generation(num); + let object = current_reader.try_get(ObjRef::new(num, 0))?; + self.set(ObjRef::new(num, generation), (*object).clone()); + } + *self.trailer_mut() = current.trailer().clone(); + Ok(()) + } + + /// Edit a dictionary (or stream dictionary) object in place. + pub fn update_dict(&mut self, r: ObjRef, f: impl FnOnce(&mut Dict)) -> Result<(), CosError> { + let mut obj = (*self.get(r)).clone(); + let d = obj.as_dict_mut().ok_or(CosError::NotADictionary(r))?; + f(d); + self.set(r, obj); + Ok(()) + } + + /// Current generation of an object number. + pub fn generation(&self, num: u32) -> u16 { + match self.overlay.get(&num) { + Some(Slot::Set(g, _)) => *g, + Some(Slot::Freed(g)) => *g, + None => match self.entries.lookup(num, &self.data) { + Some(XrefEntry::InFile { generation, .. }) => generation, + Some(XrefEntry::Free { next_generation }) => next_generation, + _ => 0, + }, + } + } + + pub(crate) fn overlay_entries(&self) -> impl Iterator>)> + '_ { + self.overlay.iter().map(|(n, s)| match s { + Slot::Set(g, o) => (*n, *g, Some(o.clone())), + Slot::Freed(g) => (*n, *g, None), + }) + } + + pub(crate) fn next_num(&self) -> u32 { + self.next_num + } + + /// All object numbers known (file and overlay), excluding freed ones. + pub fn object_numbers(&self) -> Vec { + let source_numbers = self.entries.object_numbers(&self.data); + let mut numbers = Vec::with_capacity(source_numbers.len().max(self.overlay.len())); + let mut source = source_numbers.into_iter().peekable(); + for (&num, slot) in &self.overlay { + while source.peek().is_some_and(|n| *n < num) { + if let Some(n) = source.next() { + numbers.push(n); + } + } + if source.peek().is_some_and(|n| *n == num) { + source.next(); + } + if matches!(slot, Slot::Set(..)) { + numbers.push(num); + } + } + numbers.extend(source); + numbers + } + + /// Rebase this document on bytes written by the writer (after a save): edits become part of + /// the file and the overlay is cleared, so the next incremental save appends only new edits. + pub fn reopen_after_save(&self, bytes: Arc>) -> Result { + Document::open(bytes) + } + + // ── cross-reference parsing ───────────────────────────────────────────────────────────── + + pub(super) fn root_is_catalog(&self) -> bool { + let Some(r) = self.root() else { return false }; + matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages")) + } +} + +/// The `/DecodeParms` entries the decoders read (ISO 32000-2 Tables 8, 11 and 13: the Flate and +/// LZW predictors, CCITT, DCT's colour transform): numbers and flags a file may give by +/// reference, which [`Dict::int`] doesn't follow. +const DECODE_PARMS_KEYS: [&[u8]; 13] = [ + b"Predictor", + b"Colors", + b"BitsPerComponent", + b"Columns", + b"EarlyChange", + b"K", + b"Rows", + b"EndOfLine", + b"EncodedByteAlign", + b"EndOfBlock", + b"BlackIs1", + b"DamagedRowsBeforeError", + b"ColorTransform", +]; + +/// An object read in place, or the object a reference names as the document holds it: either way +/// nothing is copied ([`Document::held`]). +enum Held<'o> { + Direct(&'o Object), + Resolved(Arc), +} + +impl std::ops::Deref for Held<'_> { + type Target = Object; + + fn deref(&self) -> &Object { + match self { + Held::Direct(o) => o, + Held::Resolved(o) => o, + } + } +} diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs new file mode 100644 index 0000000000..57d697ed66 --- /dev/null +++ b/crates/cos/src/document/open.rs @@ -0,0 +1,343 @@ +use super::reader::find; +use super::state::Credentials; +use super::xref::XrefIndex; +use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object}; + +impl Document { + /// A new document with an empty page tree (for split / extract / combine). It has no + /// original bytes, so saving always writes a full file. + pub fn new_empty() -> Self { + let mut doc = Document { + data: Arc::new(Vec::new()), + endstreams: Arc::default(), + source_identity: Arc::new(()), + entries: Arc::new(XrefIndex::empty()), + trailer: Dict::new(), + revisions: Arc::new(Vec::new()), + xref_oddities: Arc::default(), + located: Arc::default(), + repair_log: Arc::new(Vec::new()), + cache: Arc::default(), + objstms: Arc::default(), + stream_limit: crate::object::MAX_DECODED, + overlay: BTreeMap::new(), + next_num: 1, + header_offset: 0, + version: "1.7".into(), + security: None, + encrypt_num: None, + encryption_changed: false, + full_save: false, + out_security: None, + out_encrypt_num: None, + }; + let mut pages = Dict::new(); + pages.set(b"Type".to_vec(), Object::name("Pages")); + pages.set(b"Kids".to_vec(), Object::Array(Vec::new())); + pages.set(b"Count".to_vec(), Object::Int(0)); + let pages = doc.add(pages); + let mut catalog = Dict::new(); + catalog.set(b"Type".to_vec(), Object::name("Catalog")); + catalog.set(b"Pages".to_vec(), Object::Ref(pages)); + let root = doc.add(catalog); + doc.trailer.set(b"Root".to_vec(), Object::Ref(root)); + doc + } + + /// Parse a document. Damaged cross-reference data is reconstructed; see `repair_log`. + /// Encrypted documents open only if their user password is empty; see `open_with_password`. + pub fn open(data: Arc>) -> Result { + Self::open_with_password(data, None) + } + + /// Parse a document, authenticating encrypted ones with `password` (owner or user; `None` + /// tries the empty password). Fails with `NeedsPassword` / `WrongPassword` as appropriate. + pub fn open_with_password(data: Arc>, password: Option<&str>) -> Result { + Self::open_with_stream_limit(data, password, crate::object::MAX_DECODED) + } + + /// Open with a tighter decompression bound for object and cross-reference streams. + /// Inspection can use this without changing the editing reader's default limit. Such a + /// document is read-only: objects in a larger object stream read as missing, so the + /// writers refuse to save it ([`CosError::ReadOnlyLimit`]). + pub fn open_with_stream_limit(data: Arc>, password: Option<&str>, stream_limit: usize) -> Result { + Self::open_as(data, Credentials::Password(password), stream_limit) + } + + /// Open `data`, an earlier revision of this file (the bytes a signature covers) or another + /// save of it, with this document's credentials: an encrypted one must have the same + /// `/Encrypt` dictionary and `/ID` as this document, whose file key is then reused, so no + /// password is needed or kept; unencrypted data opens as [`open`](Self::open) does. Data + /// encrypted differently fails with `NeedsPassword`. + pub fn open_with_same_credentials(&self, data: Arc>) -> Result { + let credentials = match &self.security { + Some(handler) => Credentials::Handler { handler, id0: self.first_id() }, + None => Credentials::Password(None), + }; + Self::open_as(data, credentials, self.stream_limit) + } + + fn open_as(data: Arc>, credentials: Credentials<'_>, stream_limit: usize) -> Result { + // Viewers accept files whose header is missing or damaged as long as the body looks + // like PDF; so do we (a note goes to the repair log). + let header = find(&data, b"%PDF-", 0, 1024); + let headerless = header.is_none(); + if headerless && find(&data, b" obj", 0, 4096).is_none() { + return Err(CosError::NotPdf); + } + let header_offset = header.unwrap_or(0); + let version = match header { + Some(h) => String::from_utf8_lossy(&data[(h + 5).min(data.len())..(h + 8).min(data.len())]).trim().to_string(), + None => "1.4".to_string(), + }; + let mut doc = Document { + data, + endstreams: Arc::default(), + source_identity: Arc::new(()), + entries: Arc::new(XrefIndex::empty()), + trailer: Dict::new(), + revisions: Arc::new(Vec::new()), + xref_oddities: Arc::default(), + located: Arc::default(), + repair_log: Arc::new(Vec::new()), + cache: Arc::default(), + objstms: Arc::default(), + stream_limit: stream_limit.min(crate::object::MAX_DECODED), + overlay: BTreeMap::new(), + next_num: 1, + header_offset, + version, + security: None, + encrypt_num: None, + encryption_changed: false, + full_save: false, + out_security: None, + out_encrypt_num: None, + }; + let mut log = Vec::new(); + if headerless { + log.push("the %PDF- header is missing; reading the file as PDF 1.4".into()); + } + let parsed = doc.read_xref_chain(&mut log); + let mut authenticated = false; + let ok = match parsed { + Ok((entries, trailer, revisions, oddities)) => { + doc.entries = Arc::new(entries); + doc.trailer = trailer; + doc.revisions = Arc::new(revisions); + doc.xref_oddities = Arc::new(oddities); + doc.fill_xref_gaps(&mut log); + // Authenticate before checking the catalog: it may sit in an encrypted object + // stream, which reads as garbage until the key is known. + if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() { + doc.authenticate(&enc, &credentials)?; + authenticated = true; + } + doc.root_is_catalog() + } + Err(e) => { + log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers")); + false + } + }; + if !ok { + if !doc.entries.is_empty() { + log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into()); + } + doc.reconstruct(&mut log)?; + } + doc.repair_log = Arc::new(log); + // Reconstruction may have found a different trailer: (re)authenticate against it. + if (!ok || !authenticated) + && let Some(enc) = doc.trailer.get(b"Encrypt").cloned() + { + doc.authenticate(&enc, &credentials)?; + } + if Arc::make_mut(&mut doc.entries).remove_exhausted() { + Arc::make_mut(&mut doc.repair_log).push("skipped exhausted xref object number 4294967295".into()); + } + let max = doc.entries.max_num(); + let size = u32::try_from(doc.trailer.int(b"Size").unwrap_or(0).max(0)).unwrap_or(u32::MAX); + if size == u32::MAX { + Arc::make_mut(&mut doc.repair_log).push("exhausted trailer /Size; new objects use unused object numbers".into()); + } + doc.next_num = max.saturating_add(1).max(size).max(1); + Ok(doc) + } + + fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> { + // Authentication can change how the same input is decoded. + self.source_identity = Arc::new(()); + let (num, dict) = match enc { + Object::Ref(r) => (Some(r.num), self.get(*r).as_dict().cloned()), + Object::Dict(d) => (None, Some(d.clone())), + _ => (None, None), + }; + let Some(dict) = dict else { + // A dangling /Encrypt: viewers treat the file as unencrypted, and so do we. + let mut log = self.repair_log.as_ref().clone(); + log.push("the trailer names an /Encrypt dictionary that does not exist; reading the file as unencrypted".into()); + self.repair_log = Arc::new(log); + self.trailer.remove(b"Encrypt"); + return Ok(()); + }; + let params = crate::security::encrypt_dict(self, &dict); + let id0 = self.first_id(); + let handler = match credentials { + Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e { + pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, + pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, + other => CosError::Security(other.to_string()), + })?), + // The same encryption as the document whose key this is: that key opens it. + Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler), + Credentials::Handler { .. } => return Err(CosError::NeedsPassword), + }; + self.security = Some(handler); + self.encrypt_num = num; + // Objects read while locating the catalog were read before decryption was possible. + self.cache = Arc::default(); + self.objstms = Arc::default(); + Ok(()) + } + + /// The first file identifier, part of the encryption key (empty when there is none). + fn first_id(&self) -> Vec { + match self.trailer.get(b"ID") { + Some(Object::Array(a)) => a.first().and_then(|s| s.as_string()).map(|s| s.bytes.clone()).unwrap_or_default(), + _ => Vec::new(), + } + } + + /// Lift the restrictions of a document opened with its user password by entering the + /// owner (permissions) password (§7.6.4). Fails with `WrongPassword` for any other + /// password. Does nothing when the document is unencrypted or already opened as owner. + /// Both passwords unlock the same file key, so nothing read so far is decoded again. + pub fn unlock(&mut self, password: &str) -> Result<(), CosError> { + let Some(current) = self.security.as_deref() else { return Ok(()) }; + if current.auth() == pdfcraft_crypt::Auth::Owner { + return Ok(()); + } + let owner = current.authenticate_owner(&self.first_id(), password).map_err(|e| match e { + pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, + other => CosError::Security(other.to_string()), + })?; + self.security = Some(Arc::new(owner)); + Ok(()) + } + + /// The security handler, when the document is encrypted. + pub fn security(&self) -> Option<&pdfcraft_crypt::SecurityHandler> { + self.security.as_deref() + } + + /// What the opening password allows (`None` for unencrypted documents: everything). + pub fn permissions(&self) -> Option { + self.security().map(|s| s.permissions()) + } + + /// Make the next save a full rewrite: earlier revisions (which still hold what an edit + /// removed) must not stay in the file. Redaction requires this. + pub fn require_full_save(&mut self) { + self.full_save = true; + } + + /// The next save must rewrite the whole file (see [`Document::require_full_save`]). + pub fn full_save_required(&self) -> bool { + self.full_save + } + + /// Security was set or removed since the document was opened (the next save applies it). + pub fn encryption_changed(&self) -> bool { + self.encryption_changed + } + + /// The handler used to write: the new one after `set_encryption` / `remove_encryption`, + /// otherwise the one the document was opened with. + pub(crate) fn output_security(&self) -> (Option<&pdfcraft_crypt::SecurityHandler>, Option) { + if self.encryption_changed { (self.out_security.as_deref(), self.out_encrypt_num) } else { (self.security.as_deref(), self.encrypt_num) } + } + + /// The security the next save writes: protection applied with `set_encryption`, none after + /// `remove_encryption`, otherwise the security the document was opened with. + pub fn output_handler(&self) -> Option<&pdfcraft_crypt::SecurityHandler> { + self.output_security().0 + } + + /// Protect the document with a password (§7.6.4). Takes effect on the next save, which is + /// always a full rewrite. Returns the handler (authenticated as owner). + pub fn set_encryption(&mut self, params: &pdfcraft_crypt::NewEncryption) -> Result<(), CosError> { + // The file identifier is part of the key; make sure it exists and keep it. + let id0 = match self.trailer.get(b"ID") { + Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(), + _ => { + let id = pdfcraft_crypt::file_id(¶ms.seed); + let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true }); + self.trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); + id + } + }; + let h = pdfcraft_crypt::create(params, &id0).map_err(|e| CosError::Security(e.to_string()))?; + let d = h.dict(); + let s = |b: &[u8]| Object::String(crate::PdfString { bytes: b.to_vec(), hex: true }); + let mut e = Dict::new(); + e.set(b"Filter".to_vec(), Object::name("Standard")); + e.set(b"V".to_vec(), Object::Int(d.v)); + e.set(b"R".to_vec(), Object::Int(d.r)); + e.set(b"Length".to_vec(), Object::Int(d.length_bits)); + e.set(b"O".to_vec(), s(&d.o)); + e.set(b"U".to_vec(), s(&d.u)); + e.set(b"P".to_vec(), Object::Int(i64::from(d.p))); + if d.v >= 4 { + let mut cf = Dict::new(); + let mut std_cf = Dict::new(); + std_cf.set(b"Type".to_vec(), Object::name("CryptFilter")); + let (cfm, len) = if d.v >= 5 { ("AESV3", 32) } else { ("AESV2", 16) }; + std_cf.set(b"CFM".to_vec(), Object::name(cfm)); + std_cf.set(b"Length".to_vec(), Object::Int(len)); + std_cf.set(b"AuthEvent".to_vec(), Object::name("DocOpen")); + cf.set(b"StdCF".to_vec(), Object::Dict(std_cf)); + e.set(b"CF".to_vec(), Object::Dict(cf)); + e.set(b"StmF".to_vec(), Object::name("StdCF")); + e.set(b"StrF".to_vec(), Object::name("StdCF")); + if !d.encrypt_metadata { + e.set(b"EncryptMetadata".to_vec(), Object::Bool(false)); + } + } + if d.v >= 5 { + e.set(b"OE".to_vec(), s(&d.oe)); + e.set(b"UE".to_vec(), s(&d.ue)); + e.set(b"Perms".to_vec(), s(&d.perms)); + } + if let Some(Object::Ref(old)) = self.trailer.get(b"Encrypt").cloned() { + self.free(old); + } + let r = self.add(e); + self.trailer.set(b"Encrypt".to_vec(), Object::Ref(r)); + // Objects are still read with the original handler; saves use the new one. + self.out_security = Some(Arc::new(h)); + self.out_encrypt_num = Some(r.num); + self.encryption_changed = true; + Ok(()) + } + + /// Remove password protection (requires the document to be open — any authenticated + /// password; callers should require the owner password, §7.6.4). Applied by a full save. + pub fn remove_encryption(&mut self) { + if let Some(Object::Ref(r)) = self.trailer.get(b"Encrypt").cloned() { + self.free(r); + } + self.trailer.remove(b"Encrypt"); + self.out_security = None; + self.out_encrypt_num = None; + self.encryption_changed = true; + } + + /// Decrypt a freshly parsed indirect object if the document is encrypted. + pub(super) fn decrypted(&self, id: ObjRef, o: Object) -> Object { + match &self.security { + Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, crate::security::Pass::Decrypt), + _ => o, + } + } +} diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs new file mode 100644 index 0000000000..d21bf9403f --- /dev/null +++ b/crates/cos/src/document/reader.rs @@ -0,0 +1,862 @@ +#[cfg(test)] +use super::state::SECTION_READS; +use super::xref::{ + MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData, +}; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, HashSet, Lexer, ObjRef, Object, is_whitespace, parse_indirect, parse_indirect_shared}; + +impl Document { + #[allow(clippy::type_complexity)] + pub(super) fn read_xref_chain(&self, log: &mut Vec) -> Result<(XrefIndex, Dict, Vec, Vec<(u64, u32)>), CosError> { + let mut compact_log = Vec::new(); + if let Ok(Some((index, trailer, revisions))) = self.read_compact_xref_chain(&mut compact_log) { + let size = trailer.int(b"Size").unwrap_or(0).max(0); + if size > 0 && size <= i64::from(u32::MAX) && u64::from(index.max_num()) < size as u64 { + log.extend(compact_log); + // The compact reader declines any section with set-aside entries, so there are none here. + return Ok((index, trailer, revisions, Vec::new())); + } + } + let (entries, trailer, revisions, oddities) = self.read_xref_chain_materialized(log)?; + Ok((XrefIndex::Materialized(Arc::new(entries)), trailer, revisions, oddities)) + } + + fn read_compact_xref_chain(&self, log: &mut Vec) -> Result)>, CosError> { + let data = &self.data; + let tail_start = data.len().saturating_sub(4096); + let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; + let mut lx = Lexer::new(data, sx + 9); + let first = match lx.object() { + Ok(Object::Int(n)) if n >= 0 => n as u64, + _ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }), + }; + let mut sections = Vec::new(); + let mut retained_rows = 0usize; + let mut retained_bytes = 0usize; + let mut trailer: Option = None; + let mut revisions = Vec::new(); + let mut visited = HashSet::new(); + let mut next = Some(first); + while let Some(off) = next.take() { + if !visited.insert(off) || visited.len() > 10_000 { + log.push(format!("cross-reference chain loops at offset {off}; stopped")); + break; + } + let ((primary, sect_trailer, is_stream), header_relative) = match self.read_compact_section(off as usize) { + Ok(Some(v)) => (v, false), + Ok(None) => return Ok(None), + Err(e) if self.header_offset > 0 => { + log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); + match self.read_compact_section(off as usize + self.header_offset)? { + Some(v) => (v, true), + None => return Ok(None), + } + } + // An unreadable older section loses every object only it locates: the + // materialized reader logs it and the file is rebuilt from object headers (#1656). + Err(e) => return Err(e), + }; + revisions.push(Revision { xref_offset: off, is_stream, header_relative }); + if primary.renumbered { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } + let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; + let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else { + return Ok(None); + }; + retained_rows = rows; + retained_bytes = bytes; + let hybrid = if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { + match self.read_compact_section(x as usize) { + Ok(Some((section, _, _))) => { + let Some(rows) = retained_rows.checked_add(section.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { + return Ok(None); + }; + let Some(bytes) = retained_bytes.checked_add(section.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) + else { + return Ok(None); + }; + retained_rows = rows; + retained_bytes = bytes; + Some(section) + } + Ok(None) => return Ok(None), + Err(e) => { + log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")); + None + } + } + } else { + None + }; + if let Some(hybrid) = &hybrid { + // A hybrid entry that disagrees with the table is set aside by the materialized reader. + let mut numbers = Vec::new(); + hybrid.extend_numbers(&mut numbers); + for num in numbers { + let Some(entry) = hybrid.lookup(num, &self.data) else { continue }; + if primary.lookup(num, &self.data).is_some_and(|old| !matches!(old, XrefEntry::Free { .. }) && old != entry) { + return Ok(None); + } + } + } + sections.push(XrefRevisionSection { primary, hybrid }); + next = prev_offset(data, §_trailer, off, |num| sections.iter().find_map(|s| s.lookup(num, data)), log); + if trailer.is_none() { + trailer = Some(sect_trailer); + } + } + revisions.reverse(); + let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?; + for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { + trailer.remove(k); + } + let mut max_num = 0; + let mut has_entries = false; + for revision in §ions { + max_num = max_num.max(revision.primary.max_num); + has_entries |= revision.primary.has_entries; + if let Some(hybrid) = &revision.hybrid { + max_num = max_num.max(hybrid.max_num); + has_entries |= hybrid.has_entries; + } + } + Ok(Some((XrefIndex::Compact { revisions: sections, max_num, has_entries }, trailer, revisions))) + } + + fn read_compact_section(&self, off: usize) -> Result, CosError> { + let data = &self.data; + let mut lx = Lexer::new(data, off); + if lx.eat_keyword(b"xref") { + let mut ranges = Vec::new(); + let mut layout_ok = true; + let mut max_num = 0; + let mut has_entries = false; + let mut previous_end = None; + let mut row_count = 0usize; + let mut renumbered = false; + loop { + lx.skip_ws(); + if lx.eat_keyword(b"trailer") { + let t = lx.object()?; + let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?; + if !layout_ok { + return Ok(None); + } + return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries, renumbered }, d, false))); + } + let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { + return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() }); + }; + if !valid_xref_range(start, count) || count > 10_000_000 { + return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); + } + let shifted = subsection_start(data, self.header_offset, lx.pos, start, count); + renumbered |= shifted != start; + let start = shifted; + let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) }; + if next_rows > MAX_COMPACT_XREF_ROWS { + return Ok(None); + } + row_count = next_rows; + let mut range_offset = None; + let mut stride = None; + let mut previous_record = None; + for i in 0..count { + lx.skip_ws(); + let record = lx.pos; + if range_offset.is_none() { + range_offset = Some(record); + } + if let Some(previous) = previous_record { + let Some(delta) = record.checked_sub(previous) else { + layout_ok = false; + continue; + }; + if stride.is_some_and(|known| known != delta) { + layout_ok = false; + } + stride.get_or_insert(delta); + } + previous_record = Some(record); + let o = lx.token(); + lx.skip_ws(); + let _generation = lx.token(); + lx.skip_ws(); + let kind = lx.token(); + let num = (start + i) as u32; + let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::().ok()); + match (kind, offset) { + (b"n", Some(offset)) if offset != 0 => { + has_entries = true; + max_num = max_num.max(num); + } + (b"n", Some(_)) => { + // Set aside by the materialized reader, which records it. + if num != 0 { + return Ok(None); + } + } + (b"f", _) => { + has_entries = true; + max_num = max_num.max(num); + } + _ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }), + } + } + if count > 0 { + let range_start = start as u32; + let range_count = count as u32; + let end = u64::from(range_start) + u64::from(range_count); + if previous_end.is_some_and(|previous| u64::from(range_start) < previous) { + layout_ok = false; + } + previous_end = Some(end); + ranges.push(XrefRange { + start: range_start, + count: range_count, + offset: range_offset.unwrap_or(lx.pos), + stride: stride.unwrap_or(1), + }); + } + } + } + let (_, obj) = parse_indirect(data, off, &|_| None)?; + let Object::Stream(stream) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) }; + if stream.dict.name(b"Type") != Some(b"XRef") { + return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() }); + } + let w: Vec = stream + .dict + .get(b"W") + .and_then(Object::as_array) + .map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect()) + .unwrap_or_default(); + if w.len() < 3 { + return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() }); + } + let widths = [w[0], w[1], w[2]]; + let row = widths.iter().sum::(); + if row == 0 { + return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() }); + } + let size = stream.dict.int(b"Size").unwrap_or(0).max(0); + let index: Vec = match stream.dict.get(b"Index").and_then(Object::as_array) { + Some(a) => a.iter().filter_map(Object::as_int).collect(), + None => vec![0, size], + }; + let mut requested_rows = 0usize; + let mut previous_end = None; + for pair in index.chunks(2) { + let [start, count] = pair else { break }; + if !valid_xref_range(*start, *count) { + return Err(CosError::Syntax { offset: off, detail: "invalid xref object range".into() }); + } + let Ok(count) = usize::try_from(*count) else { return Ok(None) }; + let Some(next) = requested_rows.checked_add(count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; + if count > 0 { + let end = (*start as u64).saturating_add(count as u64); + if previous_end.is_some_and(|previous| (*start as u64) < previous) { + return Ok(None); + } + previous_end = Some(end); + } + requested_rows = next; + } + if requested_rows.checked_mul(row).is_none_or(|bytes| bytes > MAX_COMPACT_XREF_BYTES) { + return Ok(None); + } + let raw = stream.decoded_within(MAX_XREF_OBJECTS.saturating_mul(row).min(self.stream_limit).min(MAX_COMPACT_XREF_BYTES))?; + if raw.capacity() > MAX_COMPACT_XREF_BYTES { + return Ok(None); + } + let mut ranges = Vec::new(); + let mut row_start = 0usize; + let available_rows = raw.len() / row; + for pair in index.chunks(2) { + let [start, count] = pair else { break }; + let range_start = *start as u32; + let Ok(requested) = usize::try_from(*count) else { return Ok(None) }; + let range_count = requested.min(available_rows.saturating_sub(row_start)); + if range_count > 0 { + let Ok(range_count_u32) = u32::try_from(range_count) else { return Ok(None) }; + let Some(byte_offset) = row_start.checked_mul(row) else { return Ok(None) }; + ranges.push(XrefRange { start: range_start, count: range_count_u32, offset: byte_offset, stride: row }); + } + let Some(next) = row_start.checked_add(range_count) else { return Ok(None) }; + row_start = next; + } + // Rows the materialized reader sets aside (reserved types, offset 0) are left to it. + for range in &ranges { + for i in 0..range.count as usize { + let at = range.offset + i * row; + let Some(r) = raw.get(at..at + row) else { break }; + let field = |from: usize, width: usize, default: u64| -> u64 { + if width == 0 { + return default; + } + r[from..from + width].iter().fold(0u64, |acc, byte| acc << 8 | u64::from(*byte)) + }; + let kind = field(0, widths[0], 1); + let offset = field(widths[0], widths[1], 0); + if kind > 2 || (kind == 1 && offset == 0 && range.start + i as u32 != 0) { + return Ok(None); + } + } + } + let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0); + let row_count = row_start; + let section = XrefSection { + data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, + ranges, + row_count, + max_num, + has_entries: false, + renumbered: false, + }; + let has_entries = section.has_any_entry(); + let section = XrefSection { has_entries, ..section }; + Ok(Some((section, stream.dict.clone(), true))) + } + + #[allow(clippy::type_complexity)] + pub(super) fn read_xref_chain_materialized( + &self, + log: &mut Vec, + ) -> Result<(BTreeMap, Dict, Vec, Vec<(u64, u32)>), CosError> { + let data = &self.data; + let tail_start = data.len().saturating_sub(4096); + let sx = rfind(data, b"startxref", tail_start).ok_or(CosError::Syntax { offset: data.len(), detail: "no startxref".into() })?; + let mut lx = Lexer::new(data, sx + 9); + let first = match lx.object() { + Ok(Object::Int(n)) if n >= 0 => n as u64, + _ => return Err(CosError::Syntax { offset: sx, detail: "bad startxref value".into() }), + }; + let mut entries = BTreeMap::new(); + let mut trailer: Option = None; + let mut revisions = Vec::new(); + let mut oddities = Vec::new(); + let mut visited = HashSet::new(); + // Hybrid streams already read, by offset: their entries, or `None` when unreadable. A newer + // section has merged every entry of one into `entries`, so an older section that names it + // again only has its own table compared with them, without decoding the stream again. + // At most MAX_XREF_OBJECTS entries are kept in all; a stream past that is read again. + let mut hybrids: HashMap>> = HashMap::new(); + let (mut hybrid_rows, mut noted) = (0usize, HashSet::new()); + let mut next = Some(first); + while let Some(off) = next.take() { + if !visited.insert(off) || visited.len() > 10_000 { + log.push(format!("cross-reference chain loops at offset {off}; stopped")); + break; + } + // This section's own entries; older sections only fill what newer ones left out. + let mut sect = BTreeMap::new(); + // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). + let mut odd = Vec::new(); + let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, log) { + Ok(v) => (v, false), + Err(e) if self.header_offset > 0 => { + log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); + sect.clear(); + odd.clear(); + (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true) + } + Err(e) => { + // An unreadable older section loses every object only it locates (the page + // tree, often): rebuild from object headers, as readers do (#1656). + if !revisions.is_empty() { + log.push(format!("older cross-reference section at {off} is unreadable: {e}")); + } + return Err(e); + } + }; + revisions.push(Revision { xref_offset: off, is_stream, header_relative }); + // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the + // free entries it gives objects only a cross-reference stream can locate. + let hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0); + if let Some((x, read)) = hybrid.and_then(|x| hybrids.get(&x).map(|read| (x, read))) { + if noted.insert(x) { + log.push(format!("hybrid /XRefStm at {x} named again by the section at {off}; read once")); + } + if let Some(stm) = read { + for (num, old) in § { + // The table places the object somewhere other than the stream does. + if !matches!(old, XrefEntry::Free { .. }) && stm.get(num).is_some_and(|e| e != old) { + odd.push(*num); + } + } + } + } else if let Some(x) = hybrid { + let mut stm = BTreeMap::new(); + let read = match self.read_section(x as usize, &mut stm, &mut odd, log) { + Ok(_) => { + for (&num, &e) in &stm { + match sect.get(&num) { + None | Some(XrefEntry::Free { .. }) => { + sect.insert(num, e); + } + // The table already places the object somewhere else. + Some(old) if *old != e => odd.push(num), + Some(_) => {} + } + } + Some(stm) + } + Err(e) => { + log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")); + None + } + }; + let rows = read.as_ref().map_or(0, BTreeMap::len); + if let Some(total) = hybrid_rows.checked_add(rows).filter(|total| *total <= MAX_XREF_OBJECTS) { + hybrid_rows = total; + hybrids.insert(x, read); + } + } + oddities.extend(odd.into_iter().map(|num| (off, num))); + for (num, e) in sect { + entries.entry(num).or_insert(e); + } + next = prev_offset(data, §_trailer, off, |num| entries.get(&num).copied(), log); + if trailer.is_none() { + trailer = Some(sect_trailer); + } + } + revisions.reverse(); + let mut trailer = trailer.ok_or(CosError::Syntax { offset: 0, detail: "no trailer".into() })?; + for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { + trailer.remove(k); + } + Ok((entries, trailer, revisions, oddities)) + } + + /// Read one section at `off`, adding entries not already known (newer sections win). `odd` + /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]). + fn read_section( + &self, + off: usize, + entries: &mut BTreeMap, + odd: &mut Vec, + log: &mut Vec, + ) -> Result<(Dict, bool), CosError> { + #[cfg(test)] + SECTION_READS.with(|c| c.set(c.get() + 1)); + let data = &self.data; + let mut lx = Lexer::new(data, off); + if lx.eat_keyword(b"xref") { + loop { + lx.skip_ws(); + if lx.eat_keyword(b"trailer") { + let t = lx.object()?; + let d = t.as_dict().cloned().ok_or(CosError::Syntax { offset: lx.pos, detail: "trailer is not a dictionary".into() })?; + return Ok((d, false)); + } + let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { + return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() }); + }; + if !valid_xref_range(start, count) || count > 10_000_000 { + return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() }); + } + let shifted = subsection_start(data, self.header_offset, lx.pos, start, count); + if shifted != start { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } + let start = shifted; + for i in 0..count { + lx.skip_ws(); + let o = lx.token(); + lx.skip_ws(); + let g = lx.token(); + lx.skip_ws(); + let kind = lx.token(); + let num = (start + i) as u32; + let offset = std::str::from_utf8(o).ok().and_then(|s| s.parse::().ok()); + let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().ok()).unwrap_or(0).min(u16::MAX as u32) as u16; + let entry = match (kind, offset) { + (b"n", Some(offset)) => XrefEntry::InFile { offset, generation }, + (b"f", _) => XrefEntry::Free { next_generation: generation }, + _ => return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref entry".into() }), + }; + // Offset 0 "in use" entries are a known producer bug: ignore them. (Object 0 + // is never a real object, so its entry cannot mislead anyone.) + if matches!(entry, XrefEntry::InFile { offset: 0, .. }) { + if num != 0 { + odd.push(num); + } + continue; + } + if *entries.entry(num).or_insert(entry) != entry { + odd.push(num); + } + } + } + } + // Cross-reference stream. + let (_, obj) = parse_indirect(data, off, &|_| None)?; + let Object::Stream(s) = obj else { return Err(CosError::Syntax { offset: off, detail: "expected xref table or stream".into() }) }; + if s.dict.name(b"Type") != Some(b"XRef") { + return Err(CosError::Syntax { offset: off, detail: "stream at startxref is not /Type /XRef".into() }); + } + let w: Vec = s + .dict + .get(b"W") + .and_then(Object::as_array) + .map(|a| a.iter().map(|x| x.as_int().unwrap_or(0).clamp(0, 8) as usize).collect()) + .unwrap_or_default(); + if w.len() < 3 { + return Err(CosError::Syntax { offset: off, detail: "xref stream /W invalid".into() }); + } + let size = s.dict.int(b"Size").unwrap_or(0).max(0); + let index: Vec = match s.dict.get(b"Index").and_then(Object::as_array) { + Some(a) => a.iter().filter_map(Object::as_int).collect(), + None => vec![0, size], + }; + let row = w[0] + w[1] + w[2]; + if row == 0 { + return Err(CosError::Syntax { offset: off, detail: "xref stream row width 0".into() }); + } + // One row per object, and a document has at most 8,388,607 indirect objects (the classic + // implementation limit): decoding more than that (about 200 MB at the widest rows) is a + // decompression bomb, not cross-reference data. The section is then reconstructed. + let raw = s.decoded_within(MAX_XREF_OBJECTS.saturating_mul(row).min(self.stream_limit))?; + let field = |r: &[u8], from: usize, len: usize, default: u64| -> u64 { + if len == 0 { + return default; + } + r[from..from + len].iter().fold(0u64, |acc, b| acc << 8 | *b as u64) + }; + let mut rows = raw.chunks_exact(row); + for pair in index.chunks(2) { + let [start, count] = pair else { break }; + if !valid_xref_range(*start, *count) { + return Err(CosError::Syntax { offset: off, detail: "invalid xref object range".into() }); + } + for i in 0..*count { + let Some(r) = rows.next() else { break }; + let t = field(r, 0, w[0], 1); + let a = field(r, w[0], w[1], 0); + let b = field(r, w[0] + w[1], w[2], 0); + let num = (*start + i) as u32; + let entry = match t { + 0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 }, + 1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 }, + 2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 }, + // Reserved types are treated as null references (§7.5.8.3). + _ => { + odd.push(num); + continue; + } + }; + if matches!(entry, XrefEntry::InFile { offset: 0, .. }) { + if num != 0 { + odd.push(num); + } + continue; + } + if *entries.entry(num).or_insert(entry) != entry { + odd.push(num); + } + } + } + Ok((s.dict.clone(), true)) + } + + /// Cross-reference data that leaves out objects the file contains: a subsection numbered one + /// too low (object 0 in use, the last object without an entry), or an in-use entry at offset 0 + /// that nothing else replaces. (A number no section lists is not evidence: it is a null + /// object, §7.3.10, and valid files have them below an oversized `/Size`.) Viewers rebuild + /// the table and show these objects; reading them as null would drop them from every copy. + /// When one of those shows, the file is scanned once for object headers, and wrong offsets + /// are looked up there (`load`). Only the numbers the damage leaves out take the last + /// header's location: one whose entry was set aside, or the last object of a subsection + /// numbered one too low. Any other unlisted number stays null, and existing + /// entries are never replaced: an object freed by an update stays freed. + pub(super) fn fill_xref_gaps(&mut self, log: &mut Vec) { + let entries = &self.entries; + let shifted = matches!(entries.lookup(0, &self.data), Some(XrefEntry::InFile { .. })); + let set_aside = self.xref_oddities.iter().any(|(_, num)| entries.lookup(*num, &self.data).is_none()); + if !(shifted || set_aside) { + return; + } + let mut located = BTreeMap::new(); + for (num, offset, generation, _) in object_headers(&self.data) { + located.insert(num, (offset, generation)); + } + let mut filled = entries.to_map(&self.data); + // A subsection `0 N` whose rows are those of objects 1 to N leaves out object N, whatever + // newer sections say about its neighbours. + let past_shift: HashSet = if shifted { + self.revisions + .iter() + .filter(|r| !r.is_stream) + .filter_map(|r| usize::try_from(r.xref_offset).ok().map(|o| if r.header_relative { o.saturating_add(self.header_offset) } else { o })) + .filter_map(|at| shifted_subsection_end(&self.data, at)) + .collect() + } else { + HashSet::new() + }; + let odd: HashSet = self.xref_oddities.iter().map(|(_, n)| *n).collect(); + let mut found = 0usize; + for (&num, &(offset, generation)) in &located { + let left_out = past_shift.contains(&num) || odd.contains(&num); + if num != 0 && !filled.contains_key(&num) && left_out { + filled.insert(num, XrefEntry::InFile { offset, generation }); + found += 1; + } + } + if found > 0 { + log.push(format!("the cross-reference data leaves out {found} objects the file contains; located them by their object headers")); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(filled))); + } + self.located = Arc::new(located); + } + + /// Rebuild the cross-reference index by scanning for `N G obj` headers (§7.5.4 recovery). + pub(super) fn reconstruct(&mut self, log: &mut Vec) -> Result<(), CosError> { + let data = self.data.clone(); + let mut entries: BTreeMap = BTreeMap::new(); + let mut objstms = Vec::new(); + // Offsets of objects that may be cross-reference streams: their dictionaries are the + // trailers of files that have no `trailer` keyword (§7.5.8). + let mut xref_streams = Vec::new(); + for (num, offset, generation, body) in object_headers(&data) { + entries.insert(num, XrefEntry::InFile { offset, generation }); + objstms.push(num); + if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some() + && let Ok(at) = usize::try_from(offset) + { + xref_streams.push(at); + } + } + if entries.is_empty() { + return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() }); + } + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries.clone()))); + self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); + // Objects inside object streams. + for num in objstms { + if let Ok(o) = self.try_get(num) + && let Object::Stream(s) = &*o + && s.dict.name(b"Type") == Some(b"ObjStm") + && let Ok(stm) = self.objstm(num) + { + for (idx, member) in stm.index.iter().enumerate() { + if let Some((inner, _)) = member { + entries.entry(*inner).or_insert(XrefEntry::InStream { stream: num, index: idx as u32 }); + } + } + } + } + let object_count = entries.len(); + self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries))); + self.cache.lock().map_err(|_| CosError::Poisoned)?.clear(); + // Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a + // usable /Root (last in the file, whichever kind), else the catalog itself. One whose + // /Root is a catalog (as `root_is_catalog` judges it) beats one whose /Root is another + // dictionary, such as /Info, wherever it is; an FDF's /Root (no /Type, no /Pages) still + // counts when nothing better is there. + // A file whose only trailers are cross-reference streams (and whose `startxref` is gone + // or points nowhere) keeps its /Info, /ID and /Encrypt this way. + let rank = |at: usize, d: &Dict| { + let root = self.try_get(d.reference(b"Root")?.num).ok()?; + let Object::Dict(c) = &*root else { return None }; + Some((c.name(b"Type") == Some(b"Catalog") || c.contains(b"Pages"), at)) + }; + let mut trailer: Option<((bool, usize), Dict)> = None; + let mut from = 0; + while let Some(p) = find(&data, b"trailer", from, data.len()) { + from = p + 7; + let mut lx = Lexer::new(&data, from); + if let Ok(Object::Dict(d)) = lx.object() + && let Some(r) = rank(p, &d) + && trailer.as_ref().is_none_or(|(best, _)| r > *best) + { + trailer = Some((r, d)); + } + } + for at in xref_streams { + if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) { + continue; + } + if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None, &self.endstreams) + && s.dict.name(b"Type") == Some(b"XRef") + && let Some(r) = rank(at, &s.dict) + && trailer.as_ref().is_none_or(|(best, _)| r > *best) + { + trailer = Some((r, s.dict)); + } + } + let trailer = match trailer { + Some((_, t)) => t, + None => { + let catalog = self + .entries + .object_numbers(&self.data) + .into_iter() + .find(|n| matches!(self.try_get(*n).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog"))); + let Some(c) = catalog else { return Err(CosError::Syntax { offset: 0, detail: "no document catalog found".into() }) }; + let mut t = Dict::new(); + t.set(b"Root".to_vec(), Object::Ref(ObjRef::new(c, self.generation(c)))); + t + } + }; + let mut trailer = trailer; + // What a cross-reference stream's dictionary carries for itself goes, as the chain + // readers drop it: a save writes its own. + for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] { + trailer.remove(k); + } + self.trailer = trailer; + self.revisions = Arc::new(Vec::new()); + log.push(format!("rebuilt cross-reference index from {object_count} object headers")); + Ok(()) + } +} + +/// For a classic table at `at` whose first subsection is `0 N` and opens with an in-use row +/// (the rows of objects 1 to N), the number N its rows really end at. +fn shifted_subsection_end(data: &[u8], at: usize) -> Option { + let mut lx = Lexer::new(data, at); + if !lx.eat_keyword(b"xref") { + return None; + } + let (Ok(Object::Int(0)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { return None }; + lx.skip_ws(); + let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + lx.token(); + lx.skip_ws(); + match (offset, lx.token()) { + (Some(offset), b"n") if offset != 0 => u32::try_from(count).ok().filter(|n| *n > 0), + _ => None, + } +} + +/// Every `N G obj` header in `data`, in file order, as (number, offset, generation, position +/// after `obj`). +fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16, usize)> { + let mut headers = Vec::new(); + let mut i = 0; + while i < data.len() { + // Object headers start at a line start (or after whitespace). + let at_start = i == 0 || is_whitespace(data[i - 1]) || data[i - 1] == b'>'; + if at_start && data[i].is_ascii_digit() { + let mut lx = Lexer::new(data, i); + let n = lx.token(); + lx.skip_ws(); + let g = lx.token(); + if !n.is_empty() && !g.is_empty() && n.iter().all(u8::is_ascii_digit) && g.iter().all(u8::is_ascii_digit) && lx.eat_keyword(b"obj") { + let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::().ok()); + let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().ok()).unwrap_or(0); + if let Some(num) = num { + headers.push((num, i as u64, generation, lx.pos)); + } + i = lx.pos; + continue; + } + } + i += 1; + } + headers +} + +/// The offset of the section before the one whose trailer this is (`/Prev`), if any. Some writers +/// give it as a reference, `N 0 R`. When the sections read so far (`lookup`) place that object +/// and it holds an offset (iText's `indirectPrev.pdf`), that is the offset; otherwise, as the +/// tiff2pdf files in JHOVE's PDF-HUL-157 cases need, the sections before are at N. Either way the +/// reference is noted. Any other value that is not an offset is noted and ends the chain. +fn prev_offset(data: &[u8], trailer: &Dict, off: u64, lookup: impl Fn(u32) -> Option, log: &mut Vec) -> Option { + match trailer.get(b"Prev") { + Some(Object::Int(p)) if *p >= 0 => Some(*p as u64), + Some(Object::Ref(r)) => { + let held = match lookup(r.num) { + Some(XrefEntry::InFile { offset, generation }) if generation == r.generation => { + usize::try_from(offset).ok().and_then(|at| parse_indirect(data, at, &|_| None).ok()).and_then(|(at, value)| match value { + Object::Int(p) if at == *r && p >= 0 => Some(p as u64), + _ => None, + }) + } + _ => None, + }; + match held { + Some(p) => { + log.push(format!( + "the trailer at {off} gives /Prev as the reference {} {} R; reading the offset {p} it holds", + r.num, r.generation + )); + Some(p) + } + None => { + log.push(format!( + "the trailer at {off} gives /Prev as the reference {} {} R; reading it as offset {}", + r.num, r.generation, r.num + )); + Some(u64::from(r.num)) + } + } + } + Some(_) => { + log.push(format!("the trailer at {off} has a /Prev that is not an offset; the sections before it are unreachable")); + None + } + None => None, + } +} + +pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option { + let to = to.min(hay.len()); + if from >= to || needle.is_empty() || to - from < needle.len() { + return None; + } + hay[from..to].windows(needle.len()).position(|w| w == needle).map(|p| p + from) +} + +pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option { + if hay.len() < needle.len() { + return None; + } + (from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle) +} + +/// The object number a classic xref subsection really starts at. A common producer bug heads the +/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so +/// every row would be read one number too high and object 1 as free. Such a subsection is read +/// from 0 when its second row, read that way, locates an object that is indeed numbered 1, at +/// that offset or, as `load` reads offsets, relative to a header shifted by `header_offset`. +/// `rows` is the position just after the subsection header. +fn subsection_start(data: &[u8], header_offset: usize, rows: usize, start: i64, count: i64) -> i64 { + if start != 1 || count < 2 { + return start; + } + let mut lx = Lexer::new(data, rows); + let mut row = || { + lx.skip_ws(); + let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + (offset, generation, lx.token() == b"f") + }; + let (Some(0), Some(65535), true) = row() else { return start }; + let (Some(second), _, false) = row() else { return start }; + let object_one = |at: usize| { + if at >= data.len() { + return false; + } + let mut obj = Lexer::new(data, at); + matches!((obj.object(), obj.object()), (Ok(Object::Int(1)), Ok(Object::Int(_)))) && obj.eat_keyword(b"obj") + }; + let Ok(second) = usize::try_from(second) else { return start }; + if object_one(second) || (header_offset != 0 && object_one(second.saturating_add(header_offset))) { 0 } else { start } +} + +/// Object numbers must fit the representation used by references and the xref map. +/// Validate the whole subsection before adding entries, so neither arithmetic overflow nor +/// truncation can turn a damaged range into entries for unrelated objects. +fn valid_xref_range(start: i64, count: i64) -> bool { + u32::try_from(start).is_ok() && count >= 0 && start.checked_add(count).is_some_and(|end| end <= i64::from(u32::MAX) + 1) +} + +/// The most indirect objects a document can have (the classic implementation limit, ISO +/// 32000-1 Annex C): it bounds how much data a cross-reference stream can hold. +const MAX_XREF_OBJECTS: usize = 8_388_607; diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs new file mode 100644 index 0000000000..b4323f982e --- /dev/null +++ b/crates/cos/src/document/state.rs @@ -0,0 +1,210 @@ +use super::xref::XrefEntry; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, ObjRef, Object, Weak}; + +thread_local! { + /// The objects being loaded on this thread, innermost last (see `Document::try_get`). + static LOADING: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; + /// Indirect `/Length` values resolved during the current outermost load: a damaged file + /// retries a parse several ways, and each retry would resolve the same lengths again + /// (exponential in the chain of streams whose lengths point at further streams). + pub(super) static LENGTHS: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); +} + +/// Marks an object as being loaded on this thread. Loading can re-enter (indirect stream +/// `/Length`, object streams): an object already being loaded is a cycle, and the nesting is +/// bounded as well. +pub(super) struct LoadGuard; + +impl LoadGuard { + const MAX: usize = 32; + + pub(super) fn enter(num: u32) -> Option { + LOADING.with(|l| { + let mut l = l.borrow_mut(); + if l.len() >= Self::MAX || l.contains(&num) { + return None; + } + l.push(num); + Some(LoadGuard) + }) + } +} + +impl Drop for LoadGuard { + fn drop(&mut self) { + let outermost = LOADING.with(|l| { + let mut l = l.borrow_mut(); + l.pop(); + l.is_empty() + }); + if outermost { + LENGTHS.with(|m| m.borrow_mut().clear()); + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct ObjStm { + /// One slot per member in stream order, since cross-reference entries name a member by its + /// position: (object number, byte offset within `data`), or `None` for a member whose number + /// or offset is unusable ([`objstm_member`]). + pub(super) index: Vec>, + pub(super) data: Arc>, +} + +/// Where member `num` of an object stream starts: its `offset` past `/First` (`first`), when the +/// number is an object number and the start lies inside the decoded `len` bytes. Checked on every +/// target, so a 32-bit build neither truncates nor wraps a large `/First` or offset (#1815). +pub(super) fn objstm_member(num: i64, offset: i64, first: Option, len: usize) -> Option<(u32, usize)> { + let num = u32::try_from(num).ok()?; + let start = first?.checked_add(usize::try_from(offset).ok()?)?; + (start < len).then_some((num, start)) +} + +/// Where an overlay slot came from. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Slot { + Set(u16, Arc), + Freed(u16), +} + +/// An immutable input/decode context shared by document clones. Holding this identity +/// retains neither the input bytes nor the parsed document. Weak ownership prevents an +/// expired identity from matching a later allocation at the same address. +#[derive(Clone, Debug)] +pub struct SourceIdentity(pub(super) Weak<()>); + +impl PartialEq for SourceIdentity { + fn eq(&self, other: &Self) -> bool { + self.0.ptr_eq(&other.0) + } +} + +impl Eq for SourceIdentity {} + +/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed +/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every +/// change makes a different one. Changed objects are compared as allocations, which the state +/// keeps alive, so a later object can't take an address one of them had. +#[derive(Clone)] +pub struct ReadState { + pub(super) source: SourceIdentity, + pub(super) trailer: Dict, + pub(super) overlay: BTreeMap, +} + +impl ReadState { + /// Whether `doc` reads as this state. + pub fn matches(&self, doc: &Document) -> bool { + self.source == doc.source_identity() + && self.trailer == doc.trailer + && self.overlay.len() == doc.overlay.len() + && self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| { + a == b + && match (x, y) { + (Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p), + (Slot::Freed(g), Slot::Freed(h)) => g == h, + _ => false, + } + }) + } +} + +impl std::fmt::Debug for ReadState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive() + } +} + +/// A temporary reader for whole-document work. Its caches never populate the editor or +/// undo snapshots. Returned objects survive cache eviction; a stream read from the file is a +/// view into the input bytes (it keeps the whole input alive while held, not a copy). +pub(crate) struct ObjectReader { + pub(crate) document: Document, + pub(super) decoded_count: usize, +} + +impl ObjectReader { + pub(super) const OBJECTS: usize = 128; + pub(super) const STREAM_BYTES: usize = 8 * 1024 * 1024; + + pub(crate) fn get(&mut self, reference: ObjRef) -> Arc { + self.try_get(reference).unwrap_or_else(|_| Arc::new(Object::Null)) + } + + pub(crate) fn try_get(&mut self, reference: ObjRef) -> Result, CosError> { + let object = self.document.try_get(reference.num); + // A missed eviction here only costs a re-parse, so never fail the read on a poisoned + // lock. The editing-cache evictors propagate instead; they guard user edits. + if let Ok(mut streams) = self.document.objstms.lock() { + // Only insertion changes this private cache between reads. Most neighboring + // objects reuse a decoded stream: do not sum the whole store for every object. + if streams.len() != self.decoded_count { + let bytes = streams.values().fold(0usize, |bytes, stream| { + bytes + .saturating_add(stream.data.capacity()) + .saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::>())) + }); + if bytes > Self::STREAM_BYTES || streams.len() >= Self::OBJECTS { + // Retain one oversized active stream so its next object does not require + // decoding it again. That stream still obeys the document's decode cap. + let current = match self.document.xref_entry(reference.num) { + Some(XrefEntry::InStream { stream, .. }) => Some(stream), + _ => None, + }; + streams.retain(|num, _| Some(*num) == current); + } + self.decoded_count = streams.len(); + } + } + // Parsed objects stay cached (an indirect /Length or a dictionary read again costs a + // lookup, not a parse) until the cache passes its object count or holds more than + // STREAM_BYTES of data of its own. Streams read from the file are views into the + // input bytes, which the document keeps anyway, so they don't count; decrypted + // streams and strings do. Checked after failed loads too: resolving a /Length may + // have cached large objects. + if let Ok(mut cache) = self.document.cache.lock() { + let data = &self.document.data; + if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES { + cache.clear(); + } + } + object + } +} + +#[cfg(test)] +thread_local! { + /// Cross-reference sections read on this thread. + pub(super) static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object +/// is measured in full. +const OWNED_BYTES_DEPTH: usize = 256; + +/// Bytes `object` holds of its own: string and stream data that is not a view into `data` +/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured +/// and counts as too large to keep. +pub(super) fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { + if depth > OWNED_BYTES_DEPTH { + return usize::MAX; + } + match object { + Object::String(s) => s.bytes.len(), + Object::Stream(s) => { + let own = if s.raw.shares(data) { 0 } else { s.raw.len() }; + s.dict.iter().fold(own, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))) + } + Object::Array(a) => a.iter().fold(0usize, |n, v| n.saturating_add(owned_bytes(v, data, depth + 1))), + Object::Dict(d) => d.iter().fold(0usize, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))), + _ => 0, + } +} + +/// How an encrypted document is opened: with a password, or with the file key of a document +/// that has the same encryption (an earlier revision of the same file). +pub(super) enum Credentials<'a> { + Password(Option<&'a str>), + Handler { handler: &'a Arc, id0: Vec }, +} diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs new file mode 100644 index 0000000000..f5e36f9c83 --- /dev/null +++ b/crates/cos/src/document/tests.rs @@ -0,0 +1,1492 @@ +use super::*; + +/// /DecodeParms given by reference, whole, as array entries or as the numbers inside, are read +/// once made direct: here a Flate predictor, which the stream alone doesn't see. Only the entries the filters use are +/// resolved, and nothing is for a chain longer than a decoder takes. +#[test] +fn decode_parms_given_by_reference_are_read_once_direct() { + let rows = [1u8, 2, 3, 4, 5, 6, 7, 8]; + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 4, early_change: 1 }; + let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &rows).unwrap(); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(4)); + let mut doc = Document::new_empty(); + let r = doc.add(Object::Dict(parms.clone())); + let array = doc.add(Object::Array(vec![Object::Ref(r)])); + let stream = |filters: usize, p: Object| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"); filters])); + d.set(b"DecodeParms".to_vec(), p); + Stream::from_raw(d, coded.clone()) + }; + for p in [Object::Ref(r), Object::Array(vec![Object::Ref(r)]), Object::Ref(array)] { + let s = stream(1, p.clone()); + assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is"); + assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}"); + } + // A number inside given by reference, which `Dict::int` doesn't follow: in the dictionary + // itself, and in an array's entry. + let twelve = doc.add(Object::Int(12)); + let mut inner = parms.clone(); + inner.set(b"Predictor".to_vec(), Object::Ref(twelve)); + for p in [Object::Dict(inner.clone()), Object::Array(vec![Object::Dict(inner)])] { + let s = stream(1, p.clone()); + assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is"); + assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}"); + } + // Only numbers and flags are copied: a key naming a big array, or an entry that is no + // dictionary, is left out (a file could name one big object from every key and filter). + let big = doc.add(Object::Array(vec![Object::Int(1); 100_000])); + let mut hostile = Dict::new(); + hostile.set(b"Predictor".to_vec(), Object::Ref(big)); + hostile.set(b"Columns".to_vec(), Object::Ref(big)); + hostile.set(b"Colors".to_vec(), Object::Ref(twelve)); + let entry = doc.add(Object::Dict(hostile)); + let two = stream(2, Object::Array(vec![Object::Ref(entry), Object::Ref(big)])); + let made = doc.with_direct_parms(&two); + let made = made.dict.get(b"DecodeParms").and_then(Object::as_array).unwrap(); + let first = made[0].as_dict().unwrap(); + assert_eq!((first.contains(b"Predictor"), first.contains(b"Columns"), first.int(b"Colors")), (false, false, Some(12))); + assert!(matches!(made[1], Object::Null), "{:?}", made[1]); + let direct = stream(1, Object::Dict(parms)); + assert!(matches!(doc.with_direct_parms(&direct), Cow::Borrowed(_)), "nothing to resolve"); + assert_eq!(direct.decoded().unwrap(), rows); + // Entries past the filters' are left out, unresolved; so is everything past the chain limit. + let many = stream(1, Object::Array(vec![Object::Ref(r); 1000])); + let made = doc.with_direct_parms(&many); + assert_eq!(made.dict.get(b"DecodeParms").and_then(Object::as_array).map(Vec::len), Some(1)); + let long = stream(pdfcraft_filters::MAX_CHAIN + 1, Object::Array(vec![Object::Ref(r); 9])); + assert!(matches!(doc.with_direct_parms(&long), Cow::Borrowed(_))); + // A chain as long as a decoder takes is resolved, and a `/Crypt` in it doesn't count, as + // the decoders leave it out: ASCIIHex as often as the limit allows, then the predictor's + // Flate, its parameters by reference. + let hex = |b: &[u8]| b.iter().flat_map(|x| format!("{x:02X}").into_bytes()).chain(*b">").collect::>(); + let wrapped = (1..pdfcraft_filters::MAX_CHAIN).fold(coded.clone(), |data, _| hex(&data)); + for crypt in [false, true] { + let mut names = vec![Object::name("ASCIIHexDecode"); pdfcraft_filters::MAX_CHAIN - 1]; + names.push(Object::name("FlateDecode")); + if crypt { + names.insert(0, Object::name("Crypt")); + } + let mut entries = vec![Object::Null; names.len() - 1]; + entries.push(Object::Ref(r)); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::Array(names)); + d.set(b"DecodeParms".to_vec(), Object::Array(entries)); + let s = Stream::from_raw(d, wrapped.clone()); + assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "with /Crypt: {crypt}"); + } +} + +/// A CCITT fax image reads through its /DecodeParms, given directly, in an array or by +/// reference (its numbers too), with /BlackIs1, and behind other filters; one whose /Columns +/// aren't its /Width is refused, /Height rows are decoded whatever /Rows says, and other images +/// decode through their filters. +#[test] +fn fax_images_decode_with_their_parameters() { + // 10 × 3: the first five samples of each row white, the rest black. + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_g4(&bits, 10, 3).unwrap(); + let mut doc = Document::new_empty(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(10)); + let indirect = doc.add(Object::Dict(parms.clone())); + let image = |doc: &Document, parms: Option, width: i64, height: i64| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(width)); + d.set(b"Height".to_vec(), Object::Int(height)); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + if let Some(p) = parms { + d.set(b"DecodeParms".to_vec(), p); + } + doc.decoded_image(&Stream::from_raw(d, coded.clone())) + }; + for p in [ + Object::Dict(parms.clone()), + Object::Array(vec![Object::Dict(parms.clone())]), + Object::Ref(indirect), + Object::Array(vec![Object::Ref(indirect)]), + ] { + assert_eq!(image(&doc, Some(p.clone()), 10, 3).unwrap(), bits, "{p:?}"); + } + let mut inverted = parms.clone(); + inverted.set(b"BlackIs1".to_vec(), Object::Bool(true)); + assert_eq!( + image(&doc, Some(Object::Dict(inverted)), 10, 3).unwrap(), + [0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000] + ); + assert_eq!(image(&doc, Some(Object::Dict(parms.clone())), 10, 2).unwrap(), bits[..4], "at most /Height rows"); + // Within a bound: no more rows than it holds. + let mut bounded = Dict::new(); + bounded.set(b"Width".to_vec(), Object::Int(10)); + bounded.set(b"Height".to_vec(), Object::Int(3)); + bounded.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + bounded.set(b"DecodeParms".to_vec(), Object::Dict(parms.clone())); + assert_eq!(doc.decoded_image_within(&Stream::from_raw(bounded, coded.clone()), 5).unwrap(), bits[..4], "two rows of two bytes in five"); + let mut by_reference = Dict::new(); + by_reference.set(b"K".to_vec(), Object::Ref(doc.add(Object::Int(-1)))); + by_reference.set(b"Columns".to_vec(), Object::Ref(doc.add(Object::Int(10)))); + assert_eq!(image(&doc, Some(Object::Dict(by_reference)), 10, 3).unwrap(), bits, "/K and /Columns by reference"); + let mut understated = parms.clone(); + understated.set(b"Rows".to_vec(), Object::Int(1)); + assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says"); + // The parameters as the file gives them, /Rows included, and Table 11's defaults. + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(understated)); + let p = doc.fax_params(&Stream::from_raw(d.clone(), Vec::new())).unwrap(); + assert_eq!((p.k, p.columns, p.rows, p.end_of_block, p.black_is_1), (-1, 10, 1, true, false)); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + assert!(doc.fax_params(&Stream::from_raw(d, Vec::new())).is_none(), "not a fax image"); + assert!(matches!(image(&doc, Some(Object::Dict(parms.clone())), 12, 3), Err(CosError::Filter(_))), "/Columns other than /Width"); + assert!(matches!(image(&doc, None, 10, 3), Err(CosError::Filter(_))), "the default 1728 /Columns"); + let mut plain = Dict::new(); + plain.set(b"Filter".to_vec(), Object::name("FlateDecode")); + assert_eq!(doc.decoded_image(&Stream::from_raw(plain, pdfcraft_filters::encode_flate(&bits))).unwrap(), bits); + + // Behind other filters, its parameters at its own place in the array (/Crypt counts). + let behind = |filters: &[Object], parms: &[Object], data: Vec| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::Array(filters.to_vec())); + d.set(b"DecodeParms".to_vec(), Object::Array(parms.to_vec())); + doc.decoded_image(&Stream::from_raw(d, data)) + }; + let (fax, null, own) = (Object::name("CCITTFaxDecode"), Object::Null, Object::Dict(parms.clone())); + let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + let flated = pdfcraft_filters::encode_flate(&coded); + let ahx = [Object::name("AHx"), fax.clone()]; + assert_eq!(behind(&ahx, &[null.clone(), Object::Ref(indirect)], hex.clone()).unwrap(), bits, "behind ASCIIHexDecode"); + assert_eq!( + behind(&[Object::name("FlateDecode"), Object::name("CCF")], &[null.clone(), own.clone()], flated.clone()).unwrap(), + bits, + "behind Flate" + ); + assert_eq!(behind(&[Object::name("Crypt"), fax.clone()], &[null.clone(), own.clone()], coded.clone()).unwrap(), bits, "after /Crypt"); + // A /Crypt belongs first (§7.4.10); one after the fax filter leaves it its own entry. + assert_eq!(behind(&[fax.clone(), Object::name("Crypt")], &[own.clone(), null.clone()], coded.clone()).unwrap(), bits, "before /Crypt"); + assert!(behind(&ahx, &[own.clone(), null.clone()], hex).is_err(), "the first entry is the other filter's"); + assert!(behind(&[fax.clone(), Object::name("FlateDecode")], &[own.clone(), null.clone()], flated).is_err(), "CCITT, then Flate"); + assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name"); +} + +#[test] +fn a_missing_file_id_is_derived_from_the_encryption_seed() { + let mut doc = Document::new_empty(); + let seed = [9u8; 32]; + doc.set_encryption(&pdfcraft_crypt::NewEncryption { + algorithm: pdfcraft_crypt::Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed, + }) + .unwrap(); + let id = doc.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone()); + assert_eq!(id.as_deref(), Some(pdfcraft_crypt::file_id(&seed).as_slice())); + + let mut kept = Document::new_empty(); + let fixed = Object::String(crate::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true }); + kept.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![fixed.clone(), fixed])); + kept.set_encryption(&pdfcraft_crypt::NewEncryption { + algorithm: pdfcraft_crypt::Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed, + }) + .unwrap(); + let still = kept.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone()); + assert_eq!(still.as_deref(), Some(b"0123456789abcdef".as_slice())); +} + +#[test] +fn a_read_state_matches_a_clone_and_no_changed_document() { + let mut doc = Document::new_empty(); + let a = doc.add(Object::Int(1)); + let state = doc.read_state(); + let snapshot = doc.clone(); + assert!(state.matches(&doc) && state.matches(&snapshot)); + // The same value set again is still a change: another allocation. + doc.set(a, Object::Int(1)); + assert!(!state.matches(&doc) && state.matches(&snapshot)); + let edited = doc.read_state(); + doc.free(a); + assert!(!edited.matches(&doc)); + let mut trailer_changed = snapshot.clone(); + trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null); + assert!(!state.matches(&trailer_changed)); + // Another document's state never matches, even with the same changes. + let mut other = Document::new_empty(); + other.add(Object::Int(1)); + assert!(!other.read_state().matches(&snapshot)); +} + +/// Build a classic-xref file from object bodies (object i+1 = bodies[i]). +pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} {trailer} >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + out +} + +fn assert_compact_matches_materialized(doc: &Document) { + assert!(matches!(doc.entries.as_ref(), XrefIndex::Compact { .. }), "valid xrefs should not retain a per-entry map"); + let (entries, _, revisions, _) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); + assert_eq!(revisions.as_slice(), doc.revisions()); + let max = entries.keys().next_back().copied().unwrap_or(0); + assert_eq!(doc.entries.max_num(), max); + for num in 0..=max { + assert_eq!(doc.xref_entry(num), entries.get(&num).copied(), "object {num}"); + } + let expected: Vec<_> = entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect(); + assert_eq!(doc.object_numbers(), expected); +} + +#[test] +fn compact_xref_tables_streams_and_revisions_match_the_materialized_reader() { + let classic = Document::open(Arc::new(build( + &["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"], + "/Root 1 0 R", + ))) + .unwrap(); + assert_compact_matches_materialized(&classic); + + let mut sparse = b"%PDF-1.7\n".to_vec(); + let root_offset = sparse.len(); + sparse.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n"); + let pages_offset = sparse.len(); + sparse.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + let xref = sparse.len(); + sparse.extend_from_slice( + format!( + "xref\n0 1\n0000000000 65535 f \n1 1\n{root_offset:010} 00000 n \n5 1\n{pages_offset:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + let sparse = Document::open(Arc::new(sparse)).unwrap(); + assert_eq!(sparse.object_numbers(), vec![1, 5]); + assert_compact_matches_materialized(&sparse); + + let mut sparse_stream = b"%PDF-1.7\n".to_vec(); + let mut offsets = [0u32; 8]; + offsets[1] = sparse_stream.len() as u32; + sparse_stream.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 5 0 R >>\nendobj\n"); + offsets[5] = sparse_stream.len() as u32; + sparse_stream.extend_from_slice(b"5 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + offsets[6] = sparse_stream.len() as u32; + sparse_stream.extend_from_slice(b"6 0 obj\n<< /Unused true >>\nendobj\n"); + offsets[7] = sparse_stream.len() as u32; + let xref_row = |kind: u8, offset: u32, generation: u16| { + let mut row = Vec::with_capacity(7); + row.push(kind); + row.extend_from_slice(&offset.to_be_bytes()); + row.extend_from_slice(&generation.to_be_bytes()); + row + }; + let mut rows = Vec::new(); + rows.extend(xref_row(0, 0, u16::MAX)); + rows.extend(xref_row(1, offsets[1], 0)); + rows.extend(xref_row(1, offsets[5], 0)); + rows.extend(xref_row(1, offsets[6], 0)); + rows.extend(xref_row(1, offsets[7], 0)); + sparse_stream.extend_from_slice( + format!("7 0 obj\n<< /Type /XRef /Size 8 /Root 1 0 R /W [1 4 2] /Index [0 2 5 3] /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + sparse_stream.extend_from_slice(&rows); + sparse_stream.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{}\n%%EOF\n", offsets[7]).as_bytes()); + let sparse_stream = Document::open(Arc::new(sparse_stream)).unwrap(); + assert_eq!(sparse_stream.object_numbers(), vec![1, 5, 6, 7]); + assert_compact_matches_materialized(&sparse_stream); + + let stream_bytes = crate::write_full(&classic, &crate::SaveOptions::default()).unwrap(); + let stream = Document::open(Arc::new(stream_bytes)).unwrap(); + assert!(stream.revisions().last().is_some_and(|revision| revision.is_stream)); + assert_compact_matches_materialized(&stream); + + let mut edited = classic.clone(); + edited.update_dict(ObjRef::new(1, 0), |dict| dict.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap(); + edited.free(ObjRef::new(3, 0)); + let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); + let updated = Document::open(Arc::new(incremental)).unwrap(); + assert_eq!(updated.revisions().len(), 2); + assert_compact_matches_materialized(&updated); +} + +#[test] +fn malformed_size_uses_the_materialized_compatibility_path() { + let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Value 9 >>"], "/Root 1 0 R"); + let marker = b"/Size 4"; + let at = bytes.windows(marker.len()).position(|window| window == marker).unwrap(); + bytes[at..at + marker.len()].copy_from_slice(b"/Size 2"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(matches!(doc.entries.as_ref(), XrefIndex::Materialized(_))); + assert_eq!(doc.next_num, 4); + assert_eq!(doc.object_numbers(), vec![1, 2, 3]); +} + +/// Entries the reader sets aside are remembered, so a caller comparing revisions can tell +/// that another reader may resolve the object differently. +#[test] +fn cross_reference_entries_that_are_set_aside_are_recorded() { + let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R"); + let plain = Document::open(Arc::new(base.clone())).unwrap(); + assert!(plain.xref_oddities().is_empty()); + assert!((1..=3).all(|n| plain.xref_entry_is_exact(n))); + let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap(); + let third = plain.xref_entry(3).unwrap(); + + // A later section: object 3 "in use" at offset 0, object 2 listed twice with different + // offsets, and object 0 at offset 0 (the usual producer slip, which names no object). + let mut bytes = base.clone(); + let section = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 n \n3 1\n0000000000 00000 n \n2 1\n0000000058 00000 n \n2 1\n0000000009 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.xref_oddities(), [(section as u64, 3), (section as u64, 2)]); + // What the reader resolves is what it resolved before: the first entry it kept. + assert_eq!(doc.xref_entry(3), Some(third)); + assert_eq!(doc.xref_entry(2), Some(XrefEntry::InFile { offset: 58, generation: 0 })); +} + +/// An entry that misses its object still resolves (by searching) but is not exact. +#[test] +fn an_entry_that_misses_its_object_is_found_by_searching_but_is_not_exact() { + let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R"); + let text = String::from_utf8_lossy(&base).into_owned(); + let at = text.find("3 0 obj").unwrap(); + let bytes = text.replacen(&format!("{at:010} 00000 n"), &format!("{:010} 00000 n", at + 2), 1).into_bytes(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.get(ObjRef { num: 3, generation: 0 }).as_string().map(|s| s.bytes.clone()), Some(b"three".to_vec())); + assert!(!doc.xref_entry_is_exact(3)); + assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); +} + +/// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered +/// `1 n` instead of `0 n` (#2676): every row is one number off. +fn misnumbered_table(start: u32) -> Vec { + let bodies = ["<< /Marker (first object) >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Type /Catalog /Pages 2 0 R >>"]; + let mut out = b"%PDF-1.7 +" + .to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +{body} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n{start} 4\n0000000000 65535 f \n").as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +#[test] +fn a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0() { + let doc = Document::open(Arc::new(misnumbered_table(1))).unwrap(); + let first = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("object 1 is not free"); + assert!(first.contains(b"Marker")); + assert_eq!(doc.root(), Some(ObjRef::new(3, 0))); + assert!(doc.repair_log().iter().any(|l| l.contains("numbered from 1")), "{:?}", doc.repair_log()); + // A correct table reads the same and logs nothing. + let fine = Document::open(Arc::new(misnumbered_table(0))).unwrap(); + assert!(fine.get(ObjRef::new(1, 0)).as_dict().is_some_and(|d| d.contains(b"Marker"))); + assert!(!fine.repair_log().iter().any(|l| l.contains("numbered from 1"))); +} + +#[test] +fn a_table_that_really_starts_at_1_keeps_its_numbers() { + // Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its + // second row locates object 2, so nothing is shifted. + let mut bytes = b"%PDF-1.7\n".to_vec(); + let pages = bytes.len(); + bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + let catalog = bytes.len(); + bytes.extend_from_slice(b"3 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n0 1\n0000000000 65535 f \n1 3\n0000000000 65535 f \n{pages:010} 00000 n \n{catalog:010} 00000 n \ntrailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(doc.get(ObjRef::new(2, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Pages"))); + assert!(doc.get(ObjRef::new(1, 0)).as_dict().is_none(), "object 1 stays free"); + assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1"))); +} + +/// A one-page scan: the page draws image 5 with content stream 4. +fn scanned_page() -> Vec { + build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>", + "<< /Length 21 >>\nstream\n10 0 0 10 0 0 cm /Im0 Do\nendstream", + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream", + ], + "/Root 1 0 R", + ) +} + +/// `bytes` with its cross-reference table replaced by `table` (the lines after `xref`). +fn with_table(bytes: &[u8], table: &[String], size: usize) -> Vec { + let text = String::from_utf8_lossy(bytes).into_owned(); + let at = text.rfind("\nxref\n").unwrap() + 1; + let mut out = bytes[..at].to_vec(); + out.extend_from_slice(format!("xref\n{}\ntrailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{at}\n%%EOF\n", table.join("\n")).as_bytes()); + out +} + +/// The table's entry lines, object 0 first. +fn table_entries(bytes: &[u8]) -> Vec { + let text = String::from_utf8_lossy(bytes).into_owned(); + let at = text.rfind("\nxref\n").unwrap() + 1; + text[at..].lines().skip(2).take_while(|l| !l.starts_with("trailer")).map(str::to_string).collect() +} + +/// The classic off-by-one: the subsection says it starts at 0 but lists objects 1 to 5, so +/// the last object has no entry. Viewers rebuild the table and show the page; a reader that +/// took the missing entry for a null object dropped the image when pages were copied (#1628). +#[test] +fn an_off_by_one_xref_subsection_still_finds_the_last_object() { + let base = scanned_page(); + let entries = table_entries(&base); + let mut table = vec!["0 5".to_string()]; + table.extend(entries[1..6].iter().cloned()); + let doc = Document::open(Arc::new(with_table(&base, &table, 5))).unwrap(); + let image = doc.get(ObjRef::new(5, 0)); + assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "{image:?}"); + assert!(matches!(&*image, Object::Stream(_))); + assert!(matches!(&*doc.get(ObjRef::new(4, 0)), Object::Stream(s) if &s.raw[..] == b"10 0 0 10 0 0 cm /Im0 Do")); + assert!(doc.repair_log().iter().any(|l| l.contains("object headers")), "{:?}", doc.repair_log()); +} + +/// An "in use" entry at offset 0 says nothing about where the object is: the object is +/// found by its header, and the repair is recorded. +#[test] +fn an_object_listed_at_offset_zero_is_found_by_its_header() { + let base = scanned_page(); + for num in [4usize, 5] { + let mut table = vec!["0 6".to_string()]; + table.extend(table_entries(&base)); + table[num + 1] = "0000000000 00000 n ".into(); + let doc = Document::open(Arc::new(with_table(&base, &table, 6))).unwrap(); + assert!(matches!(&*doc.get(ObjRef::new(num as u32, 0)), Object::Stream(_)), "object {num}"); + assert!(doc.repair_log().iter().any(|l| l.contains("object headers")), "object {num}: {:?}", doc.repair_log()); + } +} + +/// A valid table that simply does not list every number below `/Size` is not repaired: an +/// unlisted number is a null object (§7.3.10), even when an old body with that number is +/// still in the file. +#[test] +fn a_consistent_table_with_unlisted_numbers_is_taken_as_it_is() { + let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R"); + let at = String::from_utf8_lossy(&base).rfind("\nxref\n").unwrap() + 1; + let mut bytes = base[..at].to_vec(); + bytes.extend_from_slice(b"4 0 obj\n(stale)\nendobj\n"); + let mut table = vec!["0 4".to_string()]; + table.extend(table_entries(&base)); + let doc = Document::open(Arc::new(with_table(&[&bytes[..], &base[at..]].concat(), &table, 6))).unwrap(); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + assert_eq!(*doc.get(ObjRef::new(4, 0)), Object::Null); + assert!(matches!(&*doc.get(ObjRef::new(3, 0)), Object::String(_))); +} + +/// Filling the gaps of a damaged table never brings back an object that an incremental +/// update freed: its body is still in the file, but the newer section says it is gone. +#[test] +fn an_object_freed_by_an_update_stays_freed_when_the_table_is_repaired() { + let base = scanned_page(); + // The original table lists the content stream at offset 0, so the gaps get filled. + let mut table = vec!["0 6".to_string()]; + table.extend(table_entries(&base)); + table[5] = "0000000000 00000 n ".into(); + let mut bytes = with_table(&base, &table, 6); + let prev = String::from_utf8_lossy(&bytes).rfind("\nxref\n").unwrap() + 1; + let section = bytes.len(); + bytes.extend_from_slice( + format!("xref\n5 1\n0000000000 00001 f \ntrailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(matches!(&*doc.get(ObjRef::new(4, 0)), Object::Stream(_)), "the gap is filled"); + assert_eq!(*doc.get(ObjRef::new(5, 0)), Object::Null, "the freed image stays freed"); + assert_eq!(doc.xref_entry(5), Some(XrefEntry::Free { next_generation: 1 })); +} + +/// Repairing a damaged table fills only the numbers the damage leaves out: an old body of a +/// number no section lists, under an oversized `/Size`, stays a null object. +#[test] +fn a_repaired_table_leaves_an_unlisted_number_null() { + let base = scanned_page(); + let at = String::from_utf8_lossy(&base).rfind("\nxref\n").unwrap() + 1; + let entries = table_entries(&base); + let mut offset_zero = vec!["0 6".to_string()]; + offset_zero.extend(entries.iter().cloned()); + offset_zero[5] = "0000000000 00000 n ".into(); + let mut off_by_one = vec!["0 5".to_string()]; + off_by_one.extend(entries[1..6].iter().cloned()); + for (name, table, found) in [("offset 0", offset_zero, 4), ("off by one", off_by_one, 5)] { + let bytes = [&base[..at], b"7 0 obj\n(stale)\nendobj\n", &base[at..]].concat(); + let doc = Document::open(Arc::new(with_table(&bytes, &table, 9))).unwrap(); + assert!(matches!(&*doc.get(ObjRef::new(found, 0)), Object::Stream(_)), "{name}: the gap is filled"); + assert_eq!(*doc.get(ObjRef::new(7, 0)), Object::Null, "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.xref_entry(7), None, "{name}"); + } +} + +/// The last object of a subsection numbered one too low is filled from that subsection, +/// whatever later happens to the row before it: an incremental save that rewrites object 4, +/// or object 4's own row at offset 0. +#[test] +fn the_last_object_of_a_shifted_subsection_is_filled_whatever_its_neighbour_says() { + let base = scanned_page(); + let entries = table_entries(&base); + let mut off_by_one = vec!["0 5".to_string()]; + off_by_one.extend(entries[1..6].iter().cloned()); + let shifted = with_table(&base, &off_by_one, 5); + let prev = String::from_utf8_lossy(&shifted).rfind("\nxref\n").unwrap() + 1; + let mut updated = shifted.clone(); + let body = updated.len(); + updated.extend_from_slice(b"4 0 obj\n<< /Length 21 >>\nstream\n10 0 0 10 0 0 cm /Im0 Do\nendstream\nendobj\n"); + let section = updated.len(); + updated.extend_from_slice( + format!("xref\n4 1\n{body:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(), + ); + let mut neighbour_at_zero = off_by_one.clone(); + neighbour_at_zero[5] = "0000000000 00000 n ".into(); + for (name, bytes) in [("updated", updated), ("row 4 at offset 0", with_table(&base, &neighbour_at_zero, 5))] { + let doc = Document::open(Arc::new(bytes)).unwrap(); + let image = doc.get(ObjRef::new(5, 0)); + assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "{name}: {:?}", doc.repair_log()); + } +} + +/// The image the first page draws as `/Im1`, followed through the page tree. +fn first_page_image(doc: &Document) -> Arc { + let catalog = doc.get(doc.root().unwrap()); + let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap()); + let page = doc.resolve(&pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0]); + let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap()); + let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + doc.resolve(xobjects.as_dict().unwrap().get(b"Im1").unwrap()) +} + +/// The opposite off-by-one (#1628): the subsection says it starts at 1, but its first entry +/// is object 0's free-list head, so every entry lands one number too high and object 1, the +/// image, reads as free. The offset the table gives object 2 holds `1 0 obj`, so the +/// subsection is read from 0, and the image survives a full save. +#[test] +fn a_subsection_from_one_that_opens_with_the_free_list_head_is_read_from_zero() { + let base = build( + &[ + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\nABCDEFGHIJKL\nendstream", + "<< /Length 29 >>\nstream\nq 72 0 0 72 0 0 cm /Im1 Do Q\nendstream", + "<< /Type /Page /Parent 4 0 R /MediaBox [0 0 72 72] /Resources << /XObject << /Im1 1 0 R >> >> /Contents 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Catalog /Pages 4 0 R >>", + "<< /Subject (synthetic) >>", + ], + "/Root 5 0 R /Info 6 0 R", + ); + // /Size 7 is one short of the shifted numbers; /Size 8 covers them all. Junk before the + // header moves every object past the offsets the table gives; it is not a comment, which + // the lexer would skip on its way to `1 0 obj` anyway. + for (size, junk) in [(7, ""), (8, ""), (7, "Content-Type: application/pdf\n\n")] { + let text = String::from_utf8_lossy(&base).replace("\nxref\n0 7\n", "\nxref\n1 7\n").replace("/Size 7 ", &format!("/Size {size} ")); + let doc = Document::open(Arc::new(format!("{junk}{text}").into_bytes())).unwrap(); + let image = first_page_image(&doc); + assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "/Size {size} {junk:?}: {image:?}"); + assert_eq!(doc.xref_entry(0), Some(XrefEntry::Free { next_generation: 65535 }), "/Size {size} {junk:?}"); + assert!(doc.repair_log().iter().any(|l| l.contains("from 0")), "/Size {size} {junk:?}: {:?}", doc.repair_log()); + let saved = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap(); + let image = first_page_image(&saved); + assert!(matches!(&*image, Object::Stream(s) if &s.raw[..] == b"ABCDEFGHIJKL"), "/Size {size} {junk:?}: {image:?}"); + } +} + +/// Object 1 really freed: the subsection from 1 opens with a free entry, but the next entry's +/// offset holds object 2, not object 1. The table is taken as it is, and the old body of +/// object 1 still in the file stays freed. +#[test] +fn a_subsection_from_one_whose_next_offset_holds_object_two_is_taken_as_it_is() { + let base = build(&["(freed)", "<< /Type /Catalog /Pages 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 2 0 R"); + let entries = table_entries(&base); + let mut table = vec!["1 3".to_string(), "0000000000 65535 f ".to_string()]; + table.extend(entries[2..].iter().cloned()); + let text = String::from_utf8_lossy(&base).into_owned(); + let at = text.rfind("\nxref\n").unwrap() + 1; + for junk in ["", "Content-Type: application/pdf\n\n"] { + let mut bytes = junk.as_bytes().to_vec(); + bytes.extend_from_slice(&base[..at]); + bytes.extend_from_slice(format!("xref\n{}\ntrailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n{at}\n%%EOF\n", table.join("\n")).as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.xref_entry(1), Some(XrefEntry::Free { next_generation: 65535 }), "{junk:?}"); + assert_eq!(*doc.get(ObjRef::new(1, 0)), Object::Null, "{junk:?}: the freed object stays freed"); + assert!(matches!(&*doc.get(ObjRef::new(2, 0)), Object::Dict(d) if d.name(b"Type") == Some(&b"Catalog"[..])), "{junk:?}"); + assert!(!doc.repair_log().iter().any(|l| l.contains("from 0")), "{junk:?}: {:?}", doc.repair_log()); + assert!(!junk.is_empty() || doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + } +} + +#[test] +fn opening_an_exhausted_classic_xref_repairs_and_allocates() { + // The extra in-use row need not be loaded to exhaust the next object number. + let bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\nxref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n4294967295 1\n0000000058 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n110\n%%EOF\n"; + assert_eq!(bytes.len(), 266); + let mut doc = Document::open(Arc::new(bytes.to_vec())).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref"))); + assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); +} + +#[test] +fn opening_an_exhausted_xref_stream_repairs_and_allocates() { + let mut bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n".to_vec(); + let offset = bytes.len(); + bytes.extend_from_slice(b"3 0 obj\n<< /Type /XRef /Size 4 /Root 1 0 R /W [1 4 2] /Index [0 3 4294967295 1] /Length 28 >>\nstream\n"); + for (kind, position, generation) in [(0_u8, 0_u32, 65535_u16), (1, 9, 0), (1, 58, 0), (1, 58, 0)] { + bytes.push(kind); + bytes.extend_from_slice(&position.to_be_bytes()); + bytes.extend_from_slice(&generation.to_be_bytes()); + } + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert!(doc.repair_log().iter().any(|line| line.contains("exhausted xref"))); + assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0)); +} + +#[test] +fn exhausted_size_and_high_edits_allocate_without_overwriting_objects() { + let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + for size in ["4294967295", "4294967296", "9223372036854775807"] { + let bytes = String::from_utf8(ordinary.clone()).unwrap().replace("/Size 3", &format!("/Size {size}")); + let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); + assert!(doc.repair_log().iter().any(|line| line.contains("exhausted trailer"))); + assert_eq!(doc.add(Object::Int(42)), ObjRef::new(3, 0)); + doc.set(ObjRef::new(u32::MAX, 0), Object::Int(99)); + doc.set(ObjRef::new(u32::MAX - 1, 0), Object::Int(98)); + assert_eq!(doc.add(Object::Int(43)), ObjRef::new(4, 0)); + assert_eq!(doc.get(ObjRef::new(3, 0)).as_int(), Some(42)); + assert_eq!(doc.get(ObjRef::new(u32::MAX, 0)).as_int(), Some(99)); + assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice())); + } + let mut doc = Document::new_empty(); + doc.set(ObjRef::new(u32::MAX - 2, 0), Object::Null); + assert_eq!(doc.add(Object::Null), ObjRef::new(u32::MAX - 1, 0)); + assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); + assert_eq!(doc.add(Object::Null), ObjRef::new(4, 0)); +} + +#[test] +fn exhausted_xref_controls_preserve_ordinary_ignored_and_repaired_entries() { + let ordinary = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + for (extra, repaired) in + [("", false), ("4294967295 1\n0000000000 00000 n \n", false), ("4294967295 2\n0000000058 00000 n \n0000000058 00000 n \n", true)] + { + let bytes = String::from_utf8(ordinary.clone()).unwrap().replacen("trailer\n", &format!("{extra}trailer\n"), 1); + let mut doc = Document::open(Arc::new(bytes.into_bytes())).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice())); + assert_eq!(!doc.repair_log().is_empty(), repaired); + if repaired { + assert!(doc.repair_log().iter().any(|line| line.contains("xref") && line.contains("range"))); + } + assert_eq!(doc.add(Object::Null), ObjRef::new(3, 0)); + } +} + +#[test] +fn invalid_xref_object_ranges_are_reconstructed() { + // Original in-memory PDF: an intact catalog followed by a damaged xref section. + for stream in [false, true] { + for (start, count) in [(i64::MAX, 2), (-1, 2), (4_294_967_296, 1), (4_294_967_295, 2)] { + let mut bytes = + b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n".to_vec(); + let offset = bytes.len(); + if stream { + bytes.extend_from_slice( + format!("3 0 obj\n<< /Type /XRef /Root 1 0 R /Size 4 /W [1 1 1] /Index [{start} {count}] /Length 6 >>\nstream\n").as_bytes(), + ); + bytes.extend_from_slice(&[0, 0, 0, 0, 0, 0]); + bytes.extend_from_slice(b"\nendstream\nendobj\n"); + } else { + bytes.extend_from_slice( + format!("xref\n{start} {count}\n0000000000 65535 f \n0000000000 65535 f \ntrailer\n<< /Root 1 0 R /Size 3 >>\n").as_bytes(), + ); + } + bytes.extend_from_slice(format!("startxref\n{offset}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!( + doc.repair_log().iter().any(|line| line.contains("xref") && line.contains("range")), + "stream={stream}, start={start}, count={count}: {:?}", + doc.repair_log() + ); + assert_eq!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().name(b"Type"), Some(b"Catalog".as_slice())); + } + } +} + +#[test] +fn objects_only_an_unreadable_older_section_locates_are_found_by_scanning() { + // The newest section lists the catalog only; its /Prev points past the end of the file, + // so the page tree is reachable only by scanning object headers, as readers do (#1656). + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (num, body) in + [(1, "<< /Type /Catalog /Pages 2 0 R >>"), (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"), (3, "<< /Type /Page /Parent 2 0 R >>")] + { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n0 2\n0000000000 65535 f \n{:010} 00000 n \ntrailer\n<< /Root 1 0 R /Size 4 /Prev 999999 >>\nstartxref\n{xref}\n%%EOF\n", + offsets[0] + ) + .as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.get(ObjRef::new(2, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Pages".as_slice())); + assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(b"Page".as_slice())); + assert!(doc.repair_log().iter().any(|l| l.contains("999999")), "{:?}", doc.repair_log()); +} + +#[test] +fn self_referential_length_does_not_overflow() { + let bytes = build( + &["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Length 3 0 R >>\nstream\nabc\nendstream"], + "/Root 1 0 R", + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + // Either recovered via the endstream search or reported — but never a stack overflow. + let o = doc.get(ObjRef::new(3, 0)); + if let Object::Stream(s) = o.as_ref() { + assert_eq!(&s.raw[..], b"abc"); + } +} + +#[test] +fn mutually_referential_lengths_do_not_overflow() { + let bytes = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + "<< /Length 4 0 R >>\nstream\nabc\nendstream", + "<< /Length 3 0 R >>\nstream\nxyz\nendstream", + ], + "/Root 1 0 R", + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let _ = doc.get(ObjRef::new(3, 0)); + let _ = doc.get(ObjRef::new(4, 0)); +} + +#[test] +fn reads_classic_revision_and_edits_are_copy_on_write() { + let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.revisions().len(), 1); + assert!(doc.repair_log().is_empty()); + let mut edited = doc.clone(); + edited.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap(); + assert!(edited.is_modified() && !doc.is_modified(), "clone is an independent snapshot"); + assert!(doc.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); +} + +#[test] +fn object_numbers_merge_source_and_overlay_in_order() { + let mut doc = Document::new_empty(); + doc.overlay.clear(); + doc.entries = Arc::new(XrefIndex::Materialized(Arc::new(BTreeMap::from([ + (0, XrefEntry::Free { next_generation: 65535 }), + (2, XrefEntry::InFile { offset: 10, generation: 7 }), + (4, XrefEntry::Free { next_generation: 9 }), + (6, XrefEntry::InStream { stream: 80, index: 1 }), + (8, XrefEntry::InFile { offset: 20, generation: 5 }), + (10, XrefEntry::InFile { offset: 30, generation: 3 }), + (u32::MAX, XrefEntry::InFile { offset: 40, generation: 1 }), + ])))); + doc.set(ObjRef::new(1, 2), Object::Int(1)); // before the first live source slot + doc.set(ObjRef::new(2, 8), Object::Int(2)); // override, without duplication + doc.set(ObjRef::new(4, 9), Object::Int(4)); // reuse a source free slot + doc.free(ObjRef::new(6, 0)); // remove a compressed source object + doc.free(ObjRef::new(7, 2)); // free a number absent from the source + doc.free(ObjRef::new(10, 3)); + doc.set(ObjRef::new(11, 4), Object::Int(11)); + assert_eq!(doc.object_numbers(), vec![1, 2, 4, 8, 11, u32::MAX]); + assert_eq!(doc.generation(2), 8); + assert_eq!(doc.generation(4), 9); + assert_eq!(doc.generation(6), 1); + assert_eq!(doc.generation(8), 5); + assert_eq!(doc.generation(10), 4); + doc.overlay.clear(); + assert_eq!(doc.object_numbers(), vec![2, 6, 8, 10, u32::MAX]); + doc.entries = Arc::new(XrefIndex::empty()); + assert!(doc.object_numbers().is_empty()); + doc.set(ObjRef::new(5, 7), Object::Int(5)); + assert_eq!(doc.object_numbers(), vec![5]); +} + +#[test] +fn full_object_scans_preserve_edits_without_retaining_the_scan() { + let mut bodies = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_owned(), "<< /Type /Pages /Kids [] /Count 0 >>".to_owned()]; + bodies.extend((0..400).map(|i| format!("<< /Value {i} >>"))); + let refs: Vec<_> = bodies.iter().map(String::as_str).collect(); + let mut doc = Document::open(Arc::new(build(&refs, "/Root 1 0 R"))).unwrap(); + doc.set(ObjRef::new(3, 0), Object::Int(900)); + doc.free(ObjRef::new(4, 0)); + let added = doc.add(Object::Int(901)); + let cached = doc.cache.lock().unwrap().len(); + let held: Vec<_> = doc.scan_objects().collect(); + assert_eq!(held.len(), 402); + assert_eq!(doc.cache.lock().unwrap().len(), cached, "scanning must not populate the editor cache"); + assert!(doc.objstms.lock().unwrap().is_empty()); + assert!(!held.iter().any(|(r, _)| r.num == 4)); + assert_eq!(held.iter().find(|(r, _)| r.num == 3).unwrap().1.as_int(), Some(900)); + assert_eq!(held.iter().find(|(r, _)| *r == added).unwrap().1.as_int(), Some(901)); + assert_eq!(held.iter().find(|(r, _)| r.num == 400).unwrap().1.as_dict().unwrap().int(b"Value"), Some(397)); + // Previously returned objects survive cache eviction and dropping the source. + drop(doc); + assert_eq!(held.iter().find(|(r, _)| r.num == 128).unwrap().1.as_dict().unwrap().int(b"Value"), Some(125)); +} + +#[test] +fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() { + // A stream read from the file is a view into the input: caching it costs no copy, so + // even a large one stays cached (its /Length object too) instead of being re-parsed. + let body = "x".repeat(ObjectReader::STREAM_BYTES + 1); + let stream = format!("<< /Length 4 0 R >>\nstream\n{body}\nendstream"); + let length = body.len().to_string(); + let bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream, &length], "/Root 1 0 R"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let mut reader = doc.object_reader(); + let read = reader.get(ObjRef::new(3, 0)); + let Object::Stream(s) = &*read else { panic!("stream") }; + assert!(s.raw.shares(&reader.document.data), "no copy of the file's bytes"); + assert!(reader.document.cache.lock().unwrap().contains_key(&3), "a view costs nothing to keep"); + // Data of its own (decrypted streams, strings) counts: past the budget the cache is + // cleared after the next read, including a failed one. + let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1])); + reader.document.cache.lock().unwrap().insert(3, Arc::new(owned)); + assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null); + assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted"); +} + +#[test] +fn temporary_reader_and_full_save_do_not_fill_snapshot_caches() { + let mut source = Document::new_empty(); + let objects: Vec<_> = (0..260) + .map(|i| { + let mut value = vec![b'x'; 48 * 1024]; + value[..4].copy_from_slice(&(i as u32).to_le_bytes()); + Object::Ref(source.add(Object::String(crate::PdfString::literal(value)))) + }) + .collect(); + source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); + let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); + let doc = Document::open(bytes).unwrap(); + let root = doc.get(doc.root().unwrap()); + let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap(); + let snapshot = doc.clone(); + let cached = doc.cache.lock().unwrap().len(); + let streams = doc.objstms.lock().unwrap().len(); + let mut reader = doc.object_reader(); + let mut held = None; + for (i, reference) in references.iter().enumerate() { + let Object::Ref(reference) = reference else { panic!("indirect object") }; + let object = reader.get(*reference); + assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes()); + if i == 0 { + held = Some(object); + } + assert!(reader.document.cache.lock().unwrap().len() < ObjectReader::OBJECTS); + let decoded = reader.document.objstms.lock().unwrap(); + assert!(decoded.len() < ObjectReader::OBJECTS); + let bytes: usize = decoded.values().map(|s| s.data.capacity() + s.index.capacity() * std::mem::size_of::>()).sum(); + assert!(bytes <= ObjectReader::STREAM_BYTES || decoded.len() == 1, "only one oversized stream may remain"); + } + drop(reader); + assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes(), "owned results survive evictions"); + for object_streams in [false, true] { + let saved = crate::write_full(&doc, &crate::SaveOptions { object_streams, ..Default::default() }).unwrap(); + assert!(Document::open(Arc::new(saved)).unwrap().repair_log().is_empty()); + assert_eq!(doc.cache.lock().unwrap().len(), cached, "full saves must not fill the editor cache"); + assert_eq!(doc.objstms.lock().unwrap().len(), streams); + assert!(Arc::ptr_eq(&doc.cache, &snapshot.cache), "do not detach or clear caller snapshots"); + } +} + +#[test] +fn streams_without_length_share_one_endstream_search() { + let n = 2_000; + let mut file = b"%PDF-1.7\n".to_vec(); + for k in 1..=n { + file.extend_from_slice(format!("{k} 0 obj\n<< >>\nstream\nxx\n").as_bytes()); + } + file.extend_from_slice(b"endstream\nendobj\n"); + file.extend_from_slice( + format!("{0} 0 obj\n<< /Type /Catalog /Pages {1} 0 R >>\nendobj\n{1} 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n", n + 1, n + 2) + .as_bytes(), + ); + file.extend_from_slice(format!("trailer\n<< /Root {} 0 R /Size {} >>\n%%EOF\n", n + 1, n + 3).as_bytes()); + let len = file.len(); + crate::parser::ENDSTREAM_SCAN.with(|c| c.set(0)); + let doc = Document::open(Arc::new(file)).unwrap(); + for k in [1, n / 2, n] { + assert!(matches!(doc.try_get(k).as_deref(), Ok(Object::Stream(_))), "object {k}"); + } + let scanned = crate::parser::ENDSTREAM_SCAN.with(|c| c.get()); + assert!(scanned <= 4 * len, "{scanned} bytes searched in a {len}-byte file"); +} + +#[test] +fn editor_cache_counts_deeply_nested_strings() { + let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20])); + for _ in 0..8 { + nested = Object::Array(vec![nested]); + } + let mut source = Document::new_empty(); + let objects: Vec<_> = (0..48).map(|_| Object::Ref(source.add(nested.clone()))).collect(); + source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); + let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); + let doc = Document::open(bytes).unwrap(); + assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20); + let root = doc.get(doc.root().unwrap()); + for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() { + let Object::Ref(reference) = reference else { panic!("indirect object") }; + drop(doc.get(*reference)); + let cache = doc.cache.lock().unwrap(); + let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); + let strings = cache.values().filter(|o| o.as_array().is_some()).count(); + assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted"); + } +} + +#[test] +fn editor_cache_stays_bounded() { + let mut source = Document::new_empty(); + let objects: Vec<_> = (0..(Document::EDITOR_CACHE_OBJECTS + 64)) + .map(|i| { + let mut value = vec![b'x'; 1024]; + value[..4].copy_from_slice(&(i as u32).to_le_bytes()); + Object::Ref(source.add(Object::String(crate::PdfString::literal(value)))) + }) + .collect(); + source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); + let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); + let doc = Document::open(bytes).unwrap(); + let root = doc.get(doc.root().unwrap()); + let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap().clone(); + let mut held = None; + for (i, reference) in references.iter().enumerate() { + let Object::Ref(reference) = reference else { panic!("indirect object") }; + let object = doc.get(*reference); + assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes()); + if i == 0 { + held = Some(object); + } + assert!(doc.cache.lock().unwrap().len() <= Document::EDITOR_CACHE_OBJECTS, "editing must not pin the whole object graph"); + // Eviction keeps the newcomer. + assert!(doc.cache.lock().unwrap().contains_key(&reference.num), "eviction must keep the newcomer"); + } + // Returned objects survive eviction, through both handles of the shared cache. + assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes()); + let snapshot = doc.clone(); + let first = references[0].as_ref().unwrap(); + assert_eq!(&snapshot.get(first).as_string().unwrap().bytes[..4], &0u32.to_le_bytes()); +} + +#[test] +fn editor_cache_byte_bound_fires() { + // 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget. Each + // newcomer exceeds EDITOR_LARGE_OBJECT, so every insert takes a full accounting pass. + let mut source = Document::new_empty(); + let objects: Vec<_> = (0..40) + .map(|i| { + let mut value = vec![b'y'; 1024 * 1024]; + value[..4].copy_from_slice(&(i as u32).to_le_bytes()); + Object::Ref(source.add(Object::String(crate::PdfString::literal(value)))) + }) + .collect(); + source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); + let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); + let doc = Document::open(bytes).unwrap(); + let root = doc.get(doc.root().unwrap()); + let references = root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap().clone(); + let mut held = None; + for (i, reference) in references.iter().enumerate() { + let Object::Ref(reference) = reference else { panic!("indirect object") }; + let object = doc.get(*reference); + assert_eq!(&object.as_string().unwrap().bytes[..4], &(i as u32).to_le_bytes()); + if i == 0 { + held = Some(object); + } + // Eviction keeps the newcomer. + assert!(doc.cache.lock().unwrap().contains_key(&reference.num), "eviction must keep the newcomer"); + } + assert!(doc.cache.lock().unwrap().len() < references.len(), "the byte budget must evict even below the count cap"); + let last = references[references.len() - 1].as_ref().unwrap(); + assert!(doc.cache.lock().unwrap().contains_key(&last.num), "eviction must keep the newcomer"); + assert_eq!(&held.unwrap().as_string().unwrap().bytes[..4], &0u32.to_le_bytes()); +} + +#[test] +fn a_stream_limited_document_cannot_be_saved() { + let bytes = Arc::new(crate::write_full(&Document::new_empty(), &crate::SaveOptions::default()).unwrap()); + let doc = Document::open_with_stream_limit(bytes.clone(), None, 1 << 20).unwrap(); + for saved in [crate::write_full(&doc, &crate::SaveOptions::default()), crate::write_incremental(&doc, &crate::SaveOptions::default())] { + assert!(matches!(saved, Err(CosError::ReadOnlyLimit)), "{saved:?}"); + } + let doc = Document::open(bytes).unwrap(); + assert!(crate::write_full(&doc, &crate::SaveOptions::default()).is_ok()); +} + +#[test] +fn inspection_stream_limit_applies_before_loading_compressed_objects() { + let mut doc = Document::new_empty(); + let large = doc.add(Object::String(crate::PdfString::literal(vec![b'x'; 4096]))); + doc.update_dict(doc.root().unwrap(), |catalog| catalog.set(b"Extension".to_vec(), Object::Ref(large))).unwrap(); + let bytes = Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap()); + assert!(Document::open(bytes.clone()).is_ok()); + assert!(Document::open_with_stream_limit(bytes, None, 512).is_err()); +} + +/// A /Prev chain that loops back on itself stops at the loop (and says so). +#[test] +fn looping_revision_chains_stop() { + let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + // An update whose /Prev points at itself. + let at = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 f \ntrailer << /Size 3 /Root 1 0 R /Prev {at} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert!(doc.repair_log().iter().any(|l| l.contains("loops")), "{:?}", doc.repair_log()); +} + +/// A hybrid-reference file: the classic table leaves object 3 out; the `/XRefStm` stream +/// says it lives in object stream 4. +#[test] +fn hybrid_reference_files_read_the_xref_stream_too() { + let doc = Document::open(Arc::new(object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0))).unwrap(); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + assert_compact_matches_materialized(&doc); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); +} + +/// A hybrid-reference file whose classic table leaves object 3 out, and whose `/XRefStm` +/// stream puts it at position `member` of object stream 4, which holds `header` (the number +/// and offset pairs) and then `members`. +fn object_stream_file(header: &str, members: &str, member: u8) -> Vec { + let mut out = b"%PDF-1.5\n".to_vec(); + let mut offs = [0usize; 6]; + let mut obj = |out: &mut Vec, n: usize, body: &[u8]| { + offs[n] = out.len(); + out.extend_from_slice(format!("{n} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + }; + obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>"); + obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>"); + let count = header.split_whitespace().count() / 2; + let inner = format!("{header}{members}"); + let mut stm = format!("<< /Type /ObjStm /N {count} /First {} /Length {} >>\nstream\n", header.len(), inner.len()).into_bytes(); + stm.extend_from_slice(inner.as_bytes()); + stm.extend_from_slice(b"\nendstream"); + obj(&mut out, 4, &stm); + let data = [2u8, 0, 4, member]; + let mut xs = format!("<< /Type /XRef /Size 6 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes(); + xs.extend_from_slice(&data); + xs.extend_from_slice(b"\nendstream"); + obj(&mut out, 5, &xs); + let table = out.len(); + out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for (n, off) in offs.iter().enumerate().skip(1) { + let line = if n == 3 { "0000000000 65535 f \n".to_string() } else { format!("{off:010} 00000 n \n") }; + out.extend_from_slice(line.as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes()); + out +} + +/// Many revisions naming one hybrid /XRefStm read it once. +#[test] +fn a_hybrid_stream_named_by_many_revisions_is_read_once() { + let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0); + let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap(); + let mut prev = rfind(&out, b"\nxref\n", 0).unwrap() + 1; + let revisions = 200; + for _ in 0..revisions { + let at = out.len(); + out.extend_from_slice(format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes()); + prev = at; + } + let doc = Document::open(Arc::new(out)).unwrap(); + SECTION_READS.with(|c| c.set(0)); + let (entries, ..) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap(); + let reads = SECTION_READS.with(|c| c.get()); + assert!(reads <= revisions + 2, "{reads} sections read for {} revisions", revisions + 1); + assert!(matches!(entries.get(&3), Some(XrefEntry::InStream { stream: 4, .. })), "{:?}", entries.get(&3)); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); + assert_compact_matches_materialized(&doc); +} + +/// An older section that names an /XRefStm a newer section already read still has its own +/// table compared with that stream, so an entry it sets aside is recorded for it. +#[test] +fn a_repeated_hybrid_stream_records_each_sections_conflicts() { + let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0); + let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap(); + let catalog = find(&out, b"1 0 obj", 0, out.len()).unwrap(); + let base = rfind(&out, b"\nxref\n", 0).unwrap() + 1; + // This section's table places object 3 at the catalog; the stream puts it in object stream 4. + let older = out.len(); + out.extend_from_slice( + format!("xref\n3 1\n{catalog:010} 00000 n \ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {base} >>\nstartxref\n{older}\n%%EOF\n") + .as_bytes(), + ); + let newer = out.len(); + out.extend_from_slice(format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {older} >>\nstartxref\n{newer}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + assert_eq!(doc.xref_oddities(), [(older as u64, 3)]); + assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes")); +} + +fn text_of(doc: &Document, num: u32, key: &[u8]) -> Option { + doc.get(ObjRef::new(num, 0)).as_dict().and_then(|d| d.get(key).and_then(|v| v.as_string().map(|s| s.to_text()))) +} + +/// An object stream member numbered 2^32 + 3 is not object 3 (#1815): the number used to be +/// cast to `u32`, which dropped the high bits. +#[test] +fn object_stream_numbers_beyond_u32_are_not_truncated() { + let control = Document::open(Arc::new(object_stream_file("3 0 ", "<< /Member (yes) >>", 0))).unwrap(); + assert_eq!(text_of(&control, 3, b"Member").as_deref(), Some("yes")); + let doc = Document::open(Arc::new(object_stream_file("4294967299 0 ", "<< /Member (yes) >>", 0))).unwrap(); + assert!(matches!(*doc.get(ObjRef::new(3, 0)), Object::Null), "{:?}", doc.get(ObjRef::new(3, 0))); + assert!(!doc.xref_entry_is_exact(3)); +} + +/// Member starts are checked rather than cast, so they cannot wrap on any target (#1815). +#[test] +fn object_stream_member_starts_are_checked() { + assert_eq!(objstm_member(3, 4, Some(10), 20), Some((3, 14))); + assert_eq!(objstm_member(3, 9, Some(10), 20), Some((3, 19)), "the last byte"); + assert_eq!(objstm_member(3, 10, Some(10), 20), None, "at the end of the data"); + assert_eq!(objstm_member(3, 1, Some(usize::MAX), 20), None, "/First plus the offset overflows"); + assert_eq!(objstm_member(3, i64::MAX, Some(10), 20), None); + assert_eq!(objstm_member(3, -1, Some(10), 20), None); + assert_eq!(objstm_member(3, 0, None, 20), None, "/First does not fit in usize"); + assert_eq!(objstm_member(i64::from(u32::MAX), 0, Some(0), 1), Some((u32::MAX, 0))); + assert_eq!(objstm_member(i64::from(u32::MAX) + 1, 0, Some(0), 1), None); + assert_eq!(objstm_member(-1, 0, Some(0), 1), None); +} + +/// A member at a negative offset is unusable, not the stream's first member (#1815): the +/// offset used to be clamped to zero, which read the object at `/First`. +#[test] +fn object_stream_negative_offsets_are_refused() { + let doc = Document::open(Arc::new(object_stream_file("7 0 3 -1 ", "<< /First (yes) >>", 1))).unwrap(); + assert!(matches!(*doc.get(ObjRef::new(3, 0)), Object::Null), "{:?}", doc.get(ObjRef::new(3, 0))); + let control = Document::open(Arc::new(object_stream_file("7 0 3 19 ", "<< /First (yes) >> << /Second (yes) >>", 1))).unwrap(); + assert_eq!(text_of(&control, 3, b"Second").as_deref(), Some("yes")); +} + +#[test] +fn revision_ends_split_incremental_updates() { + let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + let first = bytes.len(); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + assert_eq!(doc.revision_ends(), vec![first]); + let mut edited = doc.clone(); + edited.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(crate::PdfString::literal("en")))).unwrap(); + bytes = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); + let doc = Document::open(Arc::new(bytes.clone())).unwrap(); + let ends = doc.revision_ends(); + assert_eq!(ends.len(), 2, "{ends:?}"); + assert!(ends[0] >= first && ends[0] < bytes.len() && ends[1] == bytes.len(), "{ends:?} of {}", bytes.len()); + // The first revision opens on its own, without the update. + let old = Document::open(Arc::new(bytes[..ends[0]].to_vec())).unwrap(); + assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); +} + +/// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream +/// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99. +fn compressed_in_compressed_fixture() -> Document { + let mut out = b"%PDF-1.5\n".to_vec(); + let at = |out: &mut Vec, num: u32, body: &str| { + let off = out.len(); + out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + off as u16 + }; + let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>"); + let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>"); + let inner = "<< /Type /Outlines /Count 0 >>"; + at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len())); + // /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3. + let rows: Vec<[u8; 4]> = + vec![[0, 0, 0, 255], [1, 0, o1 as u8, 0], [1, 0, o2 as u8, 0], [2, 0, 99, 0], [2, 0, 3, 0], [1, (out.len() >> 8) as u8, out.len() as u8, 0]]; + let rows: Vec = rows.concat(); + let xref = out.len(); + out.extend_from_slice( + format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + out.extend_from_slice(&rows); + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// An object stream whose own cross-reference entry says it is compressed inside another +/// stream (which the format forbids, and which does not exist here: qpdf's +/// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it +/// read as null and a full save wrote `/Outlines null`. The stream is now found by its own +/// header, as a plain object with a wrong offset is. +#[test] +fn an_object_stream_marked_as_compressed_is_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log()); + let outlines = doc.get(ObjRef::new(4, 0)); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}"); + let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap()); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}"); +} + +/// The header fallback is for file entries only: once the stream is freed (or replaced) in +/// the overlay, its objects must not come back from the file's copy of it. +#[test] +fn a_freed_object_stream_is_not_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some()); + let mut edited = doc.clone(); + edited.free(ObjRef::new(3, 0)); + let fresh = edited.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + let mut replaced = doc.clone(); + replaced.set(ObjRef::new(3, 0), Object::Int(0)); + let fresh = replaced.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); +} + +/// The same once the free is saved: the revision's table says the stream is free, and +/// neither reading the file back nor rewriting it in full brings its objects back. +#[test] +fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() { + let mut edited = compressed_in_compressed_fixture(); + edited.free(ObjRef::new(3, 0)); + let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(incremental)).unwrap(); + assert_eq!(reopened.revisions().len(), 2); + assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. }))); + assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0))); + for object_streams in [true, false] { + let opts = crate::SaveOptions { object_streams, ..Default::default() }; + let full = crate::write_full(&reopened, &opts).unwrap(); + let back = Document::open(Arc::new(full)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o)); + assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}"); + } +} + +/// A trailer whose /Prev is written as the reference `N 0 R` (tiff2pdf did this; the files +/// are JHOVE's PDF-HUL-157 cases, #2366) used to end the cross-reference chain silently, so +/// every object of the earlier sections read as null: a four-page file showed one page, and +/// a full save wrote the three missing kids as null. The offset is read from the reference. +#[test] +fn a_prev_written_as_a_reference_still_reaches_the_earlier_sections() { + let base = + build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"], "/Root 1 0 R"); + let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap(); + let update = |prev_entry: &str| { + let mut bytes = base.clone(); + let at = bytes.len(); + bytes.extend_from_slice(b"4 0 obj\n<< /Title (Update) >>\nendobj\n"); + let xref = bytes.len(); + bytes.extend_from_slice( + format!("xref\n4 1\n{at:010} 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R /Info 4 0 R /Prev {prev_entry} >>\nstartxref\n{xref}\n%%EOF\n") + .as_bytes(), + ); + Document::open(Arc::new(bytes)).unwrap() + }; + let good = update(&prev.to_string()); + let bad = update(&format!("{prev} 0 R")); + assert!(good.repair_log().is_empty(), "{:?}", good.repair_log()); + assert_eq!(bad.revisions().len(), 2, "{:?}", bad.repair_log()); + assert_eq!(bad.repair_log().len(), 1, "a note about the /Prev: {:?}", bad.repair_log()); + assert_eq!(bad.xref_entry(3), good.xref_entry(3), "the page of the first section is reached"); + assert_eq!(bad.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(&b"Page"[..])); + assert_eq!( + bad.get(ObjRef::new(4, 0)).as_dict().and_then(|d| d.get(b"Title").and_then(|t| t.as_string().map(|s| s.to_text()))), + Some("Update".into()) + ); +} + +/// iText's `indirectPrev.pdf` in miniature: the update's trailer gives `/Prev 4 0 R`, and +/// object 4, listed in that update, holds the offset of the first section. Read as offset 4 +/// (inside the header), the chain broke and the file was rebuilt without its revisions. +#[test] +fn a_prev_reference_to_an_object_holding_the_offset_reads_that_offset() { + let mut bytes = + build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"], "/Root 1 0 R"); + let prev = String::from_utf8_lossy(&bytes).rfind("xref\n0 4").unwrap(); + let mut at = Vec::new(); + for (num, body) in + [(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".to_string()), (4, prev.to_string()), (5, "<< /Type /Page /Parent 2 0 R >>".into())] + { + at.push(bytes.len()); + bytes.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n2 1\n{:010} 00000 n \n4 2\n{:010} 00000 n \n{:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R /Prev 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", + at[0], at[1], at[2] + ) + .as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.revisions().len(), 2, "{:?}", doc.repair_log()); + assert_eq!(doc.repair_log().len(), 1, "a note about the /Prev, and no rebuild: {:?}", doc.repair_log()); + assert!(doc.repair_log()[0].contains(&format!("the offset {prev}")), "{:?}", doc.repair_log()); + assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(&b"Page"[..])); + assert_compact_matches_materialized(&doc); +} + +/// A file whose only trailers are cross-reference stream dictionaries, with its `startxref` +/// out of reach (here a second file appended after it, cut off before its own +/// cross-reference data, as in #2365), is rebuilt from its object headers. The rebuild used +/// to look only for the `trailer` keyword, so /Info and /ID were lost, and a full save after +/// editing one Info field dropped every other. +#[test] +fn reconstruction_takes_the_trailer_from_a_cross_reference_stream() { + let table = build( + &["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) /Creator (Me) >>"], + "/Root 1 0 R /Info 3 0 R", + ); + let packed = crate::write_full(&Document::open(Arc::new(table)).unwrap(), &crate::SaveOptions::default()).unwrap(); + let text = String::from_utf8_lossy(&packed); + assert!(text.contains("/XRef") && !text.contains("trailer"), "a cross-reference stream is the only trailer"); + let mut bytes = packed.clone(); + let appended = format!("%PDF-1.3\n90 0 obj\n<< /Length 5000 >>\nstream\n{}\nendstream\nendobj\n", "x".repeat(5000)); + bytes.extend_from_slice(appended.as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(doc.revisions().is_empty(), "rebuilt: {:?}", doc.repair_log()); + let info = doc.trailer().reference(b"Info").expect("the cross-reference stream's /Info"); + let field = |d: &Document, key: &[u8]| d.get(info).as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone(); + assert_eq!(field(&doc, b"Title"), b"Kept"); + assert!(doc.trailer().get(b"ID").is_some(), "and its /ID"); + let mut edited = doc.clone(); + edited.update_dict(info, |d| d.set(b"Subject".to_vec(), Object::String(crate::PdfString::literal("new")))).unwrap(); + let saved = crate::write_full(&edited, &crate::SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + let info = back.trailer().reference(b"Info").expect("the full save keeps /Info"); + let field = |key: &[u8]| back.get(info).as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone(); + assert_eq!((field(b"Title"), field(b"Creator"), field(b"Subject")), (b"Kept".to_vec(), b"Me".to_vec(), b"new".to_vec())); +} + +/// A rebuild prefers a trailer whose /Root is the catalog (as `root_is_catalog` judges it): a +/// later cross-reference stream whose /Root points at the Info dictionary does not replace an +/// earlier good trailer, or a full save would write the Info dictionary as /Root. +#[test] +fn reconstruction_skips_a_trailer_whose_root_is_not_the_catalog() { + let mut bytes = + build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) >>"], "/Root 1 0 R /Info 3 0 R"); + bytes.extend_from_slice(b"4 0 obj\n<< /Type /XRef /Size 5 /W [1 1 1] /Root 3 0 R /Length 3 >>\nstream\nabc\nendstream\nendobj\n"); + let appended = format!("%PDF-1.3\n90 0 obj\n<< /Length 5000 >>\nstream\n{}\nendstream\nendobj\n", "x".repeat(5000)); + bytes.extend_from_slice(appended.as_bytes()); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt")), "{:?}", doc.repair_log()); + assert_eq!(doc.trailer().reference(b"Root").map(|r| r.num), Some(1), "the catalog, not the Info dictionary"); + assert_eq!(doc.trailer().reference(b"Info").map(|r| r.num), Some(3)); +} + +#[test] +fn damaged_xref_is_reconstructed() { + let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R"); + let n = bytes.len(); + bytes[n - 12..n - 6].copy_from_slice(b"999999"); // point startxref into nowhere + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(doc.revisions().is_empty()); + assert!(!doc.repair_log().is_empty()); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); +} + +#[test] +fn a_decompression_bomb_in_an_xref_stream_is_refused_and_the_file_reconstructed() { + // 32 MiB of rows behind two FlateDecode filters (a few hundred bytes in the file) for + // /W [1 1 1]: at 3 bytes a row that is more than 8,388,607 objects' worth, so it is not + // decoded (in full: the limit applies while inflating) and the objects are found by + // reconstruction instead. + let bomb = pdfcraft_filters::encode_flate(&pdfcraft_filters::encode_flate(&vec![0u8; 32 << 20])); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let o1 = bytes.len(); + bytes.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + let o2 = bytes.len(); + bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + let x = bytes.len(); + bytes.extend_from_slice( + format!("3 0 obj\n<< /Type /XRef /Size 4 /W [1 1 1] /Root 1 0 R /Filter [/FlateDecode /FlateDecode] /Length {} >>\nstream\n", bomb.len()) + .as_bytes(), + ); + bytes.extend_from_slice(&bomb); + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{x}\n%%EOF\n").as_bytes()); + assert!(o1 < o2 && o2 < x); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.root(), Some(ObjRef::new(1, 0))); + assert!(doc.repair_log().iter().any(|l| l.contains("limit")), "{:?}", doc.repair_log()); +} + +#[test] +fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() { + // From the nightly fuzz job: `6 0 obj << /Length 6 0 R >> stream` with no `endstream` + // after it. Every parse attempt failed, and each attempt resolved the length by loading + // object 6 again, retrying the same ways down to the nesting limit: about 2^32 parses. + // Objects being loaded are now a seen-set, lengths are memoised, and the second parse + // only runs when the header is shifted. + let mut bytes = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n".to_vec(); + bytes.extend_from_slice(b"6 0 obj\n<< /Length 6 0 R >>\nstream\nno end marker follows\n"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + if let Ok(doc) = Document::open(Arc::new(bytes)) { + let _ = doc.try_get(6); + } + let _ = tx.send(()); + }); + rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially"); +} diff --git a/crates/cos/src/document/xref.rs b/crates/cos/src/document/xref.rs new file mode 100644 index 0000000000..02390ff7da --- /dev/null +++ b/crates/cos/src/document/xref.rs @@ -0,0 +1,277 @@ +use super::{Arc, BTreeMap, Lexer}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XrefEntry { + Free { next_generation: u16 }, + InFile { offset: u64, generation: u16 }, + InStream { stream: u32, index: u32 }, +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct XrefRange { + pub(super) start: u32, + pub(super) count: u32, + pub(super) offset: usize, + pub(super) stride: usize, +} + +#[derive(Clone, Debug)] +pub(super) enum XrefSectionData { + Table, + Stream { bytes: Arc>, widths: [usize; 3] }, +} + +#[derive(Clone, Debug)] +pub(super) struct XrefSection { + pub(super) data: XrefSectionData, + pub(super) ranges: Vec, + pub(super) row_count: usize, + pub(super) max_num: u32, + pub(super) has_entries: bool, + /// A table subsection was read one number lower than it says ([`subsection_start`]). + pub(super) renumbered: bool, +} + +#[derive(Clone, Debug)] +pub(super) struct XrefRevisionSection { + pub(super) primary: XrefSection, + pub(super) hybrid: Option, +} + +#[derive(Clone, Debug)] +pub(super) enum XrefIndex { + Compact { revisions: Vec, max_num: u32, has_entries: bool }, + Materialized(Arc>), +} + +pub(super) const MAX_COMPACT_XREF_ROWS: usize = 2_000_000; + +pub(super) const MAX_COMPACT_XREF_BYTES: usize = 64 * 1024 * 1024; + +impl XrefSection { + pub(super) fn has_any_entry(&self) -> bool { + match &self.data { + XrefSectionData::Table => self.has_entries, + XrefSectionData::Stream { bytes, widths } => { + let row_width = widths.iter().sum::(); + if row_width == 0 { + return false; + } + for range in &self.ranges { + let available = bytes.len().saturating_sub(range.offset) / row_width; + for i in 0..available.min(range.count as usize) { + let Some(offset) = i.checked_mul(row_width).and_then(|delta| range.offset.checked_add(delta)) else { continue }; + let Some(row) = bytes.get(offset..offset.saturating_add(row_width)) else { continue }; + let read_field = |from: usize, width: usize, default: u64| -> Option { + if width == 0 { + return Some(default); + } + let end = from.checked_add(width)?; + Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte))) + }; + let Some(kind) = read_field(0, widths[0], 1) else { continue }; + if kind == 0 || kind == 2 { + return true; + } + if kind == 1 && read_field(widths[0], widths[1], 0).is_some_and(|offset| offset != 0) { + return true; + } + } + } + false + } + } + } + + pub(super) fn retained_bytes(&self) -> usize { + match &self.data { + XrefSectionData::Table => 0, + XrefSectionData::Stream { bytes, .. } => bytes.capacity(), + } + } + + pub(super) fn lookup(&self, num: u32, file: &[u8]) -> Option { + let after = self.ranges.partition_point(|range| range.start <= num); + let range = self.ranges.get(after.checked_sub(1)?)?; + let end = u64::from(range.start).checked_add(u64::from(range.count))?; + if u64::from(num) >= end { + return None; + } + let row = usize::try_from(num.checked_sub(range.start)?).ok()?; + let offset = range.offset.checked_add(row.checked_mul(range.stride)?)?; + match &self.data { + XrefSectionData::Table => { + let mut lexer = Lexer::new(file, offset); + lexer.skip_ws(); + let object_offset = lexer.token(); + lexer.skip_ws(); + let generation = lexer.token(); + lexer.skip_ws(); + let kind = lexer.token(); + match kind { + b"n" => { + let object_offset = std::str::from_utf8(object_offset).ok()?.parse::().ok()?; + if object_offset == 0 { + return None; + } + let generation = std::str::from_utf8(generation).ok()?.parse::().unwrap_or(0).min(u16::MAX as u32) as u16; + Some(XrefEntry::InFile { offset: object_offset, generation }) + } + b"f" => { + let generation = std::str::from_utf8(generation).ok()?.parse::().unwrap_or(0).min(u16::MAX as u32) as u16; + Some(XrefEntry::Free { next_generation: generation }) + } + _ => None, + } + } + XrefSectionData::Stream { bytes, widths } => { + let row_width = widths.iter().sum::(); + let end = offset.checked_add(row_width)?; + let row = bytes.get(offset..end)?; + let field = |from: usize, width: usize, default: u64| -> Option { + if width == 0 { + return Some(default); + } + let end = from.checked_add(width)?; + Some(row.get(from..end)?.iter().fold(0u64, |acc, byte| acc.wrapping_shl(8) | u64::from(*byte))) + }; + let kind = field(0, widths[0], 1)?; + let a = field(widths[0], widths[1], 0)?; + let b = field(widths[0] + widths[1], widths[2], 0)?; + let entry = match kind { + 0 => XrefEntry::Free { next_generation: b.min(u16::MAX as u64) as u16 }, + 1 => XrefEntry::InFile { offset: a, generation: b.min(u16::MAX as u64) as u16 }, + 2 => XrefEntry::InStream { stream: a.min(u32::MAX as u64) as u32, index: b.min(u32::MAX as u64) as u32 }, + _ => return None, + }; + (!matches!(entry, XrefEntry::InFile { offset: 0, .. })).then_some(entry) + } + } + } + + pub(super) fn extend_numbers(&self, numbers: &mut Vec) { + for range in &self.ranges { + let available = match &self.data { + XrefSectionData::Table => range.count as usize, + XrefSectionData::Stream { bytes, widths } => { + let row_width = widths.iter().sum::(); + bytes.len().saturating_sub(range.offset).checked_div(row_width).unwrap_or(0) + } + }; + for i in 0..available.min(range.count as usize) { + if let Ok(i) = u32::try_from(i) + && let Some(num) = range.start.checked_add(i) + { + numbers.push(num); + } + } + } + } +} + +impl XrefRevisionSection { + pub(super) fn lookup(&self, num: u32, file: &[u8]) -> Option { + let primary = self.primary.lookup(num, file); + let hybrid = self.hybrid.as_ref().and_then(|section| section.lookup(num, file)); + match primary { + None | Some(XrefEntry::Free { .. }) => hybrid.or(primary), + entry => entry, + } + } + + fn extend_numbers(&self, numbers: &mut Vec) { + self.primary.extend_numbers(numbers); + if let Some(hybrid) = &self.hybrid { + hybrid.extend_numbers(numbers); + } + } +} + +impl XrefIndex { + pub(super) fn empty() -> Self { + Self::Materialized(Arc::new(BTreeMap::new())) + } + + pub(super) fn lookup(&self, num: u32, file: &[u8]) -> Option { + match self { + Self::Compact { revisions, .. } => revisions.iter().find_map(|revision| revision.lookup(num, file)), + Self::Materialized(entries) => entries.get(&num).copied(), + } + } + + pub(super) fn is_empty(&self) -> bool { + match self { + Self::Compact { has_entries, .. } => !has_entries, + Self::Materialized(entries) => entries.is_empty(), + } + } + + /// Drop an entry for object number 4294967295, which no incremental `/Size` can cover. + /// Only a materialized index can hold one: the compact reader requires `max_num < /Size`. + pub(super) fn remove_exhausted(&mut self) -> bool { + match self { + Self::Compact { .. } => false, + Self::Materialized(entries) => Arc::make_mut(entries).remove(&u32::MAX).is_some(), + } + } + + /// Every entry, newest section first, as the materialized reader keeps them. + pub(super) fn to_map(&self, file: &[u8]) -> BTreeMap { + match self { + Self::Materialized(entries) => entries.as_ref().clone(), + Self::Compact { revisions, .. } => { + let mut numbers = Vec::new(); + for revision in revisions { + revision.extend_numbers(&mut numbers); + } + numbers.into_iter().filter_map(|num| self.lookup(num, file).map(|entry| (num, entry))).collect() + } + } + } + + pub(super) fn max_num(&self) -> u32 { + match self { + Self::Compact { max_num, .. } => *max_num, + Self::Materialized(entries) => entries.keys().next_back().copied().unwrap_or(0), + } + } + + pub(super) fn object_numbers(&self, file: &[u8]) -> Vec { + match self { + Self::Materialized(entries) => { + entries.iter().filter_map(|(&num, entry)| (!matches!(entry, XrefEntry::Free { .. })).then_some(num)).collect() + } + Self::Compact { revisions, .. } => { + if let [revision] = revisions.as_slice() + && revision.hybrid.is_none() + { + let mut numbers = Vec::with_capacity(revision.primary.row_count); + revision.primary.extend_numbers(&mut numbers); + numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. }))); + return numbers; + } + let mut numbers = Vec::new(); + for revision in revisions { + revision.extend_numbers(&mut numbers); + } + numbers.sort_unstable(); + numbers.dedup(); + numbers.retain(|num| self.lookup(*num, file).is_some_and(|entry| !matches!(entry, XrefEntry::Free { .. }))); + numbers + } + } + } +} + +/// One cross-reference section of the file (one per save; the first is the original). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Revision { + /// Offset of the xref section (`startxref` value). + pub xref_offset: u64, + /// `true` if the section is a cross-reference stream. + pub is_stream: bool, + /// `true` if `xref_offset` counts from the `%PDF-` header rather than from the start of the + /// file. The two differ only in a file with bytes before its header, and an update appended + /// to such a file must count the same way as the section it points back to. + pub header_relative: bool, +} diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 152daed2de..f3be66b9a1 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -13,6 +13,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod budget; mod bytes; mod document; mod object; @@ -21,6 +22,7 @@ mod parser; mod security; mod writer; +pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS}; pub use bytes::Bytes; pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 438b8d5f35..17ee7844cf 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -76,10 +76,37 @@ impl PdfString { /// A name object (without the leading `/`, with `#xx` escapes already decoded). pub type Name = Vec; +#[cfg(test)] +thread_local! { + /// Key comparisons and index lookups made by [`Dict`] on this thread. + static KEY_COMPARES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Entries from which a [`Dict`] keeps a key index. Smaller dictionaries (nearly all of them) +/// search their entries; a parsed file controls the key count, and a linear search per +/// inserted key would make a dictionary of many distinct keys quadratic to build. +const INDEX_FROM: usize = 32; + /// An ordered dictionary. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Default)] pub struct Dict { entries: Vec<(Name, Object)>, + /// Key to position in `entries`, kept once the dictionary reaches [`INDEX_FROM`] entries. + /// Boxed so that every `Dict`, and so every `Object`, grows by one pointer, not by a map. + #[allow(clippy::box_collection)] + index: Option>>, +} + +impl PartialEq for Dict { + fn eq(&self, other: &Self) -> bool { + self.entries == other.entries + } +} + +impl std::fmt::Debug for Dict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Dict").field("entries", &self.entries).finish() + } } impl Dict { @@ -87,26 +114,53 @@ impl Dict { Self::default() } + fn position(&self, key: &[u8]) -> Option { + if let Some(index) = &self.index { + #[cfg(test)] + KEY_COMPARES.with(|c| c.set(c.get() + 1)); + return index.get(key).copied(); + } + self.entries.iter().position(|(k, _)| { + #[cfg(test)] + KEY_COMPARES.with(|c| c.set(c.get() + 1)); + k == key + }) + } + pub fn get(&self, key: &[u8]) -> Option<&Object> { - self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v) + self.position(key).and_then(|i| self.entries.get(i)).map(|(_, v)| v) } pub fn get_mut(&mut self, key: &[u8]) -> Option<&mut Object> { - self.entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v) + let i = self.position(key)?; + self.entries.get_mut(i).map(|(_, v)| v) } /// Insert or replace, keeping the position of an existing key. pub fn set(&mut self, key: impl Into, value: impl Into) { let key = key.into(); let value = value.into(); - match self.entries.iter_mut().find(|(k, _)| *k == key) { + match self.position(&key).and_then(|i| self.entries.get_mut(i)) { Some((_, v)) => *v = value, - None => self.entries.push((key, value)), + None => { + let at = self.entries.len(); + if let Some(index) = &mut self.index { + index.insert(key.clone(), at); + } else if at + 1 >= INDEX_FROM { + let index = self.entries.iter().enumerate().map(|(i, (k, _))| (k.clone(), i)).chain([(key.clone(), at)]).collect(); + self.index = Some(Box::new(index)); + } + self.entries.push((key, value)); + } } } pub fn remove(&mut self, key: &[u8]) -> Option { - let i = self.entries.iter().position(|(k, _)| k == key)?; + let i = self.position(key)?; + if let Some(index) = &mut self.index { + index.remove(key); + index.values_mut().filter(|at| **at > i).for_each(|at| *at -= 1); + } Some(self.entries.remove(i).1) } @@ -204,6 +258,21 @@ impl Stream { .collect() } + /// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an + /// array of names (an indirect reference, say): the data is not unfiltered, so it must not + /// read as decoded. + pub(crate) fn readable_filters(&self) -> Result, pdfcraft_filters::FilterError> { + let readable = match self.dict.get(b"Filter") { + None | Some(Object::Null | Object::Name(_)) => true, + Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()), + Some(_) => false, + }; + if !readable { + return Err(pdfcraft_filters::FilterError::Unsupported("/Filter is not a name or an array of names".into())); + } + Ok(self.filters()) + } + /// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data /// decoded before the damage is returned). Streams with image codecs fail with /// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage. @@ -214,26 +283,60 @@ impl Stream { /// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than /// [`MAX_DECODED`]: decoding past `max` bytes is an error. pub fn decoded_within(&self, max: usize) -> Result, CosError> { - let chain = self.filters(); + let decode = |chain: &[(Filter, Params)], data: &[u8], max| { + pdfcraft_filters::decode_tolerant(chain, data, max).map(|(v, _)| (v, 0)).map_err(|e| (e, 0)) + }; + self.decode_chain(max, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string())) + } + + /// [`Stream::decoded_within`] for a caller charging an allowance: the filter error is kept + /// typed, so a caller can tell a spent limit from corrupt data, `max` bounds all filter + /// stages together, and the result gives the bytes they decoded (the work done, on success + /// and on failure). + pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)> { + let decode = |chain: &[(Filter, Params)], data: &[u8], max| { + pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, _, spent)| (v, spent)) + }; + self.decode_chain(max, decode) + } + + /// The stream decoded through its filters by `decode`, or copied when it has none, within + /// `max` and [`MAX_DECODED`]. + fn decode_chain( + &self, + max: usize, + decode: impl FnOnce(&[(Filter, Params)], &[u8], usize) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)>, + ) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)> { + let max = max.min(MAX_DECODED); + let chain = self.readable_filters().map_err(|e| (e, 0))?; if chain.is_empty() { - return self.raw_within(max); + return self + .raw_within_typed(max) + .map(|v| { + let n = v.len(); + (v, n) + }) + .map_err(|e| (e, 0)); } - pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string())) + decode(&chain, &self.raw, max) } /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, CosError> { - let chain = self.filters(); - if chain.is_empty() { - return self.raw_within(MAX_DECODED); - } - pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) + let decode = |chain: &[(Filter, Params)], data: &[u8], max| pdfcraft_filters::decode(chain, data, max).map(|v| (v, 0)).map_err(|e| (e, 0)); + self.decode_chain(MAX_DECODED, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string())) + } + + /// [`Stream::decoded_strict`] bounded by `max` and counted like + /// [`Stream::decoded_within_counted`]. + pub(crate) fn decoded_strict_within_counted(&self, max: usize) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)> { + self.decode_chain(max, pdfcraft_filters::decode_counted) } /// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded. /// Any corruption in that first filter is an error. pub fn decoded_first_filter_strict(&self, max: usize) -> Result, CosError> { - let mut chain = self.filters(); + let mut chain = self.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?; chain.truncate(1); if chain.is_empty() { return self.raw_within(max); @@ -242,11 +345,15 @@ impl Stream { } fn raw_within(&self, max: usize) -> Result, CosError> { + self.raw_within_typed(max).map_err(|e| CosError::Filter(e.to_string())) + } + + fn raw_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> { let max = max.min(MAX_DECODED); // An empty decoder chain still produces an owned output buffer: check its limit // before cloning, including streams whose /Crypt filter was applied on load. if self.raw.len() > max { - return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string())); + return Err(pdfcraft_filters::FilterError::LimitExceeded(max)); } Ok(self.raw.to_vec()) } @@ -382,6 +489,53 @@ impl From for Object { mod tests { use super::*; + #[test] + fn a_filter_that_is_not_a_direct_name_is_not_decoded_as_absent() { + let stream = |filter: Object| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), filter); + Stream::from_raw(d, b"48 69>".to_vec()) + }; + let hex = Object::Name(b"ASCIIHexDecode".to_vec()); + assert_eq!(stream(hex.clone()).decoded_strict().ok(), Some(b"Hi".to_vec())); + assert_eq!(stream(Object::Array(vec![hex.clone()])).decoded().ok(), Some(b"Hi".to_vec())); + assert_eq!(stream(Object::Null).decoded_strict().ok(), Some(b"48 69>".to_vec())); + for filter in [ + Object::Ref(ObjRef::new(5, 0)), + Object::Array(vec![Object::Ref(ObjRef::new(5, 0))]), + Object::Int(3), + Object::Array(vec![hex, Object::Int(1)]), + ] { + let s = stream(filter.clone()); + assert!(s.decoded_strict().is_err(), "{filter:?}"); + assert!(s.decoded().is_err(), "{filter:?}"); + assert!(s.decoded_first_filter_strict(1 << 20).is_err(), "{filter:?}"); + } + } + + #[test] + fn parsing_many_distinct_keys_is_not_quadratic() { + let n = 20_000; + let body: String = (0..n).map(|i| format!("/K{i} {i} ")).collect(); + let text = format!("<< {body}/K7 true >>"); + KEY_COMPARES.with(|c| c.set(0)); + let Ok(Object::Dict(d)) = crate::parser::Lexer::new(text.as_bytes(), 0).object() else { panic!("not a dict") }; + let compares = KEY_COMPARES.with(|c| c.get()); + assert!(compares < 8 * n, "{compares} key comparisons for {n} keys"); + // Order and replace-in-place semantics stay those of a plain ordered dictionary. + assert_eq!(d.len(), n); + assert_eq!(d.iter().next().map(|(k, _)| k.as_slice()), Some(&b"K0"[..])); + assert_eq!(d.get(b"K7"), Some(&Object::Bool(true))); + assert_eq!(d.iter().nth(7).map(|(_, v)| v), Some(&Object::Bool(true))); + let mut d = d; + assert_eq!(d.remove(b"K3"), Some(Object::Int(3))); + assert_eq!(d.get(b"K4"), Some(&Object::Int(4))); + assert_eq!(d.iter().nth(3).map(|(k, _)| k.as_slice()), Some(&b"K4"[..])); + d.set(b"K3".to_vec(), Object::Null); + assert_eq!(d.iter().last().map(|(k, _)| k.as_slice()), Some(&b"K3"[..])); + assert!(d.get(b"K20000").is_none()); + } + #[test] fn text_strings_round_trip() { for s in ["Plain ASCII", "Café — naïve", "日本語", "emoji 📄"] { diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index c9689d7fee..1e9b45ad61 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -10,6 +10,9 @@ use crate::object::{Dict, ObjRef, Object, PdfString, Stream}; use crate::{Bytes, CosError}; const MAX_DEPTH: usize = 128; +/// Objects one top-level [`Lexer::object`] call may build. Real arrays (page trees, parent +/// trees, widths) stay far below; a decompressed object stream of `0 0 0 ...` does not. +const MAX_NODES: usize = 1 << 22; /// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1). pub fn is_whitespace(b: u8) -> bool { @@ -28,11 +31,13 @@ fn is_regular(b: u8) -> bool { pub struct Lexer<'a> { pub data: &'a [u8], pub pos: usize, + /// Objects built by the current top-level [`Lexer::object`] call. + nodes: usize, } impl<'a> Lexer<'a> { pub fn new(data: &'a [u8], pos: usize) -> Self { - Self { data, pos: pos.min(data.len()) } + Self { data, pos: pos.min(data.len()), nodes: 0 } } fn peek(&self) -> Option { @@ -84,13 +89,22 @@ impl<'a> Lexer<'a> { /// Parse one direct object. References `N G R` are recognised. pub fn object(&mut self) -> Result { + self.nodes = 0; self.object_depth(0) } + fn over_budget(&self) -> bool { + self.nodes > MAX_NODES + } + fn object_depth(&mut self, depth: usize) -> Result { if depth > MAX_DEPTH { return Err(self.err("nesting too deep")); } + self.nodes = self.nodes.saturating_add(1); + if self.over_budget() { + return Err(self.err("object too large")); + } self.skip_ws(); let Some(b) = self.peek() else { return Err(self.err("unexpected end of data")) }; match b { @@ -125,6 +139,7 @@ impl<'a> Lexer<'a> { let before = self.pos; match self.object_depth(depth + 1) { Ok(o) => items.push(o), + Err(e) if self.over_budget() => return Err(e), // Tolerate junk inside arrays by skipping one byte. Err(_) if self.pos == before => self.pos += 1, Err(e) => return Err(e), @@ -283,6 +298,9 @@ impl<'a> Lexer<'a> { // Tolerate garbage between entries (seen in damaged files): skip a token. let before = self.pos; let _ = self.object_depth(depth + 1); + if self.over_budget() { + return Err(self.err("object too large")); + } if self.pos == before { self.pos += 1; } @@ -324,18 +342,8 @@ impl<'a> Lexer<'a> { if s.contains('.') { // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix. let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1); - let mut end = cleaned.len(); - while end > 0 { - // Only cut at character boundaries: a token can hold multi-byte UTF-8 (fuzzing - // found ".—", which panicked here). - if cleaned.is_char_boundary(end) - && let Ok(v) = cleaned[..end].parse::() - { - return Ok(Object::Real(v)); - } - end -= 1; - } - return Ok(Object::Real(0.0)); + let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::(p).ok()); + return Ok(Object::Real(v.unwrap_or(0.0))); } // Tolerate a doubled sign ("--280") like the real branch does. let t = s.trim_start_matches('+'); @@ -350,6 +358,46 @@ impl<'a> Lexer<'a> { } } +/// Length of the longest prefix of `s` that reads as a decimal number: a sign, digits with at +/// most one `.`, and an exponent with digits. Zero when that prefix has no digit. Found in one +/// pass so a long damaged token is converted once, not once per shorter prefix. +fn real_prefix_len(s: &[u8]) -> usize { + let digits = |from: usize| s.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count()); + let mut at = usize::from(matches!(s.first(), Some(b'+' | b'-'))); + let int = digits(at); + at += int; + let mut frac = 0; + if s.get(at) == Some(&b'.') { + frac = digits(at + 1); + at += 1 + frac; + } + if int + frac == 0 { + return 0; + } + if matches!(s.get(at), Some(b'e' | b'E')) { + let sign = usize::from(matches!(s.get(at + 1), Some(b'+' | b'-'))); + let exp = digits(at + 1 + sign); + if exp > 0 { + at += 1 + sign + exp; + } + } + at +} + +#[cfg(test)] +thread_local! { + /// Bytes searched for `endstream` on this thread. + pub(crate) static ENDSTREAM_SCAN: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Bytes handed to number conversion on this thread. + static NUMBER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn parse_counted(s: &str) -> Result { + #[cfg(test)] + NUMBER_BYTES.with(|c| c.set(c.get() + s.len())); + s.parse() +} + fn hexval(b: u8) -> Option { match b { b'0'..=b'9' => Some(b - b'0'), @@ -362,17 +410,30 @@ fn hexval(b: u8) -> Option { /// Parse an indirect object at `offset`: `N G obj [stream…endstream] endobj`. /// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen). pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option) -> Result<(ObjRef, Object), CosError> { - parse_indirect_in(data, None, offset, resolve_length) + parse_indirect_in(data, None, offset, resolve_length, None) } /// [`parse_indirect`] for a document's own bytes: stream data points into `data` instead of /// being copied out of it. +/// `endstreams` is the document's [`EndstreamIndex`] for `data`. pub(crate) fn parse_indirect_shared( data: &Arc>, offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option, + endstreams: &EndstreamIndex, ) -> Result<(ObjRef, Object), CosError> { - parse_indirect_in(data, Some(data), offset, resolve_length) + parse_indirect_in(data, Some(data), offset, resolve_length, Some(endstreams)) +} + +/// Where `endstream` occurs in a document's bytes, found once, the first time a stream's +/// /Length is wrong or missing. Searching the rest of the file for each such stream instead +/// makes many of them ending at one late `endstream` quadratic to read. +pub(crate) type EndstreamIndex = std::sync::OnceLock>; + +fn endstream_offsets(data: &[u8]) -> Vec { + #[cfg(test)] + ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len())); + data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect() } fn parse_indirect_in( @@ -380,6 +441,7 @@ fn parse_indirect_in( shared: Option<&Arc>>, offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option, + endstreams: Option<&EndstreamIndex>, ) -> Result<(ObjRef, Object), CosError> { let mut lx = Lexer::new(data, offset); lx.skip_ws(); @@ -404,7 +466,16 @@ fn parse_indirect_in( if let Object::Dict(dict) = &obj && lx.eat_keyword(b"stream") { - // The keyword is followed by CRLF or LF (a lone CR is tolerated). + // The keyword is followed by CRLF or LF (a lone CR is tolerated). Microsoft Reporting + // Services writes a space first (`stream \r\n`); spaces and tabs count only when a line + // break follows, so they are never taken from the data. + let mut eol = lx.pos; + while matches!(data.get(eol), Some(b' ' | b'\t')) { + eol += 1; + } + if matches!(data.get(eol), Some(b'\r' | b'\n')) { + lx.pos = eol; + } match (data.get(lx.pos), data.get(lx.pos + 1)) { (Some(b'\r'), Some(b'\n')) => lx.pos += 2, (Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1, @@ -430,7 +501,11 @@ fn parse_indirect_in( let len = match declared { Some(l) if valid(l) => l as usize, // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). - _ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start, + _ => { + find_endstream(data, start, endstreams) + .ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? + - start + } }; let range = start..start + len; let raw = match shared { @@ -446,9 +521,20 @@ fn parse_indirect_in( } /// Offset where stream data ends (before the EOL preceding "endstream"). -fn find_endstream(data: &[u8], from: usize) -> Option { - let hay = data.get(from..)?; - let at = hay.windows(9).position(|w| w == b"endstream")? + from; +fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option { + let at = match endstreams { + Some(index) => { + let index = index.get_or_init(|| endstream_offsets(data)); + index.get(index.partition_point(|&p| p < from)).copied()? + } + None => { + let hay = data.get(from..)?; + let found = hay.windows(9).position(|w| w == b"endstream"); + #[cfg(test)] + ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9))); + found? + from + } + }; let mut end = at; if end > from && data[end - 1] == b'\n' { end -= 1; @@ -474,6 +560,22 @@ mod tests { assert_eq!(Lexer::new("1.5\u{2014}".as_bytes(), 0).object().unwrap(), Object::Real(1.5)); } + #[test] + fn a_damaged_real_is_converted_once() { + let token = format!("1.{}{}", "5".repeat(2_000), "x".repeat(20_000)); + NUMBER_BYTES.with(|c| c.set(0)); + let Ok(Object::Real(v)) = Lexer::new(token.as_bytes(), 0).object() else { panic!("not a real") }; + let converted = NUMBER_BYTES.with(|c| c.get()); + assert!(converted <= token.len(), "{converted} bytes converted for a {}-byte token", token.len()); + assert!((v - 1.5555).abs() < 1e-3); + // The longest numeric prefix wins, as before. + for (t, want) in + [("1.2.3", 1.2), ("--5.", -5.0), ("+.5x", 0.5), ("5.-", 5.0), ("1.5e3", 1500.0), ("1.5e+", 1.5), ("-.x", 0.0), (".", 0.0), ("-inf.", 0.0)] + { + assert_eq!(Lexer::new(t.as_bytes(), 0).object().ok(), Some(Object::Real(want)), "{t}"); + } + } + fn obj(s: &str) -> Object { Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}")) } @@ -554,6 +656,25 @@ mod tests { assert_eq!(s.dict.int(b"Length"), Some(5)); } + #[test] + fn indirect_stream_with_a_space_before_the_line_break() { + // `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the + // line break, so the /Length is still right and the data keeps no leading space. + for eol in [&b" \r\n"[..], b" \n", b"\t \r"] { + let mut data = b"7 0 obj\n<< /Length 5 >>\nstream".to_vec(); + data.extend_from_slice(eol); + data.extend_from_slice(b"HELLO\r\nendstream\nendobj\n"); + let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{eol:?}"); + } + // Spaces with no line break after them are data, not a keyword terminator. + let data = b"7 0 obj\n<< /Length 7 >>\nstream HELLO\nendstream\nendobj\n"; + let (_, o) = parse_indirect(data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b" HELLO"); + } + #[test] fn indirect_length_is_resolved() { let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n"; @@ -570,4 +691,20 @@ mod tests { let deep = "[".repeat(10_000); assert!(Lexer::new(deep.as_bytes(), 0).object().is_err()); } + + #[test] + fn one_object_has_a_node_budget() { + // A wide array (from a tiny compressed object stream) must not grow without bound. + let wide = |n: usize| format!("[{}]", "0 ".repeat(n)); + let at_cap = Lexer::new(wide(MAX_NODES - 1).as_bytes(), 0).object(); + assert!(matches!(at_cap, Ok(Object::Array(ref items)) if items.len() == MAX_NODES - 1)); + assert!(Lexer::new(wide(MAX_NODES).as_bytes(), 0).object().is_err()); + // The same inside a dictionary value and behind tolerated junk. + assert!(Lexer::new(format!("<< /A {} >>", wide(MAX_NODES)).as_bytes(), 0).object().is_err()); + assert!(Lexer::new(format!("[[{}] ]", "0 ".repeat(MAX_NODES)).as_bytes(), 0).object().is_err()); + // The budget is per top-level object: a lexer reused for many operands keeps working. + let two = wide(MAX_NODES - 1).repeat(2); + let mut lx = Lexer::new(two.as_bytes(), 0); + assert!(lx.object().is_ok() && lx.object().is_ok()); + } } diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index cd0d04a144..22c85c7f68 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -88,42 +88,107 @@ fn named_crypt_filter(dict: &Dict) -> Option> { /// whose `/Contents` is never encrypted (ISO 32000-2 §7.6.2, §12.8.1): the signer writes a /// placeholder, hashes the file around it and patches the value in place, so encrypting it /// would make signing impossible. Identified as other readers do: by `/Type`, or by a string -/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). +/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). A signature dictionary +/// has no `/Subtype`; a dictionary with one (an annotation, whose `/Contents` is the comment +/// text) or with another `/Type` is never exempt, whatever else it carries. fn is_signature_dict(d: &Dict) -> bool { - matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")) - || (matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_)))) + if d.get(b"Subtype").is_some() { + return false; + } + match d.get(b"Type") { + Some(_) => matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")), + None => matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))), + } } -/// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object, -/// except a signature dictionary's `/Contents` (see [`is_signature_dict`]). -pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, decrypt: bool) -> Object { +/// Remove the `/Crypt` entry from a stream's filter chain, with its decode parameters. +fn drop_crypt_filter(dict: &mut Dict) { + let i = match dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().position(|n| n.as_name() == Some(b"Crypt")), + Some(Object::Name(n)) if n.as_slice() == b"Crypt" => Some(0), + _ => None, + }; + let Some(i) = i else { return }; + let mut filters = match dict.get(b"Filter") { + Some(Object::Array(a)) => a.clone(), + _ => Vec::new(), + }; + if i < filters.len() { + filters.remove(i); + } + // A lone parameter dictionary belongs to the first filter (as `named_crypt_filter` reads it). + match dict.get(b"DecodeParms").cloned() { + Some(Object::Array(mut parms)) if i < parms.len() => { + parms.remove(i); + dict.set(b"DecodeParms".to_vec(), Object::Array(parms)); + } + Some(Object::Dict(_)) if i == 0 => { + dict.remove(b"DecodeParms"); + } + _ => {} + } + if filters.is_empty() { + dict.remove(b"Filter"); + if matches!(dict.get(b"DecodeParms"), Some(Object::Array(p)) if p.is_empty()) { + dict.remove(b"DecodeParms"); + } + } else { + dict.set(b"Filter".to_vec(), Object::Array(filters)); + } +} + +/// What [`transform`] does to an object. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Pass { + /// Decrypt, with the handler the document was opened with. + Decrypt, + /// Encrypt with the handler the document was opened with: streams keep their crypt filters. + Encrypt, + /// Encrypt with protection applied by `set_encryption`. A stream's crypt filter came from + /// the source's `/Encrypt`; unless the new one defines that name it is dropped, so the + /// stream follows the new policy instead of reading as `/Identity` and staying in clear. + EncryptReplacing, +} + +/// Decrypt or encrypt every string and stream in an indirect object, except a signature +/// dictionary's `/Contents` (see [`is_signature_dict`]). +pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object { + let decrypt = pass == Pass::Decrypt; let strings = |s: &PdfString| { let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) }; PdfString { bytes, hex: s.hex || !decrypt } }; - walk(o, &strings, h, num, generation, decrypt) + walk(o, &strings, h, num, generation, pass) } -fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, decrypt: bool) -> Object { +fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, pass: Pass) -> Object { + let decrypt = pass == Pass::Decrypt; match o { Object::String(s) => Object::String(strings(s)), - Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, decrypt)).collect()), + Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, pass)).collect()), Object::Dict(d) if is_signature_dict(d) => Object::Dict( d.iter() .map(|(k, v)| match (k.as_slice(), v) { (b"Contents", Object::String(_)) => (k.clone(), v.clone()), - _ => (k.clone(), walk(v, strings, h, num, generation, decrypt)), + _ => (k.clone(), walk(v, strings, h, num, generation, pass)), }) .collect(), ), - Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect()), + Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect()), Object::Stream(s) => { - let dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect(); + let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect(); let ty = s.dict.name(b"Type"); - if ty == Some(b"XRef") || (ty == Some(b"Metadata") && !h.encrypts_metadata()) { + // Cross-reference streams are never encrypted (§7.5.8), but only reading may trust the + // label: a stream a producer calls /XRef stays plaintext if writing believes it, and the + // writer builds its own cross-reference streams outside this transform. + if (decrypt && ty == Some(b"XRef")) || (ty == Some(b"Metadata") && !h.encrypts_metadata()) { return Object::Stream(Stream { dict, raw: s.raw.clone() }); } - let named = named_crypt_filter(&s.dict); + let mut named = named_crypt_filter(&s.dict); + if pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)) { + drop_crypt_filter(&mut dict); + named = None; + } let kind = match (&named, ty) { (Some(n), _) => StreamKind::Named(n), (None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile, diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 331f97d443..627f716495 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -2,17 +2,20 @@ //! //! * `write_incremental` appends the edited objects, a cross-reference section in the same style //! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are -//! never modified, which keeps digital signatures over earlier revisions valid. +//! never modified, which keeps digital signatures over earlier revisions valid. With +//! `SaveOptions::drop_unlinked`, objects added since the file was read that nothing refers to +//! any more are left out (written as free). //! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a //! cross-reference table or stream — the "Save As" / garbage-collecting path. Unknown trailer //! entries are copied, including objects reachable only from them. use std::borrow::Cow; -use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::io::Write; +use std::sync::Arc; use crate::CosError; -use crate::document::{Document, ObjectReader}; +use crate::document::{Document, ObjectReader, XrefEntry}; use crate::object::{Dict, ObjRef, Object, PdfString, Stream}; /// Serialize one object. Deterministic: same object → same bytes. @@ -49,8 +52,10 @@ fn write_real(r: f64, out: &mut Vec) { out.push(b'0'); return; } - if r.fract() == 0.0 && r.abs() < 1e15 { - let _ = write!(out, "{}", r as i64); + // A whole number is written as an integer only within the integer range Annex C gives + // (±2³¹−1), so `4e9` or `1e30` stays a real token for readers with 32-bit integers. + if r.fract() == 0.0 && r.abs() <= f64::from(i32::MAX) { + let _ = write!(out, "{}", r as i32); return; } // Plain decimal notation (§7.3.3 has no exponents). Rust's `Display` for f64 prints the @@ -58,6 +63,10 @@ fn write_real(r: f64, out: &mut Vec) { // round-trip exactly (rounding to a fixed number of places visibly shifted shading colours). let s = format!("{r}"); out.extend_from_slice(if s == "-0" { b"0" } else { s.as_bytes() }); + // A whole number past that range prints without a point; the trailing `.` keeps it a real. + if !s.contains('.') && s != "-0" { + out.push(b'.'); + } } fn write_name(n: &[u8], out: &mut Vec) { @@ -126,7 +135,10 @@ fn write_stream(s: &Stream, out: &mut Vec) { /// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document). fn prepared<'a>(doc: &Document, source_num: u32, num: u32, generation: u16, o: &'a Object) -> Cow<'a, Object> { match doc.output_security() { - (Some(h), encrypt_num) if Some(source_num) != encrypt_num => Cow::Owned(crate::security::transform(h, o, num, generation, false)), + (Some(h), encrypt_num) if Some(source_num) != encrypt_num => { + let pass = if doc.encryption_changed() { crate::security::Pass::EncryptReplacing } else { crate::security::Pass::Encrypt }; + Cow::Owned(crate::security::transform(h, o, num, generation, pass)) + } _ => Cow::Borrowed(o), } } @@ -147,11 +159,18 @@ pub struct SaveOptions { /// Full saves: pack non-stream objects into compressed object streams with a cross-reference /// stream (PDF 1.5+, §7.5.7–7.5.8). Off writes a classic table that any reader accepts. pub object_streams: bool, + /// Incremental saves: leave out what was added since the file was read and nothing refers to + /// any more, such as a comment added and deleted again or a redaction mark added and cleared + /// (written as free). Off by default, because a writer may add an object only it finds again + /// by scanning, as a standalone document timestamp is; such a writer frees the leftovers + /// before adding it, with [`Document::free_unlinked_additions`]. Full saves always leave + /// these out. + pub drop_unlinked: bool, } impl Default for SaveOptions { fn default() -> Self { - Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true } + Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false } } } @@ -200,8 +219,14 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, let base = (original.len() + usize::from(newline)).saturating_sub(shift) as u64; let mut out = Vec::new(); let mut offsets: BTreeMap = BTreeMap::new(); // num → (offset, gen, in use) + let unlinked = if opts.drop_unlinked { unlinked_additions(&doc) } else { HashSet::new() }; for (num, generation, obj) in doc.overlay_entries() { match obj { + // Written as free, so `/Size` still covers every number this document handed out. The + // generation stays: the number was never in use, and an undo can bring it back as is. + Some(_) if unlinked.contains(&num) => { + offsets.insert(num, (0, generation, false)); + } Some(o) => { offsets.insert(num, (base + out.len() as u64, generation, true)); write_indirect(num, generation, &prepared(&doc, num, num, generation, &o), &mut out); @@ -378,7 +403,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro /// Object `r` read again for writing. The traversal read it once and only kept its number, so /// a second read that fails, finds nothing or finds a different kind of object must fail the /// save rather than write `null` in its place. -fn reread(reader: &mut ObjectReader, r: ObjRef, is_stream: bool) -> Result, CosError> { +fn reread(reader: &mut ObjectReader, r: ObjRef, is_stream: bool) -> Result, CosError> { let o = reader.try_get(r)?; if matches!(*o, Object::Null) || matches!(*o, Object::Stream(_)) != is_stream { return Err(CosError::Syntax { offset: 0, detail: format!("object {} read differently while saving; nothing was written", r.num) }); @@ -430,6 +455,37 @@ fn output_number(index: usize) -> Result { u32::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| CosError::Syntax { offset: 0, detail: "too many objects to save".into() }) } +/// Objects added since the file was read (their numbers are not in use in it) that nothing an +/// incremental update writes refers to: what one edit added and a later one unlinked, such as a +/// deleted comment or a cleared redaction mark. The file never held them, so leaving them out +/// loses nothing, while writing them would keep what the user removed. Only the update is walked: +/// from the trailer and the file's changed objects through additions. An unchanged object of the +/// file is not followed: a reference it holds to such a number already reads as null in the file +/// (a damaged file's dangling reference), and it still does. +pub(crate) fn unlinked_additions(doc: &Document) -> HashSet { + let added = |num: u32| matches!(doc.xref_entry(num), None | Some(XrefEntry::Free { .. })); + let mut pending: HashMap> = + doc.overlay_entries().filter_map(|(num, _, o)| Some((num, o?))).filter(|(num, _)| added(*num)).collect(); + if pending.is_empty() { + return HashSet::new(); + } + let mut stack: Vec = Vec::new(); + doc.trailer().iter().for_each(|(_, v)| collect_refs(v, &mut |r| stack.push(r))); + for (num, _, o) in doc.overlay_entries() { + if let Some(o) = o + && !added(num) + { + collect_refs(&o, &mut |r| stack.push(r)); + } + } + while let Some(r) = stack.pop() { + if let Some(o) = pending.remove(&r.num) { + collect_refs(&o, &mut |r| stack.push(r)); + } + } + pending.into_keys().collect() +} + fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) { match o { Object::Ref(r) => f(*r), @@ -578,22 +634,33 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { /// A PDF date string for "now" given seconds since the Unix epoch (UTC). pub fn pdf_date(unix_secs: i64) -> String { - let days = unix_secs.div_euclid(86_400); - let secs = unix_secs.rem_euclid(86_400); - // Civil-from-days (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; + let (days, secs) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); + let (y, m, d) = civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (and a PDF date is a + // bare four-plus-digit year, never signed). No year clamp — the old code wrote `y` as-is. format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range, +/// which the old hand-rolled converter formatted directly. The Gregorian calendar repeats every +/// 400 years (146_097 days), so chrono converts the day within one cycle and whole cycles go back +/// into the year (an `i128` so the extremes can't overflow), keeping chrono as the only calendar +/// logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } +} + #[cfg(test)] mod tests { use proptest::prelude::*; @@ -656,6 +723,26 @@ mod tests { } } + #[test] + fn pdf_dates_are_utc_and_byte_identical() { + // `D:YYYYMMDDHHmmSSZ` (ISO 32000-2 §7.9.4), the same strings the hand-rolled code wrote. + assert_eq!(pdf_date(0), "D:19700101000000Z"); + assert_eq!(pdf_date(1_790_000_000), "D:20260921141320Z"); + // Leap days, including the 400-year rule, and the last second of a year. + assert_eq!(pdf_date(1_709_164_800), "D:20240229000000Z"); + assert_eq!(pdf_date(951_782_400), "D:20000229000000Z"); + assert_eq!(pdf_date(1_767_225_599), "D:20251231235959Z"); + // Before the epoch (seconds into a day are counted forward from midnight UTC). + assert_eq!(pdf_date(-1), "D:19691231235959Z"); + // Years past 9999 keep the plain zero-padded year (no `+`), as the hand-rolled code did. + assert_eq!(pdf_date(253_402_300_800), "D:100000101000000Z"); + // Past chrono's ±262143-year range the proleptic Gregorian date is kept, not the epoch. + // Pinned to the old hand-rolled output (which had no year clamp — a negative year keeps + // its sign, exactly as before). + assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z"); + assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z"); + } + #[test] fn reals_keep_full_precision_without_exponents() { for r in [0.1, -0.000_000_123_456_789, 1e-12, 123_456.789_012_345, 1.0 / 3.0, -2.5e7 + 0.25] { @@ -666,14 +753,38 @@ mod tests { } } + #[test] + fn whole_reals_past_the_integer_limit_stay_reals() { + for (r, text) in [ + (2_147_483_647.0, "2147483647"), + (-2_147_483_647.0, "-2147483647"), + (2_147_483_648.0, "2147483648."), + (4e9, "4000000000."), + (-4e9, "-4000000000."), + (1e30, "1000000000000000000000000000000."), + (595.0, "595"), + (-0.0, "0"), + ] { + let mut out = Vec::new(); + serialize(&Object::Real(r), &mut out); + assert_eq!(String::from_utf8_lossy(&out), text, "{r}"); + let back = roundtrip(&Object::Real(r)); + if r.abs() > f64::from(i32::MAX) { + assert_eq!(back, Object::Real(r), "{r} must read back as a real"); + } + } + } + proptest! { #[test] fn any_finite_real_round_trips(r in proptest::num::f64::NORMAL | proptest::num::f64::SUBNORMAL | proptest::num::f64::ZERO) { - prop_assume!(r.abs() < 1e15 || r.fract() == 0.0); let back = roundtrip(&Object::Real(r)); match back { Object::Real(b) => prop_assert_eq!(b, r), - Object::Int(i) => prop_assert_eq!(i as f64, r), + Object::Int(i) => { + prop_assert!(i.unsigned_abs() <= i32::MAX as u64, "{r} came back as integer {i}"); + prop_assert_eq!(i as f64, r); + } other => prop_assert!(false, "unexpected {other:?}"), } } diff --git a/crates/cos/tests/bounded_writer.rs b/crates/cos/tests/it/bounded_writer.rs similarity index 100% rename from crates/cos/tests/bounded_writer.rs rename to crates/cos/tests/it/bounded_writer.rs diff --git a/crates/cos/tests/corpus.rs b/crates/cos/tests/it/corpus.rs similarity index 100% rename from crates/cos/tests/corpus.rs rename to crates/cos/tests/it/corpus.rs diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/it/encryption.rs similarity index 76% rename from crates/cos/tests/encryption.rs rename to crates/cos/tests/it/encryption.rs index e05161558d..2ee33afc12 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -2,6 +2,7 @@ //! with our reader and with an independent one (hayro-syntax's decryption). Also editing //! encrypted documents incrementally, changing and removing passwords, and permissions. +use std::io::Write; use std::sync::Arc; use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, Object, PdfString, SaveOptions, write_full, write_incremental}; @@ -9,11 +10,16 @@ use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, O const SECRET: &str = "BT /F1 18 Tf 20 100 Td (Secret page) Tj ET"; fn fixture() -> Vec { + fixture_with("") +} + +/// The fixture with `extra` added to the content stream's dictionary. +fn fixture_with(extra: &str) -> Vec { let objs = [ "<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_string(), "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), - format!("<< /Length {} >>\nstream\n{SECRET}\nendstream", SECRET.len()), + format!("<< /Length {}{extra} >>\nstream\n{SECRET}\nendstream", SECRET.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), "<< /Type /Metadata /Subtype /XML /Length 22 >>\nstream\nmeta\nendstream".replace("22", "26"), "<< /Title (Quarterly numbers) >>".to_string(), @@ -209,11 +215,10 @@ fn unencrypted_metadata_is_left_readable() { #[test] fn qpdf_agrees() { let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); - let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + let dir = private_temp_dir("pdfcraft-enc"); for alg in ALL { let path = dir.join(format!("{alg:?}.pdf")); - std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap(); + std::fs::File::create_new(&path).and_then(|mut f| f.write_all(&protect(alg, "user", "owner", -1))).unwrap(); for (pw, expect) in [("user", "user password"), ("owner", "owner password")] { let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap(); let text = String::from_utf8_lossy(&out.stdout); @@ -225,6 +230,35 @@ fn qpdf_agrees() { let _ = std::fs::remove_dir_all(dir); } +/// A new directory for oracle files, private to this user: an unpredictable name (`RandomState` +/// is seeded by the operating system), created by this call or not at all. +fn private_temp_dir(prefix: &str) -> std::path::PathBuf { + use std::hash::{BuildHasher, Hasher}; + let mut name = std::collections::hash_map::RandomState::new().build_hasher(); + name.write_u32(std::process::id()); + let dir = std::env::temp_dir().join(format!("{prefix}-{:016x}", name.finish())); + #[cfg(unix)] + let builder = { + let mut builder = std::fs::DirBuilder::new(); + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder + }; + #[cfg(not(unix))] + let builder = std::fs::DirBuilder::new(); + builder.create(&dir).unwrap(); + dir +} + +/// Oracle files go in a directory this test created: another local account cannot have +/// prepared it (or symlinks inside it) in advance. +#[test] +fn oracle_files_go_in_a_fresh_directory() { + let (a, b) = (private_temp_dir("pdfcraft-enc-probe"), private_temp_dir("pdfcraft-enc-probe")); + assert_ne!(a, b); + assert_eq!(std::fs::read_dir(&a).unwrap().count(), 0); + let _ = (std::fs::remove_dir(a), std::fs::remove_dir(b)); +} + /// A signature dictionary's `/Contents` is never encrypted (§7.6.2): signers patch it in place /// after hashing the file around it. Its other strings, and the `/Contents` of anything that /// is not a signature dictionary, are encrypted as usual, and reading leaves it untouched. @@ -279,6 +313,75 @@ fn signature_contents_stay_unencrypted() { } } +/// Only a signature dictionary keeps its `/Contents` in clear. An annotation that carries a +/// `/ByteRange` (or even `/Type /Sig`) is still an annotation, and its comment is encrypted. +#[test] +fn a_byte_range_does_not_exempt_an_annotation() { + for (ty, subtype) in [(Some("Annot"), "Text"), (None, "FreeText"), (Some("Sig"), "Text")] { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let mut note = pdfcraft_cos::Dict::new(); + if let Some(ty) = ty { + note.set(b"Type".to_vec(), Object::name(ty)); + } + note.set(b"Subtype".to_vec(), Object::name(subtype)); + note.set(b"Contents".to_vec(), PdfString::literal(b"Private note".to_vec())); + note.set(b"ByteRange".to_vec(), Object::Array(Vec::new())); + let note = doc.add(Object::Dict(note)); + doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Ref(note))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Private note"), "{ty:?} {subtype}: comment encrypted"); + let back = Document::open(Arc::new(bytes)).unwrap(); + let note = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Test").unwrap(); + assert_eq!(back.get(note).as_dict().unwrap().get(b"Contents").unwrap().as_string().unwrap().bytes, b"Private note"); + } +} + +/// Protecting a document puts every stream under the new handler. A stream that selects a +/// crypt filter of its own loses the selector: the new `/Encrypt` defines only `/StdCF`, so an +/// inherited name (or `/Identity`) would leave the stream in clear. +#[test] +fn protecting_drops_inherited_crypt_filter_selectors() { + for selector in [" /Filter /Crypt /DecodeParms << /Name /MyCF >>", " /Filter [/Crypt] /DecodeParms [<< /Name /Identity >>]", " /Filter /Crypt"] { + let mut doc = Document::open(Arc::new(fixture_with(selector))).unwrap(); + assert_eq!(content(&doc), SECRET, "{selector}"); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("Secret page"), "{selector}: stream encrypted"); + assert_eq!(content(&Document::open(Arc::new(bytes.clone())).unwrap()), SECRET, "{selector}"); + assert!(hayro_syntax::Pdf::new(bytes).is_ok(), "{selector}"); + } + // Under the handler the document was opened with, `/Identity` is the author's choice (a + // stream meant to stay readable) and is kept. + let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let pages = doc.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap(); + let page = doc.resolve(&pages.get(b"Kids").unwrap().as_array().unwrap()[0]).as_dict().cloned().unwrap(); + let contents = page.reference(b"Contents").unwrap(); + let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") }; + s.dict.set(b"Filter".to_vec(), Object::name("Crypt")); + doc.set(contents, Object::Stream(s)); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(String::from_utf8_lossy(&saved).contains("Secret page"), "written as the stream asks"); + assert_eq!(content(&Document::open(Arc::new(saved)).unwrap()), SECRET); +} + #[test] fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { // A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is @@ -374,3 +477,20 @@ fn removing_an_inline_encrypt_dictionary_leaves_an_unencrypted_file() { assert!(hayro_syntax::Pdf::new(plain).is_ok(), "{alg:?}"); } } + +/// A content stream that calls itself `/Type /XRef` is still encrypted: only reading trusts the label. +#[test] +fn a_stream_labelled_xref_is_encrypted_when_saved() { + let mut doc = Document::open(Arc::new(fixture_with(" /Type /XRef"))).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: Algorithm::Aes256, + user_password: "u", + owner_password: "o", + permissions: -4, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(SECRET.len()).any(|w| w == SECRET.as_bytes()), "the labelled stream was written in the clear"); +} diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs new file mode 100644 index 0000000000..52cf05d6c2 --- /dev/null +++ b/crates/cos/tests/it/main.rs @@ -0,0 +1,8 @@ +//! The crate's integration tests, linked into one test binary. + +mod bounded_writer; +mod corpus; +mod encryption; +mod proptests; +mod shared_streams; +mod writer; diff --git a/crates/cos/tests/it/proptests.rs b/crates/cos/tests/it/proptests.rs new file mode 100644 index 0000000000..4380dd0bd6 --- /dev/null +++ b/crates/cos/tests/it/proptests.rs @@ -0,0 +1,464 @@ +//! Property tests for `pdfcraft-cos` save fidelity — proptest coverage of two areas raised in +//! #1187. **This does not close #1187.** That issue asks for five Hegel properties; this adds +//! save/reopen and edit-history coverage using `proptest`, which is already a dev-dependency of this +//! crate (`Cargo.toml:20`), so no new dependency is needed to review it. Left for follow-ups: +//! #1187's reference-following comparison, its snapshot/undo state machine, the password property (the +//! one whose pilot found #1185) and its damaged-file byte splices. The object-level serialise → parse +//! round trip it also lists already exists inline in `src/writer.rs`. +//! +//! Covered: +//! +//! 1. **A full save keeps every value it wrote into the catalog**, in both output styles (objects +//! packed into object streams, §7.5.7; the classic cross-reference table otherwise), keeps the +//! catalog's own structure intact (`/Type /Catalog`, and a `/Pages` node whose `/Kids` array and +//! `/Count` agree — the fixture's tree is empty, so this pins shape and agreement, not rendered +//! pages), preserves dictionary +//! key order (which `README.md:75` promises is byte-stable), emits spec-shaped §7.5.4 cross-reference +//! rows, and reopens with nothing to repair. +//! 2. **An incremental save is a strict append**: the original bytes come out untouched at the front +//! (§7.5.6), the file keeps two revisions whose first still points at the base cross-reference and +//! opens standalone, the edit is visible while every existing value stays put in order, and it +//! reopens with nothing to repair. +//! 3. **A write/delete sequence equals a plain map**, checked after every step and again once saved and +//! reopened. A deleted key must stay gone — not return its old value, not read as null either. +//! Deletes are generated against keys that were just written, so the delete path cannot be skipped. +//! +//! ## Scope, stated honestly +//! Generated values are pure data — no streams and no indirect references — so this says nothing about +//! stream payloads or the renumbering a full save legitimately performs. Strings are compared as bytes, +//! so literal ↔ hexadecimal re-emission is not treated as a change (§7.3.6); the *choice* of spelling +//! is therefore not asserted, and `PdfString.hex` is not pinned here. Dictionary keys at the catalog +//! level *are* compared in order, so reversing them fails; nested dictionary order inside a value is +//! sorted away by [`canon`] and is not pinned. Generated reals stay within ±1e6: `write_real` +//! (`src/writer.rs:46`) turns a non-finite real into `0` and an integral real below 1e15 into integer +//! syntax, so [`canon`] applies the writer's documented normalisation to **both** sides rather than +//! forgiving a mismatch afterwards. + +use std::sync::Arc; + +use proptest::prelude::*; +use proptest::test_runner::TestCaseError; + +use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full, write_incremental}; + +/// The prefix every generated key carries. `Document::new_empty` puts `/Type` and `/Pages` in the +/// catalog; property 1 asserts those two structurally rather than ignoring them. +const KEY: &str = "px"; + +/// Generated keys go into the catalog in this deliberately unsorted sequence. Ascending insertion would make +/// "preserves dictionary key order" and "every dictionary sorted by key" the same observable, so a writer +/// that silently sorted would rewrite every saved byte and stay green. This scramble catches sorting of the +/// keys it names; it does NOT on its own pin where structural entries like `/Type` sit, because `saved` +/// filters to the `px` prefix - that gap is closed separately by [`catalog_keys`] asserting the full +/// sequence. Nested dictionary order is deliberately not pinned (see `canon`). Every permutation of the +/// generated keys is NOT drawn here either: one fixed order per case, chosen to make sorting detectable. +const INSERTION_ORDER: [usize; 8] = [5, 0, 7, 2, 6, 1, 4, 3]; + +/// A value reduced to a comparable spelling, with the writer's documented normalisations applied. +fn canon(o: &Object) -> String { + match o { + Object::Null => "null".into(), + Object::Bool(b) => format!("b{b}"), + Object::Int(i) => format!("i{i}"), + Object::Real(r) => { + if !r.is_finite() { + "i0".into() + } else if r.fract() == 0.0 && r.abs() < 1e15 { + format!("i{}", *r as i64) + } else { + format!("r{r}") + } + } + Object::Name(n) => format!("n{}", String::from_utf8_lossy(n)), + // Bytes, because a literal may be re-emitted as hexadecimal or the reverse; both spellings + // denote the same bytes (§7.3.6). The `hex` flag itself is deliberately not compared — see + // the scope note at the top. + Object::String(s) => format!("s{}", s.bytes.iter().map(|b| format!("{b:02x}")).collect::()), + Object::Array(a) => format!("[{}]", a.iter().map(canon).collect::>().join(",")), + Object::Dict(d) => { + // Sorted inside a value: nested key order is not pinned here. Catalog-level order is, and + // it is asserted in iteration order by `saved`. + let mut pairs: Vec<(String, String)> = d.iter().map(|(k, v)| (String::from_utf8_lossy(k).into_owned(), canon(v))).collect(); + pairs.sort(); + format!("{{{}}}", pairs.iter().map(|(k, v)| format!("{k}=>{v}")).collect::>().join(",")) + } + // Unreachable under the generators below, which build no streams and no references. Kept total + // so this compiles if someone widens them — at which point it should compare decoded bytes and + // resolved targets instead of a constant. + Object::Stream(_) => "stream".into(), + Object::Ref(r) => format!("ref{r}"), + } +} + +fn leaf() -> impl Strategy { + prop_oneof![ + Just(Object::Null), + any::().prop_map(Object::Bool), + (-1_000_000i64..1_000_000i64).prop_map(Object::Int), + (-1e6f64..1e6f64).prop_map(Object::Real), + // Non-finite reals, drawn explicitly. `write_real` (`src/writer.rs:46`) replaces them with + // `0`, and [`canon`] spells both sides that way, so this is what makes the normalisation + // described in the module header actually exercised rather than merely asserted in prose. + prop::sample::select(vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY]).prop_map(Object::Real), + // Raw bytes, including ones a literal string must escape and the NUL byte. + prop::collection::vec(any::(), 0..12).prop_map(|b| Object::String(PdfString::literal(b))), + "[A-Za-z][A-Za-z0-9]{0,6}".prop_map(|s| Object::name(&s)), + ] +} + +/// Nested values to a bounded depth, so generation cannot recurse without limit. +fn value(depth: usize) -> BoxedStrategy { + if depth == 0 { + return leaf().boxed(); + } + prop_oneof![ + leaf().boxed(), + prop::collection::vec(value(depth - 1), 0..5).prop_map(Object::Array).boxed(), + prop::collection::btree_map("[a-z]{1,4}", value(depth - 1), 0..5) + .prop_map(|m| { + let mut d = Dict::new(); + for (k, v) in m { + d.set(k.into_bytes(), v); + } + Object::Dict(d) + }) + .boxed(), + ] + .boxed() +} + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +/// Every catalog key, in the dictionary's own order - structural entries included. `saved` filters to +/// `px*`, which is why sorting of generated keys is visible there but `/Type` moving to the end was not; +/// this covers that. Read from memory before a save and from the parsed file afterwards, so the two sides +/// share only `Dict` iteration, not the serialiser under test. +fn catalog_keys(doc: &Document) -> Vec { + let root = match doc.root() { + Some(r) => r, + None => return Vec::new(), + }; + match doc.get(root).as_dict() { + Some(d) => d.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect(), + None => Vec::new(), + } +} + +/// The catalog's `px*` entries in **iteration order**, so a reordered save fails. +fn saved(doc: &Document) -> Result, TestCaseError> { + let root = doc.root().ok_or_else(|| TestCaseError::fail("no catalog in the saved file"))?; + let cat = doc.get(root); + let d = match cat.as_dict() { + Some(d) => d, + None => return Err(TestCaseError::fail("catalog is not a dictionary")), + }; + Ok(d.iter().filter(|(k, _)| k.starts_with(KEY.as_bytes())).map(|(k, v)| (String::from_utf8_lossy(k).into_owned(), canon(v))).collect()) +} + +/// The catalog's structure, which a reader needs whether or not it holds our test keys. +fn assert_catalog_structure(doc: &Document, style: &str) -> Result<(), TestCaseError> { + let root = doc.root().ok_or_else(|| TestCaseError::fail(format!("no catalog after the {style} save")))?; + let cat = doc.get(root); + let d = match cat.as_dict() { + Some(d) => d, + None => return Err(TestCaseError::fail(format!("catalog is not a dictionary after the {style} save"))), + }; + prop_assert_eq!(d.name(b"Type"), Some(&b"Catalog"[..]), "catalog /Type changed on the {} save", style); + let pages = match d.get(b"Pages") { + Some(p) => p, + None => return Err(TestCaseError::fail(format!("catalog lost /Pages on the {style} save"))), + }; + let resolved = doc.resolve(pages); + let pd = match resolved.as_dict() { + Some(d) => d, + None => return Err(TestCaseError::fail(format!("/Pages does not resolve to a dictionary on the {style} save"))), + }; + prop_assert_eq!(pd.name(b"Type"), Some(&b"Pages"[..]), "/Pages /Type changed on the {} save", style); + let kids = match pd.get(b"Kids").and_then(Object::as_array) { + Some(k) => k, + None => return Err(TestCaseError::fail(format!("/Pages /Kids is missing or not an array on the {style} save"))), + }; + // /Count has to agree with /Kids. A page tree whose count lies still opens, then misnumbers every + // page downstream, so checking presence alone would not be enough. This fixture's tree is empty, so + // what is pinned here is shape and agreement — not the existence of rendered pages. + prop_assert_eq!(pd.int(b"Count"), Some(kids.len() as i64), "/Pages /Count disagrees with its {} kids on the {} save", kids.len(), style); + Ok(()) +} + +/// §7.5.4: every entry of a classic cross-reference table is exactly 20 bytes, closed by a two-byte +/// end of line. Unpadded offsets still open in lenient readers and corrupt strict ones, so the byte +/// shape is asserted rather than inferred from a successful reopen. +fn assert_classic_xref_rows(bytes: &[u8]) -> Result<(), TestCaseError> { + // Anchored on the file's own startxref (§7.5.3) rather than the first "xref" text in the buffer: + // a generated document name could otherwise point the scan at prose. A startxref that does not land + // on the `xref` keyword means a cross-reference stream, which has no table to check here. + let Some(xoff) = startxref(bytes) else { return Err(TestCaseError::fail("no readable startxref in a classic-table save")) }; + let x = xoff as usize; + if bytes.get(x..x + 4) != Some(&b"xref"[..]) { + return Ok(()); + } + let nl = match bytes[x..].iter().position(|b| *b == b'\n') { + Some(n) => n, + None => return Err(TestCaseError::fail("xref keyword not followed by a newline")), + }; + let rest = &bytes[x + nl + 1..]; + let end = match find(rest, b"trailer") { + Some(e) => e, + None => return Err(TestCaseError::fail("cross-reference table with no trailer")), + }; + let mut rows = 0usize; + let mut expected_rows = 0usize; + for line in rest[..end].split(|b| *b == b'\n') { + if line.is_empty() { + continue; + } + // A CR before the line feed belongs to the two-byte terminator (§7.5.4), not to the keyword: + // stripped here so a CR+LF row is still recognised as a legal row rather than reported (wrongly) + // as "no cross-reference rows found". Producers do emit CR+LF terminators. + // + // What catches what: classification keys off the keyword, so a row whose keyword is garbage never + // reaches the width and length asserts below. Those rows are named by the `else` arm at the end + // of this loop; the row-count compare after it is only a backstop. The length assert is not the + // safety net for malformed keywords, and this comment used to imply that it was. + let fields: Vec<&[u8]> = line + .split(|b| *b == b' ') + .filter(|f| !f.is_empty()) + .map(|f| match f.last() { + Some(b'\r') => &f[..f.len() - 1], + _ => f, + }) + .collect(); + if fields.len() == 2 && fields.iter().all(|f| f.iter().all(u8::is_ascii_digit)) { + // ` ` subsection header. + expected_rows += std::str::from_utf8(fields[1]).ok().and_then(|s| s.parse::().ok()).unwrap_or(0); + continue; + } + if fields.len() == 3 && matches!(fields[2], b"n" | b"f") { + // Classified by keyword, not by width: an unpadded row must still be recognised as a row, so + // the failure names the real crime (wrong width) instead of the misleading "no rows found". + // Both keywords are legal — `n` in use, `f` free (the head of the free chain carries + // generation 65535). §7.5.4 fixes each field's width and the whole entry at 20 bytes. + prop_assert_eq!( + fields[0].len(), + 10, + "a cross-reference offset must be 10 digits (§7.5.4), got {:?} in row {:?}", + String::from_utf8_lossy(fields[0]), + String::from_utf8_lossy(line) + ); + prop_assert_eq!( + fields[1].len(), + 5, + "a cross-reference generation must be 5 digits (§7.5.4), got {:?} in row {:?}", + String::from_utf8_lossy(fields[1]), + String::from_utf8_lossy(line) + ); + // Exactly 20 bytes per entry in the file (§7.5.4). Both legal terminators land on 19 bytes + // for the line as split here: SP+LF, which is what this writer emits today, and CR+LF, whose + // terminator is the pair itself. A 21-byte entry (SP+CRLF, or a doubled CR) fails here. + prop_assert_eq!( + line.len(), + 19, + "a cross-reference row is {} bytes; §7.5.4 wants exactly 20 in the file (SP+LF or CR+LF): {:?}", + line.len(), + String::from_utf8_lossy(line) + ); + rows += 1; + } else { + // Inside the table region, a line is either a subsection header or an entry. Anything else is + // named here with its bytes quoted, instead of surfacing later as a bare row-count mismatch + // ("no cross-reference rows found") that blames the table for one bad line. + prop_assert_eq!( + fields.len(), + 3, + "unrecognised line in the cross-reference table, want ` n|f`: {:?}", + String::from_utf8_lossy(line) + ); + prop_assert!( + matches!(fields[2], b"n" | b"f"), + "illegal cross-reference keyword {:?} in row {:?}", + String::from_utf8_lossy(fields[2]), + String::from_utf8_lossy(line) + ); + } + } + prop_assert!(rows > 0, "no cross-reference rows found in a classic-table save"); + prop_assert_eq!(rows, expected_rows, "the subsection headers promised {} rows, the table holds {}", expected_rows, rows); + Ok(()) +} + +/// The `startxref` value of a file, which the next incremental update must point back at via `/Prev`. +/// The keyword is followed by whitespace, then the offset (§7.5.3), so leading whitespace is skipped. +fn startxref(bytes: &[u8]) -> Option { + let at = find(bytes, b"startxref")?; + let tail = &bytes[at + 9..]; + let digits: String = tail.iter().skip_while(|b| b.is_ascii_whitespace()).take_while(|b| b.is_ascii_digit()).map(|b| char::from(*b)).collect(); + digits.parse().ok() +} + +proptest! { + // 256 cases per property; the suite runs in well under a second, so coverage is free here. + #![proptest_config(ProptestConfig::with_cases(256))] + + /// 1. A full save keeps the values it wrote, their order, the catalog's structure and the shape of + /// its cross-reference table — in both output styles — and reopens with nothing to repair. + #[test] + fn full_save_keeps_the_catalog_intact(map in prop::collection::btree_map(0usize..8, value(3), 1..6)) { + let mut doc = Document::new_empty(); + let root = doc.root().expect("an empty document has a catalog"); + doc.update_dict(root, |d| { + for i in INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)) { + d.set(format!("{KEY}{i}").into_bytes(), map[&i].clone()); + } + }) + .map_err(|e| TestCaseError::fail(format!("cannot write the generated catalog: {e}")))?; + // Insertion order is this scrambled sequence filtered to the drawn keys: what must come back. + let expected: Vec<(String, String)> = INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)).map(|i| (format!("{KEY}{i}"), canon(&map[&i]))).collect(); + + // Full key sequence taken from memory BEFORE any byte is written - /Type and /Pages included. + let order_before = catalog_keys(&doc); + + // Right before any save, or the later assertion would prove nothing about saving. + prop_assert_eq!(saved(&doc)?, expected.clone(), "the in-memory catalog is wrong before saving"); + + for object_streams in [true, false] { + let style: &str = if object_streams { "object-stream" } else { "classic-table" }; + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let bytes = write_full(&doc, &opts).map_err(|e| TestCaseError::fail(format!("full save failed: {e}")))?; + + if !object_streams { + assert_classic_xref_rows(&bytes).map_err(|e| TestCaseError::fail(format!("{style}: {e}")))?; + } + + let again = Document::open(Arc::new(bytes)).map_err(|e| TestCaseError::fail(format!("our own full save must reopen: {e}")))?; + + prop_assert!(again.repair_log().is_empty(), "a file we wrote must not need repair ({}); log: {:?}", style, again.repair_log()); + // Values *and* order: README.md promises dictionaries keep their key order. + prop_assert_eq!(saved(&again)?, expected.clone(), "the catalog changed on a {} save (values or order)", style); + // Structural position too: /Type and /Pages must not drift to the end or swap places. + prop_assert_eq!(catalog_keys(&again), order_before.clone(), "the catalog's full key sequence changed on a {} save", style); + assert_catalog_structure(&again, style)?; + } + } + + /// 2. An incremental save leaves the original bytes untouched, chains back through `/Prev`, and + /// reopens carrying every intended value plus the edit. The base is built **both** ways - + /// cross-reference stream and classic table - because the two are serialised by different code, and a + /// path that never runs cannot fail here. Expected contents are computed from the values we put in, + /// not read back out of the writer: comparing against our own earlier output would pass a writer that + /// is consistently wrong, which is the one thing a fidelity test must not do. + #[test] + fn incremental_save_appends_without_touching_the_original(map in prop::collection::btree_map(0usize..8, value(2), 1..5), marker in -1_000_000i64..1_000_000i64) { + let mut doc = Document::new_empty(); + let root = doc.root().expect("an empty document has a catalog"); + doc.update_dict(root, |d| { + for i in INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)) { + d.set(format!("{KEY}{i}").into_bytes(), map[&i].clone()); + } + }) + .map_err(|e| TestCaseError::fail(format!("cannot write the generated catalog: {e}")))?; + + // Our own record of what the catalog should hold, derived from the inputs before any byte is + // written. The edit key joins it below; order follows INSERTION_ORDER, not sorted order (§7.5.4). + let intended: Vec<(String, String)> = INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)).map(|i| (format!("{KEY}{i}"), canon(&map[&i]))).collect(); + + for object_streams in [true, false] { + let style: &str = if object_streams { "object-stream" } else { "classic-table" }; + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + + let base = write_full(&doc, &opts).map_err(|e| TestCaseError::fail(format!("{}: full save failed: {e}", style)))?; + let base_xref = startxref(&base).ok_or_else(|| TestCaseError::fail(format!("{}: the base file has no readable startxref", style)))?; + + let mut again = Document::open(Arc::new(base.clone())).map_err(|e| TestCaseError::fail(format!("{}: the saved file must reopen: {e}", style)))?; + // Independent of the writer's own readings: the reopen must show what we intended to write. + prop_assert_eq!(saved(&again)?, intended.clone(), "{}: the base catalog differs from what we put in", style); + + let root2 = again.root().expect("the reopened document has a catalog"); + again.update_dict(root2, |d| d.set(b"pxMark".to_vec(), Object::Int(marker))).map_err(|e| TestCaseError::fail(format!("{}: cannot apply the incremental edit: {e}", style)))?; + + let bytes = write_incremental(&again, &opts).map_err(|e| TestCaseError::fail(format!("{}: incremental save failed: {e}", style)))?; + prop_assert!(bytes.len() > base.len(), "{}: an incremental save must add bytes ({} over a base of {})", style, bytes.len(), base.len()); + prop_assert_eq!(bytes.get(..base.len()), Some(&base[..]), "{}: an incremental save must not rewrite the original bytes", style); + + let out = Document::open(Arc::new(bytes.clone())).map_err(|e| TestCaseError::fail(format!("{}: the incremental file must reopen: {e}", style)))?; + prop_assert!(out.repair_log().is_empty(), "{}: an increment we wrote must not need repair; log: {:?}", style, out.repair_log()); + + // The revision chain in the crate's own terms (Revision { xref_offset, is_stream }): two + // revisions, the first still pointing at the base cross-reference. Without that link no other + // reader can reach the earlier revision's objects, which is the classic incremental corruption. + // /Prev sits in the trailer for a table base and inside the cross-reference stream for a + // stream base, so it is not read here; `revisions` covers both (§7.5.3-7.5.6). + let revs = out.revisions(); + prop_assert_eq!(revs.len(), 2, "{}: an incremental save must leave two revisions: {:?}", style, revs); + prop_assert_eq!(revs[0].xref_offset, base_xref, "{}: the first revision must still point at the base xref {}", style, base_xref); + prop_assert!(revs[1].xref_offset > base_xref, "{}: the second revision must sit after the first: {:?}", style, revs); + let ends = out.revision_ends(); + prop_assert_eq!(ends.last().copied(), Some(bytes.len()), "{}: the last revision must run to the end of the file: {:?}", style, ends); + + // The base revision opens on its own. "Nothing was rewritten" buys the user this: cut the file + // at the append point and the earlier document still reads, with our intended values. + if let Some(end0) = ends.first().copied() { + let before_doc = Document::open(Arc::new(bytes[..end0].to_vec())).map_err(|e| TestCaseError::fail(format!("{}: the base revision must open alone: {e}", style)))?; + prop_assert_eq!(saved(&before_doc)?, intended.clone(), "{}: the base revision lost or changed values when opened without the increment", style); + } + + assert_catalog_structure(&out, style)?; + + let mut expected = intended.clone(); + expected.push(("pxMark".into(), canon(&Object::Int(marker)))); + prop_assert_eq!(saved(&out)?, expected.clone(), "{}: the increment lost a value, reordered the catalog, or dropped the edit", style); + } + } + + /// 3. Writes then deletes against a plain ordered model, checked after every step and again after + /// save and reopen. Deletes always target a key that exists, so the delete path is never empty. + #[test] + fn edit_sequence_equals_a_plain_map(plan in prop::collection::btree_map(0usize..8, value(2), 1..6).prop_flat_map(|sets| { + let keys: Vec = sets.keys().copied().collect(); + (Just(sets), prop::collection::vec(0usize..keys.len(), 1..=keys.len())).prop_map(move |(s, picks)| { + let mut dels: Vec = picks.into_iter().map(|i| keys[i]).collect(); + // Deduplicated: the same key drawn twice would make the second delete target a key that is + // already gone, which is legal PDF but not what this property means to assert. `picks` draws + // 1..=keys.len() over a non-empty key set, so `dels` cannot come back empty — the guarantee + // lives in the generator, not in an assume that could never fire. + dels.sort_unstable(); + dels.dedup(); + (s, dels) + }) + })) { + let (sets, deletes) = plan; + let mut doc = Document::new_empty(); + let root = doc.root().expect("an empty document has a catalog"); + + let mut model: Vec<(String, String)> = Vec::new(); + for (i, o) in &sets { + let key = format!("{KEY}{i}"); + doc.update_dict(root, |d| d.set(key.clone().into_bytes(), o.clone())).map_err(|e| TestCaseError::fail(format!("cannot write {key}: {e}")))?; + model.push((key.clone(), canon(o))); + prop_assert_eq!(saved(&doc)?, model.clone(), "after writing {} the catalog is wrong before saving", key); + } + + for i in &deletes { + let key = format!("{KEY}{i}"); + prop_assert!(model.iter().any(|(k, _)| k == &key), "the generator tried to delete a key that was never written: {}", key); + doc.update_dict(root, |d| { + d.remove(key.as_bytes()); + }) + .map_err(|e| TestCaseError::fail(format!("cannot delete {key}: {e}")))?; + model.retain(|(k, _)| k != &key); + prop_assert_eq!(saved(&doc)?, model.clone(), "after deleting {} it is still reachable", key); + } + + let bytes = write_full(&doc, &SaveOptions::default()).map_err(|e| TestCaseError::fail(format!("full save failed: {e}")))?; + let again = Document::open(Arc::new(bytes)).map_err(|e| TestCaseError::fail(format!("our own save must reopen: {e}")))?; + let got = saved(&again)?; + prop_assert_eq!(got.clone(), model.clone(), "the reopened catalog differs from the edit history (values or order)"); + for i in 0..8usize { + let key = format!("{KEY}{i}"); + if !model.iter().any(|(k, _)| k == &key) { + prop_assert!(!got.iter().any(|(g, _)| *g == key), "a deleted key came back after save+reopen: {}", key); + } + } + } +} diff --git a/crates/cos/tests/shared_streams.rs b/crates/cos/tests/it/shared_streams.rs similarity index 100% rename from crates/cos/tests/shared_streams.rs rename to crates/cos/tests/it/shared_streams.rs diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/it/writer.rs similarity index 87% rename from crates/cos/tests/writer.rs rename to crates/cos/tests/it/writer.rs index ac04be2e94..1b98664255 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -228,6 +228,63 @@ fn incremental_save_keeps_a_trailer_only_edit() { assert_qpdf_clean(&saved); } +/// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is +/// not written: the file never held it, and writing it would keep what was removed. Additions the +/// update still refers to, from the trailer or a changed object and through other additions, are. +#[test] +fn incremental_save_leaves_out_additions_nothing_refers_to() { + let mut doc = Document::open(Arc::new(classic_table())).unwrap(); + let text = |s: &str| Object::String(pdfcraft_cos::PdfString::literal(s.as_bytes().to_vec())); + let dict = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + entries.iter().for_each(|(k, v)| d.set(k.as_bytes().to_vec(), v.clone())); + Object::Dict(d) + }; + let page_obj = ObjRef::new(3, 0); + let leaf = doc.add(text("kept leaf")); + let kept = doc.add(dict(&[("Leaf", Object::Ref(leaf))])); + doc.update_dict(page_obj, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(kept))).unwrap(); + let by_trailer = doc.add(text("kept by the trailer")); + doc.trailer_mut().set(b"Custom".to_vec(), Object::Ref(by_trailer)); + // Linked from the page and unlinked again, as clearing a mark does. + let dropped_leaf = doc.add(text("dropped leaf")); + let dropped = doc.add(dict(&[("Leaf", Object::Ref(dropped_leaf))])); + doc.update_dict(page_obj, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(dropped)]))).unwrap(); + doc.update_dict(page_obj, |d| { + d.remove(b"Annots"); + }) + .unwrap(); + // Unlinked additions that refer to each other. + let first = doc.add(Object::Null); + let second = doc.add(dict(&[("Next", Object::Ref(first)), ("Note", text("dropped cycle"))])); + doc.set(first, dict(&[("Next", Object::Ref(second))])); + + // Off by default: a writer may add what only it finds again, by scanning. + let kept_all = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(kept_all.windows(12).any(|w| w == b"dropped leaf")); + let saved = write_incremental(&doc, &SaveOptions { drop_unlinked: true, ..SaveOptions::default() }).unwrap(); + let holds = |s: &str| saved.windows(s.len()).any(|w| w == s.as_bytes()); + assert!(holds("kept leaf") && holds("kept by the trailer")); + assert!(!holds("dropped leaf") && !holds("dropped cycle"), "unlinked additions are written"); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.get(leaf).as_string().map(|s| s.bytes.as_slice()), Some(b"kept leaf".as_slice())); + for gone in [dropped, dropped_leaf, first, second] { + assert_eq!(*back.get(gone), Object::Null); + } + assert!(!page(&back).contains(b"Annots")); + assert_qpdf_clean(&saved); + + // Freed beforehand, they are written the same way without the option, and what is added + // after that, nothing referring to it either (a document timestamp), is kept. + let mut freed = doc.clone(); + freed.free_unlinked_additions(); + assert_eq!(write_incremental(&freed, &SaveOptions::default()).unwrap(), saved, "the same objects, freed the same way"); + freed.add(text("added after")); + let stamped = write_incremental(&freed, &SaveOptions::default()).unwrap(); + assert!(stamped.windows(11).any(|w| w == b"added after") && !stamped.windows(12).any(|w| w == b"dropped leaf")); +} + #[test] fn qpdf_accepts_object_stream_output() { let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index fa649de47d..b41caa2b88 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -10,8 +10,13 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +pdfcraft-filters = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-fonts = { workspace = true } +# For `image::bmp_with_pixel_offset`, shared with XFA pictures. +pdfcraft-xfa = { workspace = true } +# Already in the graph through hayro: decodes JBIG2 images for Export ▸ Images. +hayro-jbig2.workspace = true png = "0.18" tiff = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } @@ -22,5 +27,8 @@ thiserror = { workspace = true } encoding_rs = "0.8" [dev-dependencies] +# An independent CCITT decoder, to check Create PDF's Group 4 images. +hayro-ccitt.workspace = true image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } hayro-syntax = { workspace = true } +pdfcraft-filters = { workspace = true } diff --git a/crates/create/README.md b/crates/create/README.md index 499081059a..d359eed9ae 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -6,7 +6,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: - `from_images(&[(name, bytes)])`: one page per image, sized from the image's resolution (PNG `pHYs`, JPEG JFIF density; 72 dpi when absent). JPEG data is embedded as is (`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate, - with transparency as a soft mask. An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG + with transparency as a soft mask. A picture of only pure black and white (a 1-bit PNG or TIFF, + eight-bit grey, a two-colour palette, RGB without a profile) is stored at one bit per pixel, as + CCITT Group 4 or Flate, whichever is smaller as stored (`pdfcraft_filters::encode_g4`). An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG `iCCP`) that matches the image's colour space is kept as an `/ICCBased` colour space with the device space as `/Alternate`; images with the same profile share one profile object. JPEG metadata scanning skips marker fill bytes and reads application segments after the @@ -25,7 +27,20 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: Every function returns a `pdfcraft_cos::Document`; the caller writes it. Image export and Save Image As apply each lossless image's Decode pair before converting its color components. +JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included, +as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more +pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the +page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image +exported opaque, as JPX and CCITT masks are. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. -The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited. +Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. +The existing 16-bit path still uses each sample's high byte. +JPEG image Decode handling remains limited. ICC transforms and calibrated color conversion remain outside this export path. +CCITT fax images (Group 3 and 4) are decoded for export through `pdfcraft_cos::Document::decoded_image` with their +`/DecodeParms` (`/Columns` has to be the image's width; up to `/Height` rows, whatever `/Rows` says), behind other filters such as +`ASCII85Decode` too. Byte-aligned Group 3 rows are read by their end-of-line codes, whether the fill starts each code on a byte or +ends it on one, when the data starts with one (T.4 sends one before the first row), and on bytes otherwise, whatever `/EndOfLine` +says; data that comes up short is reported. Data with codes but none before the first row is read on bytes, and is reported or, +at `/K 0`, may come out wrong. JPEG 2000 and JBIG2 images are still reported, not exported. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 768da4d1f1..b12bd50ec2 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -1,7 +1,7 @@ //! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages //! use, as files. JPEG images are written unchanged; other images, and JPEG images with //! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour -//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are +//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, separations) are //! reported, never silently left out. use std::collections::HashSet; @@ -100,6 +100,116 @@ fn filters(s: &Stream) -> Vec> { } } +/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given +/// by reference, or as an array's entry, or with numbers inside by reference, read too (a predictor +/// given so would otherwise be lost), or, for a JBIG2 image +/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples +/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7). +fn decoded(doc: &Document, s: &Stream) -> Result, String> { + match filters(s).as_slice() { + [f] if f == b"JBIG2Decode" => jbig2(doc, s), + _ => doc.decoded_image(s).map_err(|e| e.to_string()), + } +} + +/// The `/DecodeParms` of a stream with one filter: a dictionary, or the one in an array (7.4.2). +fn decode_parms(doc: &Document, s: &Stream) -> Dict { + let Some(p) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return Dict::default() }; + let p = match &*p { + Object::Array(a) => match a.first() { + Some(first) => doc.resolve(first), + None => return Dict::default(), + }, + _ => p, + }; + p.as_dict().cloned().unwrap_or_default() +} + +/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder +/// shares between pages, themselves coded, so far less than any stream may decode to +/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image. +const MAX_GLOBALS: usize = 32 << 20; + +/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller +/// checked against the export's size limit, and within that limit itself: the decoder decodes the +/// whole page, rows past the image's height too, so a small `/Height` over a far taller page +/// would cost more than the limit allows. Rows past the image's height are dropped, and too few +/// rows are refused by the caller. Globals that can't be read refuse the image, rather than +/// decoding it without them. They are decoded before the page's size is known, so the page's +/// limit can't bound them; they have [`MAX_GLOBALS`] of their own. +fn jbig2(doc: &Document, s: &Stream) -> Result, String> { + let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() { + Some(Object::Stream(g)) => { + Some(doc.with_direct_parms(g).decoded_within(MAX_GLOBALS).map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?) + } + _ => None, + }; + let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?; + if s.dict.int(b"Width") != Some(i64::from(image.width())) { + return Err("the JBIG2 image's width isn't the image's width".into()); + } + if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS { + return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height())); + } + let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0); + let mut rows = Rows::new(image.width() as usize, height); + image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?; + Ok(rows.out) +} + +/// Rows of one-bit samples, each starting on a byte, filled from a decoder's pixels: `columns` +/// a row and at most `rows` rows. Pixels past a row's end and rows past the last are dropped, so +/// the output is bounded by the size the caller gave. +struct Rows { + out: Vec, + row: Vec, + x: usize, + columns: usize, + rows: usize, + done: usize, +} + +impl Rows { + fn new(columns: usize, rows: usize) -> Self { + Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0 } + } + + /// `count` pixels, as 1 samples when `one`. + fn push(&mut self, one: bool, count: usize) { + let end = self.x.saturating_add(count); + if one { + for x in self.x..end.min(self.columns) { + if let Some(b) = self.row.get_mut(x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + self.x = end; + } + + fn end_row(&mut self) { + if self.done < self.rows { + self.out.extend_from_slice(&self.row); + self.done += 1; + } + self.row.fill(0); + self.x = 0; + } +} + +/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0. +impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.push(!black, 1); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.push(!black, (chunks as usize).saturating_mul(8)); + } + fn next_line(&mut self) { + self.end_row(); + } +} + /// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft /// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as /// alpha, JPEG images included, so cut-out images keep their transparent background. @@ -108,8 +218,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1; match f.last().map(Vec::as_slice) { Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()), - Some(b"JBIG2Decode") => return Err("JBIG2 images can't be exported yet".into()), - Some(b"CCITTFaxDecode" | b"CCF") => return Err("CCITT fax images can't be exported yet".into()), + Some(b"JBIG2Decode") if f.len() > 1 => return Err("JBIG2 images inside other filters can't be exported yet".into()), Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()), _ => {} } @@ -148,7 +257,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> space = decoded; (samples, 8) } else { - let data = s.decoded().map_err(|e| e.to_string())?; + let data = decoded(doc, s)?; let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { 1 => 1, @@ -359,6 +468,9 @@ impl Space { } } +/// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that. +const MAX_LOOKUP: usize = 64 << 10; + fn space(doc: &Document, cs: Option<&Object>) -> Result { let Some(cs) = cs else { return Err("the image has no colour space".into()) }; let cs = doc.resolve(cs); @@ -383,17 +495,28 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { Some(b"CalGray") => Ok(Space::Gray), Some(b"CalRGB") => Ok(Space::Rgb), Some(b"Indexed" | b"I") => { - let base = space(doc, a.get(1))?; - if matches!(base, Space::Indexed(..)) { + // Checked before recursing: Indexed is the only space that recurses, so refusing + // an Indexed base here also ends a cyclic base reference. + let nested = match a.get(1).map(|b| doc.resolve(b)).as_deref() { + Some(Object::Array(b)) => matches!(b.first().and_then(Object::as_name), Some(b"Indexed" | b"I")), + Some(Object::Name(n)) => matches!(n.as_slice(), b"Indexed" | b"I"), + _ => false, + }; + if nested { return Err("nested indexed colour spaces aren't valid".into()); } + let base = space(doc, a.get(1))?; let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() { Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8, _ => return Err("an indexed colour space with an invalid hival".into()), }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), - Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?, + // Through its parameters given by reference too, as the image's samples. + Some(Object::Stream(t)) => doc + .with_direct_parms(t) + .decoded_within(MAX_LOOKUP) + .map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?, _ => return Err("an indexed colour space without a lookup table".into()), }; Ok(Space::Indexed(Box::new(base), hival, table)) @@ -453,8 +576,8 @@ fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option Option> { let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?; let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true))); @@ -464,7 +587,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8(); (img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())? } - Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode" | b"CCF") => return None, + Some(b"DCTDecode" | b"DCT" | b"JPXDecode") => return None, _ => { let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { @@ -476,17 +599,14 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> _ => return None, }; let max = (1u32 << bpc.min(8)) - 1; - unpack(&m.decoded().ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() + unpack(&decoded(doc, m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() } }; - let invert = m - .dict - .get(b"Decode") - .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) - .unwrap_or(false); - if invert { - px.iter_mut().for_each(|v| *v = 255 - *v); + if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) { + for value in &mut px { + let t = f64::from(*value) / 255.0; + *value = (((1.0 - t) * lo + t * hi).clamp(0.0, 1.0) * 255.0).round() as u8; + } } if (mw, mh) == (w, h) { return Some(px); diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 5717977b1e..36b52d4211 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -2,7 +2,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize}; mod extract; mod upright_image; @@ -108,9 +108,42 @@ fn jpeg_icc(mut chunks: Vec<(u8, u8, &[u8])>) -> Option> { complete.then(|| chunks.iter().flat_map(|c| c.2.iter().copied()).collect()) } +/// The samples of a black-and-white image packed one bit per pixel (1 for white, each row +/// starting on a byte): eight-bit grey (`n` 1) or RGB (`n` 3) whose every pixel is pure black or +/// pure white, or one-bit grey, already packed (a bilevel TIFF). `None` for anything else, found +/// at its first other pixel. +fn bilevel(data: &[u8], (w, h): (u32, u32), n: usize, bits: Option) -> Option> { + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + let stride = w.div_ceil(8); + let size = stride.checked_mul(h)?; + match (bits, n) { + (Some(1), 1) => data.get(..size).map(<[u8]>::to_vec), + (Some(8), 1 | 3) if w > 0 => { + let samples = data.get(..w.checked_mul(h)?.checked_mul(n)?)?; + let mut packed = vec![0u8; size]; + for (i, px) in samples.chunks_exact(n).enumerate() { + let white = match px { + [0] | [0, 0, 0] => false, + [255] | [255, 255, 255] => true, + _ => return None, + }; + if white && let Some(b) = packed.get_mut((i / w) * stride + (i % w) / 8) { + *b |= 0x80 >> (i % w % 8); + } + } + Some(packed) + } + _ => None, + } +} + /// Add `img` to `doc` as an image XObject. An ICC profile becomes an `/ICCBased` colour space /// (with the device space as `/Alternate`); `profiles` shares one object between images that /// carry the same profile. +/// +/// A black-and-white image is stored at one bit per pixel, as CCITT Group 4 when that is smaller +/// than Flate: losslessly, and several times smaller than the eight bits a scan saved as a 1-bit +/// PNG decodes to. RGB keeps its colour space when it has a colour profile. fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, ObjRef)>) -> ObjRef { let mut d = img.dict; d.set(b"Type".to_vec(), Object::name("XObject")); @@ -118,12 +151,48 @@ fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, Obj d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64)); d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64)); let device = d.name(b"ColorSpace").map(<[u8]>::to_vec); - let n = match device.as_deref() { + let mut n = match device.as_deref() { Some(b"DeviceGray") => 1, Some(b"DeviceRGB") => 3, Some(b"DeviceCMYK") => 4, _ => 0, }; + let (mut data, mut filtered) = (img.data, img.filtered); + let profiled_rgb = n == 3 && img.icc.is_some(); + if !filtered + && !profiled_rgb + && let Some(packed) = bilevel(&data, img.px, n, d.int(b"BitsPerComponent")) + { + if n == 3 { + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + n = 1; + } + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + // The smaller as stored, dictionary included: Group 4's parameters outweigh its saving on + // a tiny image. + let stored = |d: &Dict, data: &[u8]| { + let mut out = Vec::new(); + serialize(&Object::Dict(d.clone()), &mut out); + out.len().saturating_add(data.len()) + }; + let mut flate_dict = d.clone(); + flate_dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let flate = pdfcraft_filters::encode_flate(&packed); + (d, data) = match pdfcraft_filters::encode_g4(&packed, img.px.0, img.px.1) { + Ok(g4) => { + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(img.px.0))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(img.px.1))); + let mut g4_dict = d.clone(); + g4_dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + g4_dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + if stored(&g4_dict, &g4) < stored(&flate_dict, &flate) { (g4_dict, g4) } else { (flate_dict, flate) } + } + Err(_) => (flate_dict, flate), + }; + filtered = true; + } if let (Some(icc), Some(device)) = (img.icc.and_then(|p| icc_for(p, n)), device) { let r = match profiles.iter().find(|(p, _)| *p == icc) { Some((_, r)) => *r, @@ -142,7 +211,7 @@ fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, Obj let mr = doc.add(Object::Stream(Stream::flate(m, &alpha))); d.set(b"SMask".to_vec(), Object::Ref(mr)); } - let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) }; + let stream = if filtered { Stream::from_raw(d, data) } else { Stream::flate(d, &data) }; doc.add(Object::Stream(stream)) } @@ -269,7 +338,7 @@ fn jpeg(name: &str, bytes: &[u8]) -> Result { 0xEE if seg.starts_with(b"Adobe") => adobe = true, // APP2 ICC profile, possibly split over several chunks. 0xE2 => { - if let Some(([seq, count], data)) = seg.strip_prefix(b"ICC_PROFILE\0").and_then(|r| r.split_first_chunk::<2>()) { + if let Some(([seq, count], data)) = seg.strip_prefix(b"ICC_PROFILE\0").and_then(<[u8]>::split_first_chunk::<2>) { icc_chunks.push((*seq, *count, data)); } } @@ -393,17 +462,38 @@ fn rgba_image(rgba: &[u8], (w, h): (u32, u32), dpi: (f64, f64)) -> Embedded { /// BMP and GIF (the first frame), through the `image` decoders. Their resolution is not read: /// 72 dpi, as for images without one. fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result { + // A GIF's first frame is painted on a canvas the size of its logical screen, however small + // the frame: a few bytes can ask for hundreds of MB. + if format == image::ImageFormat::Gif + && let Some(&[w0, w1, h0, h1]) = bytes.get(6..10) + && u64::from(u16::from_le_bytes([w0, w1])) * u64::from(u16::from_le_bytes([h0, h1])) > MAX_GIF_PIXELS + { + return Err(CreateError::Image(name.into(), "the GIF's screen is larger than 4096×4096 pixels".into())); + } let img = image::load_from_memory_with_format(bytes, format).map_err(|e| CreateError::Image(name.into(), e.to_string()))?; - let rgba = img.to_rgba8(); + let rgba = img.into_rgba8(); Ok(rgba_image(rgba.as_raw(), rgba.dimensions(), (72.0, 72.0))) } +/// Largest GIF logical screen, in pixels (4096×4096). +const MAX_GIF_PIXELS: u64 = 1 << 24; + /// Most decoded bytes one TIFF page or PNG may take: room for the 768 MiB CMYK poster in #665, /// twice over. const MAX_DECODED_BYTES: usize = 2 << 30; -/// Every page of a TIFF (multi-page scans become multi-page PDFs). -fn tiff_pages(name: &str, bytes: &[u8]) -> Result, CreateError> { +/// Most decoded bytes all the pages of one TIFF may take together: its pages stay in memory until +/// they are embedded, and many small directories can share one compressed strip. Saturates to the +/// whole address space on 32-bit targets (wasm32), where 4 GiB doesn't fit in `usize`. +const MAX_DECODED_TOTAL: usize = MAX_DECODED_BYTES.saturating_mul(2); + +/// The first `wanted` pages of a TIFF (multi-page scans become multi-page PDFs). +fn tiff_pages(name: &str, bytes: &[u8], wanted: usize) -> Result, CreateError> { + tiff_pages_within(name, bytes, wanted, MAX_DECODED_TOTAL) +} + +/// [`tiff_pages`], refused once the pages kept take more than `budget` bytes. +fn tiff_pages_within(name: &str, bytes: &[u8], wanted: usize, budget: usize) -> Result, CreateError> { use tiff::ColorType as C; use tiff::decoder::{Decoder, DecodingResult, Limits}; use tiff::tags::Tag; @@ -415,6 +505,7 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result, CreateError> { limits.decoding_buffer_size = MAX_DECODED_BYTES; let mut dec = Decoder::new(std::io::Cursor::new(bytes)).map_err(|e| bad(e.to_string()))?.with_limits(limits); let mut out = Vec::new(); + let mut kept = 0usize; loop { let (w, h) = dec.dimensions().map_err(|e| bad(e.to_string()))?; let ct = dec.colortype().map_err(|e| bad(e.to_string()))?; @@ -469,8 +560,12 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result, CreateError> { } other => return Err(bad(format!("{other:?} TIFF images aren't supported yet"))), }; + kept = kept.saturating_add(page.data.len()).saturating_add(page.smask.as_ref().map_or(0, |(_, alpha)| alpha.len())); + if kept > budget { + return Err(bad(format!("the TIFF's pages take more than {} MiB together", budget >> 20))); + } out.push(page); - if !dec.more_images() { + if out.len() >= wanted || !dec.more_images() { break; } dec.next_image().map_err(|e| bad(e.to_string()))?; @@ -526,16 +621,17 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { (lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text) } -/// Detect the image format from its bytes; a TIFF may hold several pages. -fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { +/// Detect the image format from its bytes; a TIFF may hold several pages, of which the first +/// `wanted` are read. +fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result, CreateError> { if bytes.starts_with(&[0xFF, 0xD8]) { Ok(vec![jpeg(name, &upright_image::upright(bytes))?]) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Ok(vec![png_image(name, bytes)?]) } else if is_tiff(bytes) { - tiff_pages(name, bytes) + tiff_pages(name, bytes, wanted) } else if bytes.starts_with(b"BM") { - Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?]) + Ok(vec![decoded(name, &pdfcraft_xfa::image::bmp_with_pixel_offset(bytes), image::ImageFormat::Bmp)?]) } else if is_gif(bytes) { Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?]) } else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) { @@ -548,7 +644,7 @@ fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { /// Embed an image file (its first page, for TIFFs) as an image XObject in `doc`. Returns the /// object and the image's natural size in points (from its resolution). pub fn image_xobject(doc: &mut Document, name: &str, bytes: &[u8]) -> Result<(ObjRef, (f64, f64)), CreateError> { - let img = embed(name, bytes)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?; + let img = embed(name, bytes, 1)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?; let size = (img.px.0 as f64 * 72.0 / img.dpi.0, img.px.1 as f64 * 72.0 / img.dpi.1); Ok((add_image(doc, img, &mut Vec::new()), size)) } @@ -580,7 +676,7 @@ pub fn from_images_with_resolution(images: &[(String, Vec)], resolution: Ima } let mut doc = Document::new_empty(); let mut profiles = Vec::new(); - for img in images.iter().map(|(name, bytes)| embed(name, bytes)).collect::, _>>()?.into_iter().flatten() { + for img in images.iter().map(|(name, bytes)| embed(name, bytes, usize::MAX)).collect::, _>>()?.into_iter().flatten() { let dpi = match resolution { ImageResolution::Embedded => img.dpi, ImageResolution::Dpi(dpi) => (dpi, dpi), diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 6356105699..03c93d4a51 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -186,7 +186,7 @@ fn bmp_gif_and_multi_page_tiff_images() { let mut enc = image::codecs::gif::GifEncoder::new(&mut gif); enc.encode(img.as_raw(), 2, 2, image::ExtendedColorType::Rgba8).unwrap(); } - // TIFF: two pages, an 8-bit gray one at 144 dpi and a 1-bit one. + // TIFF: two 8-bit gray pages, one at 144 dpi and one of only black and white. let mut tif = std::io::Cursor::new(Vec::new()); { let mut enc = tiff::encoder::TiffEncoder::new(&mut tif).unwrap(); @@ -206,6 +206,26 @@ fn bmp_gif_and_multi_page_tiff_images() { assert!(matches!(from_images(&[("x.webp".into(), b"RIFF0000WEBP".to_vec())]), Err(CreateError::Image(..)))); } +/// #4937: a BMP whose header gives the pixel data's offset as 0 is placed as the same image as +/// the intact file, not shifted by the header bytes. +#[test] +fn a_bmp_with_a_zero_pixel_offset_is_placed_like_the_intact_file() { + use image::ImageEncoder; + let mut bmp = Vec::new(); + let pixels: Vec = (0..18).map(|i| i * 13).collect(); + image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&pixels, 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let mut zero = bmp.clone(); + zero[10..14].copy_from_slice(&0u32.to_le_bytes()); + let samples = |data: Vec| { + let doc = reopen(&from_images(&[("a.bmp".into(), data)]).unwrap()); + let res = doc.resolve(pages(&doc)[0].get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let Object::Stream(s) = &*doc.resolve(xo.get(b"Im0").unwrap()) else { panic!("not an image") }; + s.decoded().unwrap() + }; + assert_eq!(samples(zero), samples(bmp)); +} + /// #665 lifted the tiff crate's 256 MiB decode limit; a few bytes claiming a 60000x60000 page /// (3.6 GB of gray pixels) must still be refused, not allocated. #[test] @@ -682,6 +702,74 @@ fn jpeg_images_with_a_soft_mask_export_as_png_with_alpha() { assert_eq!((out.images[0].extension, &out.images[0].data), ("jpg", &real_jpeg())); } +#[test] +/// Both image export paths apply mask intervals independently of the image's component Decode. +fn soft_mask_decode_ranges_become_exported_alpha() { + for indirect in [false, true] { + for (range, expected) in [ + ([0.25, 0.75], [64, 128, 191]), + ([0.75, 0.25], [191, 127, 64]), + ([1.0, 0.0], [255, 127, 0]), + ([-1.0, 2.0], [0, 129, 255]), + ([0.5, 0.5], [128, 128, 128]), + ] { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let endpoints = range.into_iter().map(|v| { + let value = Object::Real(v); + if indirect { Object::Ref(doc.add(value)) } else { value } + }); + let decode = Object::Array(endpoints.collect()); + let decode = if indirect { Object::Ref(doc.add(decode)) } else { decode }; + let mut mask = image_dict(3, 1, "DeviceGray", 8); + mask.set(b"Decode".to_vec(), decode); + let mask = doc.add(Object::Stream(Stream::from_raw(mask, vec![0, 128, 255]))); + let mut image = image_dict(3, 1, "DeviceRGB", 8); + image.set(b"SMask".to_vec(), Object::Ref(mask)); + image.set(b"Decode".to_vec(), decode_array(&[1.0, 0.0, 0.0, 1.0, 0.0, 1.0])); + Stream::from_raw(image, [0, 64, 255].repeat(3)) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(out.images.len(), 1); + let pixels = exported_png_pixels(&out.images[0].data); + assert_eq!(alphas(&pixels), expected, "Decode {range:?}, indirect={indirect}"); + assert!(pixels.as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 64, 255]), "image component Decode stays independent"); + let (ext, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(ext, "png"); + assert_eq!(exported_png_pixels(&bytes), pixels, "Save Image As and Export all images agree"); + } + } +} + +#[test] +/// Packed and JPEG samples map to opacity before resizing to their image's dimensions. +fn soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing() { + for jpeg in [false, true] { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mask = if jpeg { + let mut mask = image_dict(4, 2, "DeviceGray", 8); + mask.set(b"Filter".to_vec(), Object::name("DCTDecode")); + mask.set(b"Decode".to_vec(), decode_array(&[0.25, 0.25])); + Stream::from_raw(mask, real_jpeg()) + } else { + let mut mask = image_dict(2, 1, "DeviceGray", 1); + mask.set(b"Decode".to_vec(), decode_array(&[-1.0, 2.0])); + Stream::from_raw(mask, vec![0b0100_0000]) + }; + let mask = doc.add(Object::Stream(mask)); + let mut image = image_dict(4, 2, "DeviceRGB", 8); + image.set(b"SMask".to_vec(), Object::Ref(mask)); + Stream::from_raw(image, vec![10; 24]) + }); + // Clipping before resizing differs from resizing first, then clipping to [0, 255]. + let expected = if jpeg { vec![64; 8] } else { vec![0, 64, 191, 255, 0, 64, 191, 255] }; + assert_eq!(alphas(&exported_rgba(&reopen(&doc)).1), expected, "jpeg={jpeg}"); + } +} + #[test] fn soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha() { // A 2×1 soft mask on a 4×2 image is scaled to the image's size. @@ -832,6 +920,15 @@ fn indexed_hival_resolves_indirect_values_and_stream_palettes() { } } +#[test] +fn indexed_lookup_stream_has_a_size_cap() { + // A lookup table needs 256 × 4 bytes at most; a 1 MiB one is refused, not decoded whole. + let mut palette = vec![255, 0, 0, 0, 0, 255]; + palette.resize(1 << 20, 0); + let (doc, image) = indexed_export_fixture(Object::Int(1), palette, true, false, 8, vec![0, 1, 0, 1]); + assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("lookup table")); +} + #[test] fn indexed_hival_boundary_values_keep_valid_palette_entries() { let palette: Vec = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect(); @@ -1181,3 +1278,934 @@ fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { let img = image_of(&doc, 1); assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); } + +#[test] +fn a_cyclic_indexed_colour_space_is_refused() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + // 5 0 obj [/Indexed 5 0 R 255 <00>]: the base is the indexed space itself. + let cs = doc.add(Object::Null); + doc.set(cs, Object::Array(vec![Object::name("Indexed"), Object::Ref(cs), Object::Int(255), Object::String(PdfString::literal(vec![0]))])); + with_image(&mut doc, |_| { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"ColorSpace".to_vec(), Object::Ref(cs)); + Stream::from_raw(dict, vec![0]) + }); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty()); + assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped); +} + +/// A baseline TIFF of one uncompressed strip of 1-bit samples, `packed` rows each starting on a +/// byte, 0 for black (BlackIsZero; TIFF 6.0, section 3): the `tiff` crate writes no 1-bit images. +fn one_bit_tiff(w: u32, h: u32, packed: &[u8]) -> Vec { + // Tag, type (3 SHORT, 4 LONG), value; the strip follows the IFD. + let entries: [(u16, u16, u32); 8] = + [(256, 4, w), (257, 4, h), (258, 3, 1), (259, 3, 1), (262, 3, 1), (273, 4, 0), (278, 4, h), (279, 4, packed.len() as u32)]; + let strip = 8 + 2 + entries.len() as u32 * 12 + 4; + let mut out = b"II*\0".to_vec(); + out.extend(8u32.to_le_bytes()); + out.extend((entries.len() as u16).to_le_bytes()); + for (tag, kind, value) in entries { + let value = if tag == 273 { strip } else { value }; + out.extend(tag.to_le_bytes()); + out.extend(kind.to_le_bytes()); + out.extend(1u32.to_le_bytes()); + match kind { + 3 => out.extend((value as u16).to_le_bytes().into_iter().chain([0, 0])), + _ => out.extend(value.to_le_bytes()), + } + } + out.extend(0u32.to_le_bytes()); + out.extend_from_slice(packed); + out +} + +/// A bilevel TIFF (one bit a sample) stays one bit, stored as Group 4 when that is smaller, and +/// exports as its picture. +#[test] +fn a_one_bit_tiff_is_stored_as_group_4() { + let (w, h) = (1200u32, 240u32); + let bits = strokes(w, h); + let stride = (w as usize).div_ceil(8); + let picture: Vec = + (0..(w * h) as usize).map(|i| bits[i / w as usize * stride + i % w as usize / 8] & (0x80 >> (i % w as usize % 8)) != 0).collect(); + let doc = from_images(&[("scan.tif".into(), one_bit_tiff(w, h, &bits))]).unwrap(); + let im = image_of(&doc, 0); + assert_eq!((im.int(b"BitsPerComponent"), im.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture); +} + +/// A TIFF of `pages` 64×64 gray pages, then (with `unreadable`) one whose samples Create can't read. +fn gray_tiff(pages: usize, unreadable: bool) -> Vec { + let mut tif = std::io::Cursor::new(Vec::new()); + let mut enc = tiff::encoder::TiffEncoder::new(&mut tif).unwrap(); + for _ in 0..pages { + enc.write_image::(64, 64, &[128; 64 * 64]).unwrap(); + } + if unreadable { + enc.write_image::(1, 1, &[0.5; 3]).unwrap(); + } + tif.into_inner() +} + +/// Many small IFDs can each decode to a large page: the pages a TIFF keeps in memory together +/// are bounded, and embedding one image reads only its first page. +#[test] +fn tiff_pages_are_decoded_within_a_total_budget() { + let tif = gray_tiff(3, false); + assert!(matches!(tiff_pages_within("t.tif", &tif, usize::MAX, 10_000), Err(CreateError::Image(..))), "12 KiB of pages past a 10 KB budget"); + assert_eq!(tiff_pages_within("t.tif", &tif, usize::MAX, 20_000).unwrap().len(), 3); + let tif = gray_tiff(1, true); + let mut doc = Document::new_empty(); + assert!(image_xobject(&mut doc, "t.tif", &tif).is_ok(), "the unreadable last page is never decoded"); +} + +/// A one-pixel GIF on a `w`×`h` logical screen: the decoder paints it on a canvas that size. +fn gif_on_screen(w: u16, h: u16) -> Vec { + let mut gif = b"GIF89a".to_vec(); + gif.extend_from_slice(&w.to_le_bytes()); + gif.extend_from_slice(&h.to_le_bytes()); + gif.extend_from_slice(&[0x80, 0, 0, 0xFF, 0xFF, 0xFF, 0, 0, 0]); + gif.extend_from_slice(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 1, 0, 0x3B]); + gif +} + +/// The decoder's own limit lets a 74-byte GIF ask for a canvas of hundreds of MB, which the +/// conversion then copies; a screen past [`MAX_GIF_PIXELS`] is refused before decoding. +#[test] +fn a_tiny_gif_on_a_huge_screen_is_refused() { + assert!(matches!(from_images(&[("big.gif".into(), gif_on_screen(4200, 4100))]), Err(CreateError::Image(..)))); + let doc = reopen(&from_images(&[("small.gif".into(), gif_on_screen(40, 30))]).unwrap()); + assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 40.0, 30.0]); +} + +/// A PNG of `w` × `h` samples `data` in `color` at `depth`, with a palette or an ICC profile. +fn png_of(w: u32, h: u32, color: png::ColorType, depth: png::BitDepth, data: &[u8], palette: Option>, icc: Option>) -> Vec { + let mut out = Vec::new(); + { + let mut info = png::Info::with_size(w, h); + info.color_type = color; + info.bit_depth = depth; + info.palette = palette.map(Into::into); + info.icc_profile = icc.map(Into::into); + let mut writer = png::Encoder::with_info(&mut out, info).unwrap().write_header().unwrap(); + writer.write_image_data(data).unwrap(); + } + out +} + +/// Rows of `w` one-bit samples (1 white), each on a new byte: lines of glyphs drawn from a few +/// shapes at uneven spacing, whose edges move a pixel now and then, as a scan of text has them. +/// (Clean, evenly spaced strokes favour Flate over Group 4.) +fn strokes(w: u32, h: u32) -> Vec { + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let (w, h) = (w as usize, h as usize); + for line in (4..h.saturating_sub(18)).step_by(24) { + let mut x = 8; + while x + 16 < w { + let wide = 5 + (next() % 4) as usize; + let shape = next() % 4; + let mut fill = |x0: usize, x1: usize, y0: usize, y1: usize| { + for y in line + y0..line + y1 { + let ragged = |r: u64| match r % 8 { + 0 => 0, + 1 => 2, + _ => 1, + }; + let (left, right) = ((x + x0 + 1).saturating_sub(ragged(next())), x + x1 + ragged(next())); + for xx in left..right.saturating_sub(1).min(w) { + data[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + }; + match shape { + 0 => fill(0, 2, 0, 14), + 1 => { + fill(0, wide, 4, 6); + fill(0, wide, 12, 14); + fill(0, 2, 4, 14); + fill(wide - 2, wide, 4, 14); + } + 2 => { + fill(0, 2, 4, 14); + fill(wide - 2, wide, 6, 14); + fill(0, wide - 1, 4, 6); + } + _ => { + fill(0, 2, 7, 14); + fill(0, 2, 2, 4); + } + } + x += wide + 2 + (next() % 2) as usize + if next() % 6 == 0 { 7 } else { 0 }; + } + } + data +} + +/// The one-bit samples (true for white) a Group 4 image stream decodes to, through an +/// independent decoder. +fn g4_rows(s: &Stream) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.1.push(white); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(white, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let (w, h) = (s.dict.int(b"Width").unwrap() as u32, s.dict.int(b"Height").unwrap() as u32); + let settings = hayro_ccitt::DecodeSettings { + columns: w, + rows: h, + end_of_block: true, + end_of_line: false, + rows_are_byte_aligned: false, + encoding: hayro_ccitt::EncodingMode::Group4, + invert_black: false, + }; + let mut out = Rows(Vec::new(), Vec::new(), w as usize); + hayro_ccitt::decode(&s.raw, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)).unwrap(); + out.0 +} + +fn image_stream(doc: &Document, page: usize) -> Stream { + let p = &pages(doc)[page]; + let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + match &*doc.resolve(xo.get(b"Im0").unwrap()) { + Object::Stream(s) => s.clone(), + _ => panic!("not an image"), + } +} + +/// Black-and-white pictures are stored at one bit per pixel, as CCITT Group 4 when that is +/// smaller, whichever way the file holds them: a 1-bit PNG (which decodes to eight bits), eight-bit +/// grey of only black and white, a two-colour palette, black-and-white RGB. The bits are the +/// picture's. A grey profile stays; a picture with any other shade, colour, or an RGB profile is +/// stored as before. +#[test] +fn black_and_white_images_are_stored_at_one_bit() { + use png::{BitDepth, ColorType}; + let (w, h) = (1200u32, 240u32); + let bits = strokes(w, h); + let stride = (w as usize).div_ceil(8); + let white = |x: usize, y: usize| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0; + let expected: Vec> = (0..h as usize).map(|y| (0..w as usize).map(|x| white(x, y)).collect()).collect(); + let grey: Vec = expected.iter().flatten().map(|&wh| if wh { 255 } else { 0 }).collect(); + let rgb: Vec = grey.iter().flat_map(|&v| [v, v, v]).collect(); + let pictures = [ + png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None), + png_of(w, h, ColorType::Grayscale, BitDepth::Eight, &grey, None, None), + png_of(w, h, ColorType::Indexed, BitDepth::One, &bits.iter().map(|b| !b).collect::>(), Some(vec![255, 255, 255, 0, 0, 0]), None), + png_of(w, h, ColorType::Rgb, BitDepth::Eight, &rgb, None, None), + png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, Some(icc_profile(b"GRAY"))), + ]; + let files: Vec<(String, Vec)> = pictures.into_iter().enumerate().map(|(i, p)| (format!("{i}.png"), p)).collect(); + let doc = reopen(&from_images(&files).unwrap()); + for page in 0..5 { + let s = image_stream(&doc, page); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..])), "page {page}"); + assert_eq!(g4_rows(&s), expected, "page {page}"); + assert!(s.raw.len() < bits.len(), "page {page}: {} bytes, smaller than its one-bit samples", s.raw.len()); + } + assert_eq!(image_of(&doc, 3).name(b"ColorSpace"), Some(&b"DeviceGray"[..]), "black-and-white RGB becomes grey"); + assert_eq!(icc_of(&doc, &image_of(&doc, 4)).map(|(n, ..)| n), Some(1), "the grey profile stays"); + + // Another shade, colour, an RGB profile: as before. A tiny picture: one bit, but Flate. + let mut shaded = grey.clone(); + shaded[5] = 128; + let mut coloured = rgb.clone(); + coloured[0] = 200; + let others = [ + png_of(w, h, ColorType::Grayscale, BitDepth::Eight, &shaded, None, None), + png_of(w, h, ColorType::Rgb, BitDepth::Eight, &coloured, None, None), + png_of(w, h, ColorType::Rgb, BitDepth::Eight, &rgb, None, Some(icc_profile(b"RGB "))), + png_of(2, 2, ColorType::Grayscale, BitDepth::Eight, &[0, 255, 255, 0], None, None), + ]; + let files: Vec<(String, Vec)> = others.into_iter().enumerate().map(|(i, p)| (format!("o{i}.png"), p)).collect(); + let doc = reopen(&from_images(&files).unwrap()); + let kept: Vec<(Option, Option>)> = + (0..4).map(|p| (image_of(&doc, p).int(b"BitsPerComponent"), image_of(&doc, p).name(b"Filter").map(<[u8]>::to_vec))).collect(); + assert_eq!( + kept[..3], + [(Some(8), Some(b"FlateDecode".to_vec())), (Some(8), Some(b"FlateDecode".to_vec())), (Some(8), Some(b"FlateDecode".to_vec()))] + ); + assert_eq!(kept[3], (Some(1), Some(b"FlateDecode".to_vec())), "Group 4 isn't smaller for 2 × 2"); +} + +/// The pixels of an exported PNG (RGB), as true for white. +fn exported_white(png_data: &[u8]) -> Vec { + let mut reader = png::Decoder::new(std::io::Cursor::new(png_data.to_vec())).read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut buf).unwrap(); + assert_eq!(frame.color_type, png::ColorType::Rgb); + buf[..frame.buffer_size()].as_chunks::<3>().0.iter().map(|p| *p == [255, 255, 255]).collect() +} + +/// Export ▸ Images reads CCITT fax images: a page created from a 1-bit PNG (Group 4 now) exports as +/// the picture, as does the picture coded the other way round with /BlackIs1, the picture with its +/// filter and parameters in arrays, with /K and /Columns given by reference, with a /Rows below its +/// height, and behind another filter (its parameters by reference too). One whose /Columns aren't +/// its width is skipped, not misread. +#[test] +fn ccitt_fax_images_export_as_their_picture() { + use png::{BitDepth, ColorType}; + let (w, h) = (1200u32, 240u32); + let bits = strokes(w, h); + let stride = (w as usize).div_ceil(8); + let picture: Vec = + (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| (y, x))).map(|(y, x)| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect(); + let mut doc = from_images(&[("scan.png".into(), png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None))]).unwrap(); + assert_eq!(image_of(&doc, 0).name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture); + + // The same picture coded with 1 for black, which /BlackIs1 says. + let r = { + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + xo.get(b"Im0").and_then(Object::as_ref).unwrap() + }; + let base = image_of(&doc, 0); + let with = |black_is_1: bool, columns: i64, data: Vec| { + let mut d = base.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(columns)); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(h))); + parms.set(b"BlackIs1".to_vec(), Object::Bool(black_is_1)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + d.remove(b"Length"); + Stream::from_raw(d, data) + }; + let inverted: Vec = bits.iter().map(|b| !b).collect(); + let stream = with(true, i64::from(w), pdfcraft_filters::encode_g4(&inverted, w, h).unwrap()); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_white(&out.images[0].data), picture, "BlackIs1"); + + let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_g4(&bits, w, h).unwrap()); + let parms = stream.dict.get(b"DecodeParms").cloned().unwrap(); + stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![parms])); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "in arrays"); + + let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_g4(&bits, w, h).unwrap()); + let mut parms = stream.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + parms.set(b"K".to_vec(), Object::Ref(doc.add(Object::Int(-1)))); + parms.set(b"Columns".to_vec(), Object::Ref(doc.add(Object::Int(i64::from(w))))); + parms.set(b"Rows".to_vec(), Object::Int(1)); + stream.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "/K and /Columns by reference, /Rows 1"); + + // Behind another filter, with its parameters second in the array, as 7.4.1 pairs them. + let g4 = pdfcraft_filters::encode_g4(&bits, w, h).unwrap(); + let hex = g4.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + for (outer, data) in [("ASCIIHexDecode", hex), ("FlateDecode", pdfcraft_filters::encode_flate(&g4))] { + let mut stream = with(false, i64::from(w), data); + let parms = stream.dict.get(b"DecodeParms").cloned().unwrap(); + stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, parms])); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name(outer), Object::name("CCITTFaxDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{outer}: {:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "behind {outer}"); + } + // Behind Flate with a PNG predictor, whose parameters are given by reference. + let padded: Vec = g4.iter().copied().chain(std::iter::repeat_n(0, 16 - g4.len() % 16)).collect(); + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 16, early_change: 1 }; + let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &padded).unwrap(); + let mut flate_parms = Dict::new(); + flate_parms.set(b"Predictor".to_vec(), Object::Int(12)); + flate_parms.set(b"Columns".to_vec(), Object::Int(16)); + let flate_parms = doc.add(Object::Dict(flate_parms)); + let mut stream = with(false, i64::from(w), predicted); + let parms = stream.dict.get(b"DecodeParms").cloned().unwrap(); + stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Ref(flate_parms), parms])); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "behind Flate with a predictor given by reference"); + // CCITT before another filter isn't a picture yet. + let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_flate(&g4)); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode"), Object::name("FlateDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "CCITT, then Flate: skipped"); + + let stream = with(false, i64::from(w) + 8, pdfcraft_filters::encode_g4(&bits, w, h).unwrap()); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "columns other than the width: skipped"); +} + +/// Group 3 data: rows of run lengths below 64, white first, coded one-dimensionally with T.4's +/// terminating codes (ITU-T T.4 Tables 2 and 3). Each row starts with an end-of-line code with +/// `eol`, then with `tag` a 1 bit (the next row one-dimensional, as `/K` above 0 has it). Fill bits +/// (0s) go where `fill` says. +fn group_3(rows: &[impl AsRef<[u32]>], eol: bool, tag: bool, fill: Fill) -> Vec { + // The codes for runs 0 to 63, in order. + const WHITE: &str = concat!( + "00110101 000111 0111 1000 1011 1100 1110 1111 10011 10100 00111 01000 001000 000011 110100 110101 101010 101011 ", + "0100111 0001100 0001000 0010111 0000011 0000100 0101000 0101011 0010011 0100100 0011000 00000010 00000011 00011010 ", + "00011011 00010010 00010011 00010100 00010101 00010110 00010111 00101000 00101001 00101010 00101011 00101100 00101101 ", + "00000100 00000101 00001010 00001011 01010010 01010011 01010100 01010101 00100100 00100101 01011000 01011001 01011010 ", + "01011011 01001010 01001011 00110010 00110011 00110100", + ); + const BLACK: &str = concat!( + "0000110111 010 11 10 011 0011 0010 00011 000101 000100 0000100 0000101 0000111 00000100 00000111 000011000 0000010111 ", + "0000011000 0000001000 00001100111 00001101000 00001101100 00000110111 00000101000 00000010111 00000011000 ", + "000011001010 000011001011 000011001100 000011001101 000001101000 000001101001 000001101010 000001101011 000011010010 ", + "000011010011 000011010100 000011010101 000011010110 000011010111 000001101100 000001101101 000011011010 000011011011 ", + "000001010100 000001010101 000001010110 000001010111 000001100100 000001100101 000001010010 000001010011 000000100100 ", + "000000110111 000000111000 000000100111 000000101000 000001011000 000001011001 000000101011 000000101100 000001011010 ", + "000001100110 000001100111", + ); + let code = |white: bool, run: u32| (if white { WHITE } else { BLACK }).split(' ').nth(run as usize).expect("a run below 64"); + let mut bits = String::new(); + for row in rows { + // The end-of-line code is 12 bits. + let ends_code = if fill == Fill::CodeEnd && eol { 12 } else { 0 }; + while fill != Fill::None && !(bits.len() + ends_code).is_multiple_of(8) { + bits.push('0'); + } + if eol { + bits.push_str("000000000001"); + } + if tag { + bits.push('1'); + } + row.as_ref().iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() +} + +/// `count` text-like pictures `w` by `h` for [`group_3`], from a fixed seed: rows of random runs, +/// long white ones and short black ones, and a row may start black. +fn random_pictures(w: u32, h: u32, count: usize) -> Vec>> { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut random = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n) as u32 + }; + let mut row = || { + let (mut runs, mut left) = (Vec::new(), w); + while left > 0 { + let run = match runs.len() % 2 { + 0 if runs.is_empty() => random(31), + 0 => 1 + random(30), + _ => 1 + random(5), + }; + runs.push(run.min(left)); + left -= run.min(left); + } + runs + }; + (0..count).map(|_| (0..h).map(|_| row()).collect()).collect() +} + +/// The picture `rows` of runs (white first) make, `w` by `h`, true where white. +fn white_of(rows: &[Vec], w: u32, h: u32) -> Vec { + let mut white = vec![false; (w * h) as usize]; + for (y, row) in rows.iter().enumerate() { + let mut x = y * w as usize; + for (i, run) in row.iter().enumerate() { + white[x..x + *run as usize].fill(i % 2 == 0); + x += *run as usize; + } + } + white +} + +/// Where [`group_3`] puts fill bits: none; so each row, its end-of-line code first, starts on a +/// byte; or so each end-of-line code ends on one (T.4's fill, as TIFF writes it). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Fill { + None, + RowStart, + CodeEnd, +} + +/// What Export ▸ Images claims for CCITT and for parameters given by reference, each checked +/// against the same picture stored another way: Group 3 one-dimensional data (`/K 0`) exports as +/// its picture; a CCITT soft mask and a CCITT stencil `/Mask` export as the same masks in Flate; +/// and a Flate image's PNG predictor, and an indexed image's lookup table stream with one, read +/// the same with their `/DecodeParms` given by reference as direct. +#[test] +fn group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture() { + use png::{BitDepth, ColorType}; + let (w, h) = (64u32, 16u32); + let rows: Vec<&[u32]> = (0..h).map(|y| if y % 2 == 0 { &[32u32, 32][..] } else { &[16, 16, 32][..] }).collect(); + let mut bits = vec![0u8; 8 * h as usize]; + for (y, runs) in rows.iter().enumerate() { + let mut x = 0; + for (i, run) in runs.iter().enumerate() { + if i % 2 == 0 { + (x..x + *run as usize).for_each(|xx| bits[y * 8 + xx / 8] |= 0x80 >> (xx % 8)); + } + x += *run as usize; + } + } + let picture: Vec = (0..(w * h) as usize).map(|i| bits[i / 8] & (0x80 >> (i % 8)) != 0).collect(); + let mut doc = from_images(&[("scan.png".into(), png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None))]).unwrap(); + let r = { + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + xo.get(b"Im0").and_then(Object::as_ref).unwrap() + }; + let image = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d + }; + let fax = |k: i64| { + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(k)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + parms.set(b"Rows".to_vec(), Object::Int(h.into())); + [("Filter", Object::name("CCITTFaxDecode")), ("DecodeParms", Object::Dict(parms))] + }; + let grey = |bits: i64| [("BitsPerComponent", Object::Int(bits)), ("ColorSpace", Object::name("DeviceGray"))]; + + // One-dimensional (/K 0) plain, with end-of-line codes, with each row on a byte, and with + // both; mixed (/K 1, each row tagged one-dimensional) with end-of-line codes and without. + // Byte-aligned with end-of-line codes either way: each code starting on a byte, or ending on + // one (T.4's fill, as TIFF writes it). /EncodedByteAlign is given by reference once, and so is + // a /BlackIs1 that inverts the picture. + let inverted: Vec = picture.iter().map(|white| !white).collect(); + for (k, eol, fill, align_by_reference, black_is_1) in [ + (0, false, Fill::None, false, false), + (0, true, Fill::None, false, false), + (0, false, Fill::RowStart, true, false), + (0, true, Fill::RowStart, false, false), + (0, true, Fill::CodeEnd, false, false), + (1, true, Fill::None, false, false), + (1, false, Fill::None, false, false), + (1, true, Fill::CodeEnd, false, false), + (0, false, Fill::None, false, true), + ] { + let mut d = image(&[&grey(1)[..], &fax(k)].concat()); + let align = Object::Bool(fill != Fill::None); + let align = if align_by_reference { Object::Ref(doc.add(align)) } else { align }; + let inverts = Object::Ref(doc.add(Object::Bool(black_is_1))); + if let Some(Object::Dict(parms)) = d.get_mut(b"DecodeParms") { + parms.set(b"EndOfLine".to_vec(), Object::Bool(eol)); + parms.set(b"EncodedByteAlign".to_vec(), align); + parms.set(b"BlackIs1".to_vec(), inverts); + } + doc.set(r, Object::Stream(Stream::from_raw(d, group_3(&rows, eol, k > 0, fill)))); + let case = format!("/K {k}, EndOfLine {eol}, fill {fill:?}, BlackIs1 {black_is_1}"); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{case}: {:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), if black_is_1 { inverted.clone() } else { picture.clone() }, "{case}"); + } + // Byte-aligned rows against /EndOfLine, over several pictures, as how a misreading comes out + // depends on the picture: no codes (as TIFF's Modified Huffman has it), and /EndOfLine without + // them; codes, each starting on a byte or ending on one, /EndOfLine or not. Each exports as its + // picture, and a byte short is reported. The first picture is stripes, every row 32 white then + // 32 black; the others are text-like rows of random runs. + let image_with = |doc: &mut Document, k: i64, eol: bool, data: Vec| { + let mut d = image(&[&grey(1)[..], &fax(k)].concat()); + if let Some(Object::Dict(parms)) = d.get_mut(b"DecodeParms") { + parms.set(b"EndOfLine".to_vec(), Object::Bool(eol)); + parms.set(b"EncodedByteAlign".to_vec(), Object::Bool(true)); + } + doc.set(r, Object::Stream(Stream::from_raw(d, data))); + extract_images(doc, &[0], 0) + }; + let mut pictures = vec![vec![vec![32u32, 32]; h as usize]]; + pictures.extend(random_pictures(w, h, 8)); + for (n, runs) in pictures.iter().enumerate() { + let white = white_of(runs, w, h); + for k in [0, 1] { + for (codes, fill, eol) in [ + (false, Fill::RowStart, false), + (false, Fill::RowStart, true), + (true, Fill::RowStart, false), + (true, Fill::RowStart, true), + (true, Fill::CodeEnd, false), + (true, Fill::CodeEnd, true), + ] { + let out = image_with(&mut doc, k, eol, group_3(runs, codes, k > 0, fill)); + let case = format!("picture {n}, /K {k}, codes {codes}, fill {fill:?}, /EndOfLine {eol}"); + assert!(out.skipped.is_empty(), "{case}: {:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), white, "{case}"); + // A byte short, it is reported, not read the other way into other rows. + let mut short = group_3(runs, codes, k > 0, fill); + short.pop(); + let out = image_with(&mut doc, k, eol, short); + assert!(out.images.is_empty() && out.skipped.len() == 1, "{case}, a byte short: {:?}", out.skipped); + // At /K 1, codes with none before the first row (the first code and its fill are 2 + // bytes here) are read on bytes: the picture or reported, never another picture. + if k == 1 && fill == Fill::CodeEnd { + let out = image_with(&mut doc, k, eol, group_3(runs, codes, true, fill).split_off(2)); + assert!(out.images.iter().all(|i| exported_white(&i.data) == white), "{case}, no code first"); + } + } + } + } + // Group 4 rows on bytes keep their aligned read, /EndOfLine or not: here white rows, each the + // one code 1 (vertical, no change) and its fill. + for eol in [false, true] { + let out = image_with(&mut doc, -1, eol, vec![0x80; h as usize]); + assert!(out.skipped.is_empty(), "byte-aligned Group 4, /EndOfLine {eol}: {:?}", out.skipped); + assert!(exported_white(&out.images[0].data).iter().all(|white| *white), "byte-aligned Group 4, /EndOfLine {eol}"); + } + + // A grey picture under the same bits as a soft mask, then as a stencil mask: CCITT against Flate. + let g4 = pdfcraft_filters::encode_g4(&bits, w, h).unwrap(); + for (key, mask) in [("SMask", grey(1).to_vec()), ("Mask", vec![("ImageMask", Object::Bool(true))])] { + let mut exported = Vec::new(); + for coded in [true, false] { + let m = if coded { Stream::from_raw(image(&[&mask[..], &fax(-1)].concat()), g4.clone()) } else { Stream::flate(image(&mask), &bits) }; + let m = doc.add(Object::Stream(m)); + let d = image(&[&grey(8)[..], &[(key, Object::Ref(m))]].concat()); + doc.set(r, Object::Stream(Stream::flate(d, &vec![100u8; (w * h) as usize]))); + exported.push(exported_rgba(&doc)); + } + assert!(exported[0].1.chunks(4).any(|p| p[3] == 0), "{key}: masked somewhere"); + assert_eq!(exported[0], exported[1], "{key}: CCITT as Flate"); + } + + // A PNG predictor given by reference, for the samples and for a lookup table. + let predicted = |rows: &[u8], columns: i64| { + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(columns)); + (pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, rows).unwrap(), parms) + }; + let shades: Vec = (0..w * h).map(|i| (i * 7 % 256) as u8).collect(); + let (table, table_parms) = predicted(&[255, 0, 0, 0, 0, 255], 3); + let indices: Vec = (0..w * h).map(|i| (i % 3 % 2) as u8).collect(); + let mut exported = Vec::new(); + for by_reference in [false, true] { + let parms = |doc: &mut Document, p: &Dict| if by_reference { Object::Ref(doc.add(Object::Dict(p.clone()))) } else { Object::Dict(p.clone()) }; + let (samples, sample_parms) = predicted(&shades, w.into()); + let sample_parms = parms(&mut doc, &sample_parms); + let d = image(&[&grey(8)[..], &[("Filter", Object::name("FlateDecode")), ("DecodeParms", sample_parms)]].concat()); + doc.set(r, Object::Stream(Stream::from_raw(d, samples))); + let plain = exported_rgba(&doc); + + let mut lookup = Dict::new(); + lookup.set(b"Filter".to_vec(), Object::name("FlateDecode")); + lookup.set(b"DecodeParms".to_vec(), parms(&mut doc, &table_parms)); + let lookup = doc.add(Object::Stream(Stream::from_raw(lookup, table.clone()))); + let cs = Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), 1.into(), Object::Ref(lookup)]); + let d = image(&[("BitsPerComponent", Object::Int(8)), ("ColorSpace", cs)]); + doc.set(r, Object::Stream(Stream::flate(d, &indices))); + exported.push((plain, exported_rgba(&doc))); + } + assert_eq!(exported[0].0.1.chunks(4).map(|p| p[0]).collect::>(), shades, "the samples, direct"); + assert_eq!(exported[0].1.1.get(..8), Some(&[255, 0, 0, 255, 0, 0, 255, 255][..]), "red, then blue"); + assert_eq!(exported[1], exported[0], "by reference as direct"); +} + +/// An image whose PNG predictor is given by reference exports as its picture: its `/DecodeParms` +/// dictionary given by reference, as an array's entry, or direct with its numbers by reference. So +/// do an indexed image whose lookup table is a stream with its `/DecodeParms` given by reference, +/// and a JBIG2 image whose `/JBIG2Globals` stream has them so. `Stream::decoded` reads only a +/// direct `/DecodeParms` with direct numbers: the row tags stayed in the samples, and the picture +/// came out shifted. +#[test] +fn images_whose_predictor_is_given_by_reference_export_as_their_picture() { + // PNG predictor parameters for `columns` one-byte samples a row. + let parms = |columns: i64| { + let mut p = Dict::new(); + p.set(b"Predictor".to_vec(), Object::Int(15)); + p.set(b"Colors".to_vec(), Object::Int(1)); + p.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + p.set(b"Columns".to_vec(), Object::Int(columns)); + p + }; + // Two rows of four grey samples, each behind a PNG "None" row tag. + let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]); + let grey: Vec = [10u8, 20, 30, 40, 50, 60, 70, 80].iter().flat_map(|v| [*v, *v, *v, 255]).collect(); + for form in ["a reference", "an array of a reference", "numbers by reference"] { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(4, 2, "DeviceGray", 8); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let decode_parms = match form { + "numbers by reference" => { + let mut p = parms(4); + for key in [&b"Predictor"[..], b"Columns"] { + let v = p.get(key).cloned().unwrap(); + p.set(key.to_vec(), Object::Ref(doc.add(v))); + } + Object::Dict(p) + } + "a reference" => Object::Ref(doc.add(Object::Dict(parms(4)))), + _ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms(4))))]), + }; + d.set(b"DecodeParms".to_vec(), decode_parms); + Stream::from_raw(d, predicted.clone()) + }); + assert_eq!(exported_rgba(&doc), ("png", grey.clone()), "/DecodeParms as {form}"); + } + // An indexed image's lookup table, red then blue, as a stream behind a "None" row tag. + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut t = Dict::new(); + t.set(b"Filter".to_vec(), Object::name("FlateDecode")); + t.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(6))))); + let table = doc.add(Object::Stream(Stream::from_raw(t, pdfcraft_filters::encode_flate(&[0, 255, 0, 0, 0, 0, 255])))); + let mut d = image_dict(4, 1, "DeviceGray", 8); + d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::Ref(table)])); + Stream::from_raw(d, vec![0, 1, 0, 1]) + }); + let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 255]); + assert_eq!(exported_rgba(&doc), ("png", [red, blue, red, blue].concat()), "the lookup table's /DecodeParms by reference"); + // A JBIG2 image whose globals hold one extension segment (ISO/IEC 14492 7.4.14) a decoder + // ignores, behind a "None" row tag. With the tag left in, the globals can't be read and the + // image is skipped. + let segment = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut g = Dict::new(); + g.set(b"Filter".to_vec(), Object::name("FlateDecode")); + g.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(segment.len() as i64))))); + let tagged: Vec = [0].iter().chain(&segment).copied().collect(); + let globals = doc.add(Object::Stream(Stream::from_raw(g, pdfcraft_filters::encode_flate(&tagged)))); + let mut decode_parms = Dict::new(); + decode_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + let mut d = image_dict(16, 2, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms)); + Stream::from_raw(d, pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap()) + }); + let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] }).collect(); + assert_eq!(exported_rgba(&doc), ("png", picture), "the JBIG2 globals' /DecodeParms by reference"); +} + +/// An image the optimizer stored as JBIG2 symbols (a symbol dictionary and text regions, rather +/// than one generic region) exports as its picture. +#[test] +fn symbol_coded_jbig2_images_export_as_their_picture() { + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400usize, 40usize); + let stride = w.div_ceil(8); + // 1 samples white, as a grey image's are; the letters' pixels 0. + let mut bits = vec![0xFFu8; stride * h]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, c) in row.bytes().enumerate() { + if c == b'#' { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + } + let jbig2 = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); + assert!(jbig2.len() < generic.len(), "symbol coded: {} bytes, generic {}", jbig2.len(), generic.len()); + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |_| { + let mut d = image_dict(w as i64, h as i64, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + Stream::from_raw(d, jbig2) + }); + let picture: Vec = (0..w * h) + .flat_map(|i| if bits[i / w * stride + i % w / 8] & (0x80 >> (i % w % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] }) + .collect(); + assert_eq!(exported_rgba(&doc), ("png", picture)); +} + +/// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as +/// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in +/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the +/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image, +/// or larger than an exported image may be (however small the image's `/Height`), or data that +/// isn't JBIG2, skips the image. +#[test] +fn jbig2_images_export_as_their_picture() { + let (w, h) = (203i64, 41i64); + let stride = 26; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let bits: Vec = (0..stride * h as usize) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + // The first `rows` rows as RGBA: a 1 sample white, a 0 black. + let picture = |rows: i64| -> Vec { + (0..rows as usize) + .flat_map(|y| (0..w as usize).map(move |x| (x, y))) + .flat_map(|(x, y)| { + let v = if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 }; + [v, v, v, 255] + }) + .collect() + }; + let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); + // A page whose image is `data` as JBIG2, `width` × `height`. With `globals`, its /DecodeParms + // (an array when `.1`) name a globals stream; with `mask`, it is the stencil /Mask of a grey + // image. + let page = |width: i64, height: i64, data: &[u8], globals: Option<(&[u8], bool)>, mask: bool| { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(width, height, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + if let Some((g, in_array)) = globals { + let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), g.to_vec()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g)); + d.set(b"DecodeParms".to_vec(), if in_array { Object::Array(vec![Object::Dict(parms)]) } else { Object::Dict(parms) }); + } + if !mask { + return Stream::from_raw(d, data.to_vec()); + } + d.remove(b"ColorSpace"); + d.set(b"ImageMask".to_vec(), Object::Bool(true)); + let m = doc.add(Object::Stream(Stream::from_raw(d, data.to_vec()))); + let mut image = image_dict(width, height, "DeviceGray", 8); + image.set(b"Mask".to_vec(), Object::Ref(m)); + Stream::from_raw(image, vec![128; (width * height) as usize]) + }); + doc + }; + let skipped = |doc: &Document| { + let out = extract_images(doc, &[0], 0); + out.images.is_empty() && out.skipped.len() == 1 + }; + + assert_eq!(exported_rgba(&page(w, h, &jbig2, None, false)), ("png", picture(h))); + assert_eq!(exported_rgba(&page(w, h, &jbig2, Some((&[][..], false)), false)).1, picture(h), "empty globals"); + for in_array in [false, true] { + assert!(skipped(&page(w, h, &jbig2, Some((&b"not JBIG2"[..], in_array)), false)), "unreadable globals, in an array: {in_array}"); + } + assert_eq!(exported_rgba(&page(w, h - 5, &jbig2, None, false)).1, picture(h - 5), "rows past the height"); + for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] { + assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len()); + } + // The same stream with its page information (the first segment's data, after its 11-byte + // header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the + // export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page + // (it takes pages of up to about 2^28 pixels), so it is refused first. + let mut large = jbig2.clone(); + large[11..15].copy_from_slice(&20_000u32.to_be_bytes()); + large[15..19].copy_from_slice(&10_000u32.to_be_bytes()); + assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size"); + // Refused the same ways as a stencil /Mask, it is left out: the image exports opaque. + for (width, height, mask) in [(w + 8, h, &jbig2), (20_000, 4, &large)] { + let rgba = exported_rgba(&page(width, height, mask, None, true)).1; + assert_eq!(rgba.len() as i64, width * height * 4, "a {width}-wide mask"); + assert!(alphas(&rgba).iter().all(|a| *a == 255), "a refused {width}-wide mask is left out"); + } + // A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused. + for (rows, refused) in [(5_000u32, false), (5_001, true)] { + large[15..19].copy_from_slice(&rows.to_be_bytes()); + let doc = page(20_000, 4, &large, None, false); + assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}"); + if !refused { + assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4); + } + } + // Damaged data never panics. + for end in [0, 1, 10, 20, 30, jbig2.len() / 2] { + let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0); + } + + // As a stencil /Mask, its 1 samples (JBIG2 white) hide the image. + let alpha: Vec = picture(h).as_chunks::<4>().0.iter().map(|p| 255 - p[0]).collect(); + assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha); +} + +/// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have +/// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to +/// one byte more skip the image, with the limit in the reason, though they take a few kilobytes +/// in the file. Globals of exactly the limit are read, and the limit is the same through an +/// indirect `/DecodeParms`, in an array or not. +#[test] +fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { + const LIMIT: usize = 32 << 20; + let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; + let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); + let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect(); + // Globals of `len` bytes, Flate-encoded: one extension segment (ISO/IEC 14492 7.4.14), which a + // decoder skips; its 11-byte header gives the length of the data after it. + let globals = |len: usize| { + let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0]; + g.extend_from_slice(&u32::try_from(len - 11).unwrap().to_be_bytes()); + g.extend_from_slice(&[0, 0, 0, 1]); + g.resize(len, 0); + pdfcraft_filters::encode_flate(&g) + }; + // The image, its /DecodeParms naming `flated` as its globals: direct (0), by reference (1), or + // by reference in an array (2). + let page = |flated: &[u8], form: usize| { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut g = Dict::new(); + g.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let g = doc.add(Object::Stream(Stream::from_raw(g, flated.to_vec()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g)); + let parms = match form { + 0 => Object::Dict(parms), + 1 => Object::Ref(doc.add(Object::Dict(parms))), + _ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms)))]), + }; + let mut d = image_dict(16, 2, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + d.set(b"DecodeParms".to_vec(), parms); + Stream::from_raw(d, jbig2.clone()) + }); + doc + }; + + let (small, full, over) = (globals(15), globals(LIMIT), globals(LIMIT + 1)); + assert!(over.len() < 64 << 10, "the oversized globals take {} bytes in the file", over.len()); + assert_eq!(exported_rgba(&page(&full, 0)).1, picture, "globals of exactly the limit"); + for form in 0..3 { + assert_eq!(exported_rgba(&page(&small, form)).1, picture, "small globals, form {form}"); + let out = extract_images(&page(&over, form), &[0], 0); + assert!(out.images.is_empty(), "globals over the limit, form {form}"); + let why = &out.skipped.first().expect("the image is skipped").2; + assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); + } +} diff --git a/crates/create/src/upright_image.rs b/crates/create/src/upright_image.rs index 9816dfc4b2..b89c517190 100644 --- a/crates/create/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -14,6 +14,12 @@ const QUALITY: u8 = 92; /// Larger photos are embedded as they are: turning them would take hundreds of MB. const MAX_PIXELS: u64 = 64 << 20; +#[cfg(test)] +thread_local! { + /// How many JPEGs [`turned`] handed to the decoder on this thread. + static DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded); /// everything else, and any JPEG this can't read, is returned unchanged. pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> { @@ -24,9 +30,11 @@ pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> { } fn turned(bytes: &[u8]) -> Option> { - if !bytes.starts_with(&[0xFF, 0xD8]) { + if !bytes.starts_with(&[0xFF, 0xD8]) || extended_xmp_chunks(bytes) > MAX_EXTENDED_XMP_CHUNKS { return None; } + #[cfg(test)] + DECODES.with(|c| c.set(c.get().saturating_add(1))); let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?; let orientation = decoder.orientation().ok()?; let (w, h) = decoder.dimensions(); @@ -53,6 +61,41 @@ fn turned(bytes: &[u8]) -> Option> { Some(out) } +/// Most Extended XMP chunks a JPEG handed to the decoder may carry (64 MB of metadata). The +/// decoder re-sorts and rescans every chunk so far after each marker, quadratic in their number. +const MAX_EXTENDED_XMP_CHUNKS: usize = 1024; + +/// How many Extended XMP chunks (APP1 segments) come before the image data. +fn extended_xmp_chunks(bytes: &[u8]) -> usize { + const PREFIX: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; + let (mut i, mut chunks) = (2usize, 0usize); + while let Some(&byte) = bytes.get(i) { + // Stray bytes between segments, which the decoder skips too. + if byte != 0xFF { + i += 1; + continue; + } + let Some(&marker) = bytes.get(i.saturating_add(1)) else { break }; + match marker { + // Fill bytes before a marker. + 0xFF => i += 1, + // Markers without a segment. + 0x00 | 0x01 | 0xD0..=0xD7 => i += 2, + // The image data starts (or ends): no headers after it. + 0xDA | 0xD9 => break, + _ => { + let Some(&[hi, lo]) = bytes.get(i.saturating_add(2)..i.saturating_add(4)) else { break }; + let len = usize::from(u16::from_be_bytes([hi, lo])); + if marker == 0xE1 && bytes.get(i.saturating_add(4)..).is_some_and(|seg| seg.starts_with(PREFIX)) { + chunks += 1; + } + i = i.saturating_add(2).saturating_add(len.max(2)); + } + } + } + chunks +} + /// The resolution in a JFIF header, if the file has one in inches or centimetres. fn jfif_density(bytes: &[u8]) -> Option { let mut i = 2usize; @@ -94,4 +137,26 @@ mod tests { assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_))); assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); } + + #[test] + fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_not_decoded() { + // Empty chunks of one never-complete extended packet: the decoder re-sorts and rescans every + // chunk so far after each marker, quadratic in their number. + let chunk = |n: usize| { + let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec(); + seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID + seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length + seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset + let mut out = vec![0xFF, 0xE1]; + out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes()); + out.extend_from_slice(&seg); + out.repeat(n) + }; + let photo = phone_jpeg(6, 144); + let with = |n: usize| [&photo[..2], b"\0".as_slice(), &chunk(n), &photo[2..]].concat(); + DECODES.with(|c| c.set(0)); + assert!(matches!(upright(&with(4)), Cow::Owned(_)), "a few chunks are an ordinary photo"); + assert!(matches!(upright(&with(5_000)), Cow::Borrowed(_))); + assert_eq!(DECODES.with(std::cell::Cell::get), 1, "only the ordinary photo reached the decoder"); + } } diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 82b598688a..ff92d9af66 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -138,9 +138,51 @@ pub struct SecurityHandler { key: Vec, auth: Auth, recovered_user: Option>, + methods: Methods, +} + +/// The crypt filter methods of a handler, resolved once: a document controls both the number of +/// filters and the number of strings, so a search of `/CF` per string would multiply them. +#[derive(Clone, Debug)] +struct Methods { + string: Method, + stream: Method, + embedded_file: Method, + named: std::collections::HashMap, Method>, +} + +impl Methods { + fn new(dict: &EncryptDict) -> Self { + let mut named = std::collections::HashMap::with_capacity(dict.crypt_filters.len()); + for (name, method) in &dict.crypt_filters { + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + // The first of two filters with one name wins, as a search would find it. + named.entry(name.clone()).or_insert(*method); + } + let mut methods = Methods { string: Method::Identity, stream: Method::Identity, embedded_file: Method::Identity, named }; + methods.string = methods.get(&dict.str_f); + methods.stream = methods.get(&dict.stm_f); + methods.embedded_file = methods.get(if dict.ef_f.is_empty() { &dict.stm_f } else { &dict.ef_f }); + methods + } + + fn get(&self, name: &[u8]) -> Method { + if name == b"Identity" || name.is_empty() { + return Method::Identity; + } + #[cfg(test)] + tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1)); + self.named.get(name).copied().unwrap_or(Method::Identity) + } } impl SecurityHandler { + fn new(dict: EncryptDict, key: Vec, auth: Auth, recovered_user: Option>) -> Self { + let methods = Methods::new(&dict); + Self { dict, key, auth, recovered_user, methods } + } + /// Authenticate with `password` (tried as owner password, then user password). `None` /// tries the empty password, which opens documents that only have an owner password. pub fn open(dict: EncryptDict, id0: &[u8], password: Option<&str>) -> Result { @@ -177,7 +219,7 @@ impl SecurityHandler { } r => return Err(CryptError::Unsupported(format!("revision {r}"))), }; - Ok(Self { dict, key, auth, recovered_user }) + Ok(Self::new(dict, key, auth, recovered_user)) } /// Upgrade to owner access with the owner (permissions) password, as when a restricted @@ -235,26 +277,16 @@ impl SecurityHandler { self.dict.encrypt_metadata } - fn method_named(&self, name: &[u8]) -> Method { - if name == b"Identity" || name.is_empty() { - return Method::Identity; - } - self.dict.crypt_filters.iter().find(|(n, _)| n == name).map(|(_, m)| *m).unwrap_or(Method::Identity) - } - /// The method for strings or a kind of stream. fn method(&self, stream: Option) -> Method { if self.dict.v < 4 { return Method::Rc4; } match stream { - None => self.method_named(&self.dict.str_f), - Some(StreamKind::Normal) => self.method_named(&self.dict.stm_f), - Some(StreamKind::EmbeddedFile) => { - let eff = if self.dict.ef_f.is_empty() { &self.dict.stm_f } else { &self.dict.ef_f }; - self.method_named(eff) - } - Some(StreamKind::Named(n)) => self.method_named(n), + None => self.methods.string, + Some(StreamKind::Normal) => self.methods.stream, + Some(StreamKind::EmbeddedFile) => self.methods.embedded_file, + Some(StreamKind::Named(n)) => self.methods.get(n), } } @@ -602,12 +634,20 @@ fn expand(seed: &[u8; 32], label: &[u8], n: usize) -> Vec { h.update(label); h.update(counter.to_le_bytes()); out.extend_from_slice(&h.finalize()); - counter += 1; + counter = counter.saturating_add(1); } out.truncate(n); out } +/// Sixteen bytes of file identifier from `seed` (SHA-256 in counter mode, label `id`). +/// +/// `/ID` is written in the clear. Deriving it the same way as the file key keeps that public +/// value from being an unkeyed hash of the seed. +pub fn file_id(seed: &[u8; 32]) -> Vec { + expand(seed, b"id", 16) +} + /// Build the `/Encrypt` values and an authenticated (owner) handler for a new encryption. pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { // Bits 7–8 and 13–32 must be 1; bits 1–2 must be 0 (§7.6.4.2). @@ -642,7 +682,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { (d.v, d.r, d.length_bits) = (5, 6, 256); @@ -671,7 +711,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result Vec { (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() } +#[test] +fn file_id_is_a_one_way_16_byte_identifier() { + let seed = [7u8; 32]; + let id = file_id(&seed); + assert_eq!(id.len(), 16); + assert_eq!(id, file_id(&seed)); + assert_ne!(id, seed[..16]); + assert_ne!(id, file_id(&[8u8; 32])); +} + #[test] fn rc4_reference_vectors() { assert_eq!(rc4(b"Key", b"Plaintext"), hex("bbf316e8d940af0ad3")); @@ -121,6 +131,32 @@ fn strings_and_streams_round_trip_and_are_scrambled() { } } +thread_local! { + /// Crypt filter names compared or looked up, on this thread. + pub(crate) static FILTER_COMPARES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[test] +fn crypt_filters_are_looked_up_once_per_handler() { + let plain = new(Algorithm::Aes128, "", "o", -1); + let mut d = plain.dict().clone(); + let n = 5_000; + let mut filters: Vec<_> = (0..n).map(|i| (format!("F{i}").into_bytes(), Method::Identity)).collect(); + filters.append(&mut d.crypt_filters); + d.crypt_filters = filters; + FILTER_COMPARES.with(|c| c.set(0)); + let h = SecurityHandler::open(d, ID, None).unwrap(); + let strings = 1_000; + let enc = plain.encrypt_string(7, 0, b"secret"); + for _ in 0..strings { + assert_eq!(h.decrypt_string(7, 0, &enc), b"secret"); + assert_eq!(h.decrypt_stream(7, 0, &plain.encrypt_stream(7, 0, b"s", StreamKind::Normal), StreamKind::Normal), b"s"); + } + assert_eq!(h.decrypt_stream(7, 0, b"x", StreamKind::Named(b"F3")), b"x"); + let compares = FILTER_COMPARES.with(|c| c.get()); + assert!(compares <= 2 * n + 3 * strings, "{compares} filter name comparisons"); +} + #[test] fn identity_crypt_filters_leave_data_alone() { let mut d = new(Algorithm::Aes256, "", "o", -1).dict().clone(); diff --git a/crates/crypt/tests/it/main.rs b/crates/crypt/tests/it/main.rs new file mode 100644 index 0000000000..8b39bf1956 --- /dev/null +++ b/crates/crypt/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, linked into one test binary. + +mod proptests; +mod qpdf_literals; diff --git a/crates/crypt/tests/proptests.rs b/crates/crypt/tests/it/proptests.rs similarity index 100% rename from crates/crypt/tests/proptests.rs rename to crates/crypt/tests/it/proptests.rs diff --git a/crates/crypt/tests/it/qpdf_literals.rs b/crates/crypt/tests/it/qpdf_literals.rs new file mode 100644 index 0000000000..f34464d980 --- /dev/null +++ b/crates/crypt/tests/it/qpdf_literals.rs @@ -0,0 +1,326 @@ +//! Encryption numbers from an **outside implementation**. Six of the seven rows below are qpdf's own, +//! written by qpdf core 12.2.0 through pikepdf 9.11, and their `/O`, `/U`, `/OE`, `/UE`, `/Perms` +//! and `/ID` values were copied out of those files rather than computed here. The seventh row, +//! `crafted-r3-40bit-hand-derived`, is ours and says so where it is defined - no producer we can run +//! here writes revision 3 at 40 bits (qpdf core 12.2.0 emits `/Length 128` at `R=3`, measured below), +//! so nothing outside computed that one either. +//! +//! That distinction is the whole point. `create()` and `open()` share one derivation, so a wrong-but +//! self-consistent implementation round-trips cleanly against itself: across two review rounds we +//! broke the key derivation several ways and the unit suite stayed green (deleting Algorithm 2 step f +//! leaves all 17 unit tests passing while our rows fail). Quoting an outsider's bytes +//! breaks the circle - `open()` recomputes `/U` (or the revision 6 hash chain) and compares it to what +//! was stored, so a derivation that diverges cannot authenticate at all. +//! +//! Revisions covered: 2 (export-grade RC4, where the five-byte key makes a truncation error visible that +//! every pre-existing R3/R4 vector in this repository hides as a no-op), 3, and 4 and 6 each with metadata +//! encrypted *and* plain. For revision 4 that pairing is the only outside view of Algorithm 2 step +//! f's four `FF` bytes; revision 6 shows the same flag differently - its `/U` differs between the two +//! metadata variants, which is measured here rather than attributed to a named algorithm step. Only the +//! numbers are committed - never the PDFs, and no generator script that lives outside +//! this repository. To re-derive the six qpdf rows, take one blank 200x200 page and save it with +//! pikepdf 9.11 (qpdf core 12.2.0) using `Encryption(owner=OWNER, user=USER,` +//! `allow=Permissions(extract=False), R=, aes=…, metadata=…)` plus `save(..., static_id=True)`, +//! then read `/Encrypt` back. Below revision 4 both flags are forced rather than chosen, because pikepdf +//! refuses AES and refuses encrypted metadata there - so those two rows need `aes=False, metadata=False`, +//! while revisions 4 and 6 take `aes=True` with the case's own metadata flag. Run to run, every quoted +//! field of those rows reproduces at revisions 2-4, and whole files too for revisions 2 and 3; a +//! revision 4 file falls short of that by one stream, because `/CFM /AESV2` prepends a fresh random IV. +//! Revision 6 reproduces structurally only, its salts being new each run. What stays checked rather than +//! trusted - `open()` recovers the user password from `/O` and verifies `/U` at revisions 2-4 +//! (Algorithm 7), matches both stored hashes at revision 6 and unwraps `/OE`/`/UE` into the file key +//! there; this file then decrypts `/Perms` with that key, which authentication itself never reads. One +//! thing no assertion here verifies is the crypt-filter names: renaming a row's `/StmF` or replacing its +//! filter set leaves all three tests green, so those are held to review rather than to a test. For how +//! this repository treats qpdf as an oracle, see `crates/oracle/README.md`. + +use pdfcraft_crypt::{Auth, EncryptDict, Method, SecurityHandler, aes_cbc_decrypt}; + +const USER: &str = "open pw"; +const OWNER: &str = "owner pw"; + +/// One file qpdf wrote. Bytes are held as hex so the table stays a `const`: the literals on screen are +/// then exactly what qpdf emitted, with no runtime step in between to trust. +struct Case { + label: &'static str, + v: i64, + r: i64, + length_bits: i64, + p: i32, + encrypt_metadata: bool, + id0_hex: &'static str, + o_hex: &'static str, + u_hex: &'static str, + oe_hex: &'static str, + ue_hex: &'static str, + perms_hex: &'static str, + crypt_filters: fn() -> Vec<(Vec, Method)>, + stm_f: &'static str, + str_f: &'static str, + key_len_bytes: usize, +} + +impl Case { + fn dict(&self) -> EncryptDict { + EncryptDict { + filter: b"Standard".to_vec(), + v: self.v, + r: self.r, + length_bits: self.length_bits, + o: hex(self.o_hex), + u: hex(self.u_hex), + oe: hex(self.oe_hex), + ue: hex(self.ue_hex), + perms: hex(self.perms_hex), + p: self.p, + encrypt_metadata: self.encrypt_metadata, + crypt_filters: (self.crypt_filters)(), + stm_f: self.stm_f.as_bytes().to_vec(), + str_f: self.str_f.as_bytes().to_vec(), + ef_f: Vec::new(), + } + } + + fn id0(&self) -> Vec { + hex(self.id0_hex) + } +} + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +fn no_filters() -> Vec<(Vec, Method)> { + Vec::new() +} + +fn aes128_filter() -> Vec<(Vec, Method)> { + vec![(b"StdCF".to_vec(), Method::Aes128)] +} + +fn aes256_filter() -> Vec<(Vec, Method)> { + vec![(b"StdCF".to_vec(), Method::Aes256)] +} + +/// `/ID[0]` is shared by every case because qpdf wrote them with `static_id`. +const CASES: &[Case] = &[ + Case { + label: "r2-40bit", + v: 1, + r: 2, + length_bits: 40, + p: -28, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "166a894580aae60ae93fa9d83fc576f26f13dbe4574ce6c57497bec3ece7f125", + u_hex: "69ff2fb98261fd094174f93f0388859f356080ca486a38ce2d5ac23b98f5f89c", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 5, + }, + Case { + label: "r3-128rc4", + v: 2, + r: 3, + length_bits: 128, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 16, + }, + Case { + label: "r4-aes128-meta-enc", + v: 4, + r: 4, + length_bits: 128, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: aes128_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 16, + }, + Case { + label: "r4-aes128-meta-plain", + v: 4, + r: 4, + length_bits: 128, + p: -1044, + encrypt_metadata: false, + id0_hex: "31415926535897932384626433832795", + o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848", + u_hex: "d6d363fc817815701f0242a581cb39e60122456a91bae5134273a6db134c87c4", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: aes128_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 16, + }, + Case { + label: "r6-aes256-meta-enc", + v: 5, + r: 6, + length_bits: 256, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "1c791d66227fdfd7c1d225eb81fb3408d8bcfac1a0ff71cc57b10eb29029e5f6965cccb6b94e461d9da354a9dbd6eea7", + u_hex: "64ffbf79168ed7b325267cdba8dae211d4c2c5ebdffd339c7ac36c33e26f23b7f478c60ae42d50b5d20ceac56d659151", + oe_hex: "d2f6ef0bcc6ce93228be760c7eee8f4259af7a6044a4c4e04a0d48ab0893ef1e", + ue_hex: "963035aa24fb009e6d26b3cffd31492ffe6f4e563e4dcae8d7b7e3203c4a2f56", + perms_hex: "5675a86560873d2532e856a37045247e", + crypt_filters: aes256_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 32, + }, + Case { + label: "r6-aes256-meta-plain", + v: 5, + r: 6, + length_bits: 256, + p: -1044, + encrypt_metadata: false, + id0_hex: "31415926535897932384626433832795", + o_hex: "e227e586c4dc5a362c7271d960565cf3a37145e33af5bf5a08c73b52a1d819e152c737176dc08774e400798d6534e95e", + u_hex: "dbb84b847bb2d80865cc3c5a7c751d4c659d2c96ed1750f8be6f9c3b93fe3d11f494b43e5f17a21c85f5a4b171055afb", + oe_hex: "8c31f2392bc2a093850269a3404cbe01cea38e5e0fe308fe8ac50f5222cff103", + ue_hex: "2b0c44e4bddb3c9c01431b4ed3c7da42dbd1ef47e610d626ec2124535a0a4469", + perms_hex: "adbb69fb9fffde31ba90aaaf5eb4bdaf", + crypt_filters: aes256_filter, + stm_f: "StdCF", + str_f: "StdCF", + key_len_bytes: 32, + }, + // HAND-DERIVED, not produced by qpdf: an independent re-implementation of ISO Algorithms 2/3 + // (n = 5) computed this pair, cross-validated by having the same routine reproduce qpdf's + // n = 16 `/O` byte for byte. It exists because no producer we can run here emits R3 at 40 bits - + // qpdf core 12.2.0 writes `/Length 128` there, and this crate's own create() maps Rc4_40 to revision 2 - so + // the only route we have to `key_len` = 5 with the derivation loop RUNNING (r >= 3) is to craft + // it by hand. With that combination live, the loop's truncation stops being a no-op: mutating + // `Md5::digest(&digest[..n])` to hash the whole digest makes this password stop authenticating. + Case { + label: "crafted-r3-40bit-hand-derived", + v: 2, + r: 3, + length_bits: 40, + p: -1044, + encrypt_metadata: true, + id0_hex: "31415926535897932384626433832795", + o_hex: "50e50251fd20acb28ba50013142d19a47249d06b977d28b63cb3ce9d56fb5d50", + u_hex: "000756f24fe648256830aec6155ca6f100000000000000000000000000000000", + oe_hex: "", + ue_hex: "", + perms_hex: "", + crypt_filters: no_filters, + stm_f: "", + str_f: "", + key_len_bytes: 5, + }, +]; + +#[test] +fn outside_derived_dictionaries_authenticate_with_the_intended_role() { + for case in CASES { + let d = case.dict(); + let id0 = case.id0(); + + // The user password must open as a user. Owners are tried first, so an `Owner` here would mean + // the two passwords had collapsed onto one set of bytes - on these spellings they have not. + let user = SecurityHandler::open(d.clone(), &id0, Some(USER)) + .unwrap_or_else(|e| panic!("{}: qpdf's user password was refused by our reader: {e}", case.label)); + assert_eq!(user.auth(), Auth::User, "{}", case.label); + + // The owner password must open as owner: revisions 2-4 recover the user password from `/O` + // (Algorithm 7), revision 6 hashes against qpdf's own `/O`. + let owner = + SecurityHandler::open(d.clone(), &id0, Some(OWNER)).unwrap_or_else(|e| panic!("{}: qpdf's owner password was refused: {e}", case.label)); + assert_eq!(owner.auth(), Auth::Owner, "{}", case.label); + + // A wrong password may never open it; and the key must be exactly as wide as the revision + // promises - those five bytes are what makes a revision 2 truncation detectable at all. + assert!(SecurityHandler::open(d.clone(), &id0, Some("not the password")).is_err(), "{}: a wrong password opened", case.label); + assert_eq!(user.file_key().len(), case.key_len_bytes, "{}: file key width", case.label); + assert_eq!(owner.file_key(), user.file_key(), "{}: whichever password opens it, one key", case.label); + + // A user session must not claim owner rights. We deliberately do NOT assert individual + // permission flags here: which bit means "extract" moves between revision 2 and revisions 4 + // (qpdf wrote `/P = -28` for the export-grade file, where our accessor's bit-10 reading of + // "extract for accessibility" is a reserved field), and pinning that mapping belongs with the + // permissions tests, not with this oracle. What matters here is only that authentication, role + // and key width agree with what an outside implementation produced. + assert!(!user.permissions().owner, "{}: a user session claimed owner rights", case.label); + } +} + +#[test] +fn metadata_flag_from_outside_changes_the_file_key_it_certifies() { + // `/EncryptMetadata` false folds four set bytes into a revision 4 key (Algorithm 2, step f) and + // changes what revision 6 stores in `/U`. qpdf wrote both variants of each, so an implementation + // that ignored the flag would give identical keys. Internally this is no longer untested - + // `tests/it/proptests.rs` from #2358 varies the flag and asserts the keys differ - but internal tests + // still share our derivation, so what these rows add is a second implementation's own numbers. + for (enc_label, plain_label) in [("r4-aes128-meta-enc", "r4-aes128-meta-plain"), ("r6-aes256-meta-enc", "r6-aes256-meta-plain")] { + let a = CASES.iter().find(|c| c.label == enc_label).expect("encrypted-metadata fixture present"); + let b = CASES.iter().find(|c| c.label == plain_label).expect("plain-metadata fixture present"); + + let ha = SecurityHandler::open(a.dict(), &a.id0(), Some(USER)).unwrap_or_else(|e| panic!("{enc_label}: refused by our reader: {e}")); + let hb = SecurityHandler::open(b.dict(), &b.id0(), Some(USER)) + .unwrap_or_else(|e| panic!("{plain_label}: refused by our reader - is step f being ignored? {e}")); + + assert_ne!(ha.file_key(), hb.file_key(), "{enc_label} and {plain_label} must not share a key: the metadata flag is hashed in"); + assert!(ha.encrypts_metadata(), "{enc_label} reports encrypted metadata"); + assert!(!hb.encrypts_metadata(), "{plain_label} reports plain metadata, as qpdf wrote it"); + } + + // The outsider's numbers must differ across revisions; equality here would mean a transcription + // error, which would quietly void every assertion above. + let r2 = CASES.iter().find(|c| c.r == 2).unwrap(); + let r3 = CASES.iter().find(|c| c.r == 3).unwrap(); + assert_ne!(hex(r2.u_hex), hex(r3.u_hex), "revision 2 and revision 3 /U differ because the key width differs"); + assert_eq!(r2.u_hex.len(), 64, "revision 2 /U is RC4 over the padding: 32 bytes"); + assert_eq!(r3.u_hex.len(), 64, "revision 3 /U is 32 bytes: sixteen derived, plus padding qpdf fills (our crafted row leaves it zero)"); +} + +#[test] +fn qpdf_perms_decode_to_the_shape_both_implementations_agree_on() { + // `/Perms` is the one value in this dictionary that no test here verified before: `cos` writes it + // out and reads it back, but nothing decrypts it. Under qpdf's own bytes we can - `/P` + // little-endian, four set bytes, `T`/`F`, the `adb` sentinel (Algorithm 10 here; vendored lopdf + // checks the same fields as its Algorithm 13, though only three bytes of `/P`). + let mut checked = 0; + for case in CASES.iter().filter(|c| !c.perms_hex.is_empty()) { + let h = SecurityHandler::open(case.dict(), &case.id0(), Some(USER)) + .unwrap_or_else(|e| panic!("{}: refused before /Perms could be read: {e}", case.label)); + let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &hex(case.perms_hex), false); + + assert_eq!(open.len(), 16, "{}: /Perms is one encrypted block", case.label); + assert_eq!(&open[..4], &case.p.to_le_bytes(), "{}: /P must round trip against qpdf's bytes", case.label); + assert_eq!(&open[4..8], &[0xFF; 4], "{}: the four bytes after /P are set", case.label); + assert_eq!(open[8], if case.encrypt_metadata { b'T' } else { b'F' }, "{}: metadata byte as qpdf wrote it", case.label); + assert_eq!(&open[9..12], b"adb", "{}: sentinel from qpdf's bytes", case.label); + assert_ne!(&open[12..16], &[0u8; 4], "{}: qpdf's filler is not frozen at zero", case.label); + checked += 1; + } + assert!(checked >= 2, "expected /Perms on both revision 6 cases; saw {checked}"); +} diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 4eb2d6132b..89d9387989 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -15,6 +15,8 @@ pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } pdfcraft-model = { workspace = true } thiserror = { workspace = true } +# A backdrop copy's tag records a hash of the operators before the fill it turned off (backdrop.rs). +sha2 = "0.11" kurbo = { workspace = true } # Display order of right-to-left lines in added text, and reading and writing order in Edit text; # already in the tree via the interface. diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index d246df725b..a603a37060 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -18,161 +18,20 @@ use unicode_bidi::{Level, ParagraphBidiInfo}; use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged}; -const TAG: &str = "Added"; +mod draw; +mod layout; +mod store; +mod types; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Family { - #[default] - Helvetica, - Times, - Courier, -} - -impl Family { - pub fn label(self) -> &'static str { - match self { - Family::Helvetica => "Helvetica", - Family::Times => "Times", - Family::Courier => "Courier", - } - } - - /// The standard-14 font name for this family and style. - pub fn base_font(self, bold: bool, italic: bool) -> &'static str { - match (self, bold, italic) { - (Family::Helvetica, false, false) => "Helvetica", - (Family::Helvetica, true, false) => "Helvetica-Bold", - (Family::Helvetica, false, true) => "Helvetica-Oblique", - (Family::Helvetica, true, true) => "Helvetica-BoldOblique", - (Family::Times, false, false) => "Times-Roman", - (Family::Times, true, false) => "Times-Bold", - (Family::Times, false, true) => "Times-Italic", - (Family::Times, true, true) => "Times-BoldItalic", - (Family::Courier, false, false) => "Courier", - (Family::Courier, true, false) => "Courier-Bold", - (Family::Courier, false, true) => "Courier-Oblique", - (Family::Courier, true, true) => "Courier-BoldOblique", - } - } - - fn from_base(base: &[u8]) -> (Family, bool, bool) { - let s = String::from_utf8_lossy(base); - let family = if s.starts_with("Times") { - Family::Times - } else if s.starts_with("Courier") { - Family::Courier - } else { - Family::Helvetica - }; - (family, s.contains("Bold"), s.contains("Italic") || s.contains("Oblique")) - } - - /// Advance width of `s` at `size` points, from the published standard-14 metrics. - /// - /// `italic` matters: the item is written out as the oblique/italic face (see [`Family::base_font`]), - /// whose published widths differ from the upright face (Times-Italic `A` is 611, Times-Roman 722). - pub fn width(self, s: &str, size: f64, bold: bool, italic: bool) -> f64 { - self.std14(bold, italic).text_width(s, size) - } - - /// The standard-14 face this family means at the given style. - pub fn std14(self, bold: bool, italic: bool) -> Std14 { - match (self, bold, italic) { - (Family::Courier, false, false) => Std14::Courier, - (Family::Courier, true, false) => Std14::CourierBold, - (Family::Courier, false, true) => Std14::CourierOblique, - (Family::Courier, true, true) => Std14::CourierBoldOblique, - (Family::Times, false, false) => Std14::TimesRoman, - (Family::Times, true, false) => Std14::TimesBold, - (Family::Times, false, true) => Std14::TimesItalic, - (Family::Times, true, true) => Std14::TimesBoldItalic, - (Family::Helvetica, false, false) => Std14::Helvetica, - (Family::Helvetica, true, false) => Std14::HelveticaBold, - (Family::Helvetica, false, true) => Std14::HelveticaOblique, - (Family::Helvetica, true, true) => Std14::HelveticaBoldOblique, - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Align { - #[default] - Left, - Center, - Right, - /// Lines (but the last) stretched to the box's width. - Justify, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct AddedText { - /// The text box in display space; its width wraps the text and its top is the first line's - /// top. The height follows the text. - pub rect: [f64; 4], - pub text: String, - pub family: Family, - pub bold: bool, - pub italic: bool, - pub size: f64, - pub color: [f64; 3], - pub align: Align, -} - -impl Default for AddedText { - fn default() -> Self { - AddedText { - rect: [0.0; 4], - text: String::new(), - family: Family::Helvetica, - bold: false, - italic: false, - size: 12.0, - color: [0.0; 3], - align: Align::Left, - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct AddedImage { - /// Display space. - pub rect: [f64; 4], - /// The image XObject. - pub image: ObjRef, - /// Quarter turns counter-clockwise (0–3). - pub rotation: u8, - pub flip_h: bool, - pub flip_v: bool, - /// The fraction trimmed from each side of the image: left, bottom, right, top. - pub crop: [f64; 4], -} - -impl AddedImage { - pub fn new(rect: [f64; 4], image: ObjRef) -> Self { - AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] } - } - - /// The matrix from image space (the unit square) to display space: crop, flip, rotate, - /// then fill the box. - fn matrix(&self) -> pdfcraft_content::Matrix { - use pdfcraft_content::Matrix as M; - let [l, b, r, t] = self.crop.map(|v| v.clamp(0.0, 0.45)); - let (cw, ch) = ((1.0 - l - r).max(0.05), (1.0 - b - t).max(0.05)); - let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]); - if self.flip_h { - m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0])); - } - if self.flip_v { - m = m.then(&M([1.0, 0.0, 0.0, -1.0, 0.0, 1.0])); - } - for _ in 0..self.rotation % 4 { - // A quarter turn counter-clockwise within the unit square. - m = m.then(&M([0.0, 1.0, -1.0, 0.0, 1.0, 0.0])); - } - let [x0, y0, x1, y1] = norm(self.rect); - m.then(&M([x1 - x0, 0.0, 0.0, y1 - y0, x0, y0])) - } -} +#[cfg(test)] +use draw::{GLYPHS_PER_FONT, LaidLine, draw_laid_shaped}; +#[cfg(test)] +use layout::Piece; +#[cfg(test)] +pub(crate) use layout::WRAP_CALLS; +pub use layout::{PASTE_STEP, first_undrawable, lines, paste_place, paste_rect, text_rect, text_width}; +pub use store::{add_content, delete_content, isolate_content, list_added, scan_markers, update_content}; +pub use types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan}; #[derive(Clone, Debug, PartialEq)] pub enum Content { @@ -192,7 +51,9 @@ impl Content { fn scaled(self, factor: f64) -> Content { match self { _ if factor == 1.0 => self, - Content::Text(t) => Content::Text(AddedText { rect: t.rect.map(|v| v * factor), size: t.size * factor, ..t }), + Content::Text(t) => { + Content::Text(AddedText { rect: t.rect.map(|v| v * factor), size: t.size * factor, char_spacing: t.char_spacing * factor, ..t }) + } Content::Image(i) => Content::Image(AddedImage { rect: i.rect.map(|v| v * factor), ..i }), } } @@ -206,945 +67,22 @@ impl Content { } } -/// An added item on a page. -#[derive(Clone, Debug, PartialEq)] -pub struct Added { - pub page: usize, - /// The item's content stream. - pub obj: ObjRef, - pub content: Content, -} - -/// PdfCraft-owned page content discovered in one page/content traversal. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct MarkerScan { - pub marks: Vec, - pub added: Vec, -} - -fn nums(doc: &Document, o: Option<&Object>) -> Vec { - o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default() -} - -fn norm(r: [f64; 4]) -> [f64; 4] { - [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] -} - -/// The lines of a text item after wrapping to its box width. -pub fn lines(t: &AddedText) -> Vec { - wrapped(t).into_iter().map(|(line, _)| line).collect() -} - -/// [`lines`], each with whether its paragraph runs right to left (its first strong character -/// does). -fn wrapped(t: &AddedText) -> Vec<(String, bool)> { - let width = (t.rect[2] - t.rect[0]).max(t.size); - let mut out = Vec::new(); - for para in t.text.split('\n') { - // Arabic or Hebrew: laid out with the bidirectional algorithm and shaped. - let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c)); - let thai = para.chars().any(is_thai); - if thai - && !rtl_script - && let Ok(lines) = thai_lines(t, para, width) - { - out.extend(lines.into_iter().map(|line| (line, false))); - continue; - } - let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; - // Arabic words are shaped one by one (joining stops at spaces), so a line's width is the - // sum of its words' and shaping stays linear in the paragraph's length. The space is the - // paragraph's; a space between Latin words is drawn in the item's font, a few hundredths - // of an em apart. - let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) }; - let space = measure(" "); - let (mut line, mut line_w) = (String::new(), 0.0); - for word in para.split(' ') { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - let candidate_w = - if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; - if !line.is_empty() && candidate_w > width { - out.push((std::mem::take(&mut line), rtl)); - line = word.to_string(); - line_w = measure(word); - } else { - line = candidate; - line_w = candidate_w; - } - } - out.push((line, rtl)); - } - out -} - -/// Thai wrapping at shaped cluster boundaries never detaches a tone/vowel mark from its base. -/// This is glyph-safe wrapping, not dictionary-based Thai word segmentation. -fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, EditError> { - let (pieces, _) = shaped_layout(t, para, false)?; - let mut atoms = Vec::new(); - for piece in pieces { - match piece { - Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size))), - Piece::Latin(s) => atoms.extend(s.chars().map(|c| { - let text = c.to_string(); - let w = t.family.width(&text, t.size, t.bold, t.italic); - (text, w) - })), - } - } - let mut lines = Vec::new(); - let (mut line, mut used) = (String::new(), 0.0); - for (text, advance) in atoms { - if !line.is_empty() && used + advance > width { - lines.push(std::mem::take(&mut line)); - used = 0.0; - } - line.push_str(&text); - used += advance; - } - lines.push(line); - Ok(lines) -} - -fn is_thai(c: char) -> bool { - matches!(u32::from(c), 0x0E00..=0x0E7F) -} - -/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the -/// craft-fonts Arabic face. -fn is_arabic(c: char) -> bool { - matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC) -} - -/// Hebrew letters, points, cantillation marks and punctuation (and the presentation forms): an -/// item with any of them is drawn with the craft-fonts Hebrew face. -fn is_hebrew(c: char) -> bool { - matches!(u32::from(c), 0x0591..=0x05F4 | 0xFB1D..=0xFB4F) -} - -/// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn -/// with the craft-fonts Arabic (#125) or Hebrew (#128) face, or Arabic or Hebrew that the face can't -/// draw (or that has no face to draw it). Page-content tools refuse such text; the Add-text editor -/// keeps it open. -pub fn first_undrawable(t: &AddedText) -> Option { - if t.text.chars().any(is_thai) { - if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) { - return Some(c); - } - let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() }; - return first_undrawable(&rest); - } - // Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it. - if let Some(c) = - t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c))) - { - return Some(c); - } - if !shaped_by_a_face(t) { - // Drawn line by line in the item's standard font (`\n` splits lines). Without the face - // that is also how `draw` redraws an existing item, so it's checked that way. - return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi); - } - // What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it. - wrapped(t).into_iter().find_map(|(line, rtl)| { - let (pieces, _) = shaped_layout(t, &line, rtl).ok()?; - pieces.iter().find_map(|p| match p { - Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s), - Piece::Shaped(_) => None, - }) - }) -} - -/// Whether `t` has Arabic or Hebrew that its craft-fonts face draws (so `draw` lays it out with -/// [`draw_shaped`]). -fn shaped_by_a_face(t: &AddedText) -> bool { - (pdfcraft_fonts::document_arabic_font().is_some() && t.text.chars().any(is_arabic)) - || (pdfcraft_fonts::document_hebrew_font(false).is_some() && t.text.chars().any(is_hebrew)) -} - -/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped). -fn shaped_arabic(c: char) -> bool { - is_arabic(c) && !is_bidi_control(c) -} - -/// Direction marks, embeddings, overrides and isolates: they steer the order and are not drawn. -fn is_bidi_control(c: char) -> bool { - matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') -} - -fn mirrored(c: char) -> char { - match c { - '(' => ')', - ')' => '(', - '[' => ']', - ']' => '[', - '{' => '}', - '}' => '{', - '<' => '>', - '>' => '<', - '«' => '»', - '»' => '«', - other => other, +fn align_code(a: Align) -> i64 { + match a { + Align::Left => 0, + Align::Center => 1, + Align::Right => 2, + Align::Justify => 3, } } -/// A piece of a line with shaped text, in drawing order. -enum Piece { - /// Shown in the item's own standard font. - Latin(String), - Shaped(Vec), -} - -/// A line of a shaped-text item in drawing order (the Unicode bidirectional algorithm, UAX #9), and -/// its width at the item's size. `rtl` is its paragraph's direction. -fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f64), EditError> { - // unicode-bidi indexes the first level of a line, so an empty one would panic. - if line.is_empty() { - return Ok((Vec::new(), 0.0)); +fn align_from(code: Option) -> Align { + match code { + Some(1) => Align::Center, + Some(2) => Align::Right, + Some(3) => Align::Justify, + _ => Align::Left, } - let base = if rtl { Level::rtl() } else { Level::ltr() }; - let info = ParagraphBidiInfo::new(line, Some(base)); - let (levels, runs) = info.visual_runs(0..line.len()); - let mut pieces = Vec::new(); - for run in runs { - let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl); - // Runs lie on character boundaries; a missing slice is skipped, never a panic. - let Some(text) = line.get(run) else { continue }; - // Arabic and Hebrew letters take their face; so do spaces and punctuation between them - // (Hebrew's when they follow Hebrew), which the shaper then mirrors. Latin text (and what - // the faces lack) keeps the item's font. - let mut segments: Vec<(Option, String)> = Vec::new(); - for c in text.chars().filter(|c| !is_bidi_control(*c)) { - let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew)); - let script = if is_thai(c) { - Some(ShapedScript::Thai) - } else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) { - Some(ShapedScript::Hebrew) - } else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) { - Some(ShapedScript::Arabic) - } else { - None - }; - match segments.last_mut() { - Some((kind, s)) if *kind == script => s.push(c), - _ => segments.push((script, c.to_string())), - } - } - if run_rtl { - segments.reverse(); - } - for (script, s) in segments { - pieces.push(if let Some(script) = script { - let shaped = match script { - ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?, - ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?, - ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?, - }; - Piece::Shaped(shaped) - } else if run_rtl { - Piece::Latin(s.chars().rev().map(mirrored).collect()) - } else { - Piece::Latin(s) - }); - } - } - let width = pieces - .iter() - .map(|p| match p { - Piece::Latin(s) => t.family.width(s, t.size, t.bold, t.italic), - Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance).sum::() * t.size, - }) - .sum(); - Ok((pieces, width)) -} - -/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing -/// then reports why). -fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 { - shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold, t.italic)) -} - -fn arabic_error(e: GlyphError, text: &str) -> EditError { - EditError::Invalid(match e { - GlyphError::NoFont => "Arabic text needs PdfCraft's Arabic font, which this build doesn't include \ - (to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has an Arab face)" - .into(), - GlyphError::Missing => format!("the Arabic font can't show \"{text}\""), - GlyphError::TooComplex => format!("the Arabic font's glyphs for \"{text}\" are too complex"), - }) -} - -fn hebrew_error(e: GlyphError, text: &str) -> EditError { - EditError::Invalid(match e { - GlyphError::NoFont => "Hebrew text needs PdfCraft's Hebrew font, which this build doesn't include \ - (to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has a Hebr face)" - .into(), - GlyphError::Missing => format!("the Hebrew font can't show \"{text}\""), - GlyphError::TooComplex => format!("the Hebrew font's glyphs for \"{text}\" are too complex"), - }) -} - -fn thai_error(e: GlyphError, text: &str) -> EditError { - EditError::Invalid(match e { - GlyphError::NoFont => { - "Thai text needs a Thai font in this build; rebuild with CRAFT_FONTS_DIR pointing to craft-fonts with a Thai face".into() - } - GlyphError::Missing => format!("the Thai font can't show \"{text}\""), - GlyphError::TooComplex => "the Thai glyphs are too complex; use a shorter text box".into(), - }) -} - -/// The box a text item occupies (height from its lines). -pub fn text_rect(t: &AddedText) -> [f64; 4] { - let r = norm(t.rect); - let h = lines(t).len().max(1) as f64 * t.size * 1.2; - [r[0], r[3] - h, r[2], r[3]] -} - -/// The widest explicit line before wrapping, measured with the PDF writing fonts. -pub fn text_width(t: &AddedText) -> f64 { - t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max) -} - -fn font_name(base: &str) -> String { - format!("PCF{}", base.replace('-', "")) -} - -/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. An item gets one such font -/// per 240 distinct glyphs it needs. -const GLYPHS_PER_FONT: usize = 240; - -/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left, -/// its pieces in drawing order and its width at the item's size. -type LaidLine = (String, bool, Vec, f64); - -/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs -/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded), -/// with a ToUnicode map for copy and search. Other text uses the item's standard font. -fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec) -> Result<(), EditError> { - let laid = wrapped(t) - .into_iter() - .map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w))) - .collect::, _>>()?; - draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph) -} - -/// [`draw_shaped`] for lines that are already laid out. `outline` is the source of glyph outlines -/// for a script's face (the craft-fonts faces, or a stand-in in tests). -fn draw_laid_shaped( - doc: &mut Document, - t: &AddedText, - laid: &[LaidLine], - taken: &Dict, - fonts: &mut Dict, - out: &mut Vec, - outline: impl Fn(ShapedScript, u32) -> Result, -) -> Result<(), EditError> { - let r = text_rect(t); - let latin = font_name(t.family.base_font(t.bold, t.italic)); - // Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of - // one of the fonts, so copy and search get each character exactly once. - let mut keys: Vec<&ShapedCluster> = Vec::new(); - let mut index: HashMap = HashMap::new(); - for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p { - Piece::Shaped(clusters) => clusters.as_slice(), - Piece::Latin(_) => &[], - }) { - index.entry(cluster_key(c)).or_insert_with(|| { - keys.push(c); - keys.len() - 1 - }); - } - - // Every outline is read before anything is written, so a failure leaves the document as it was. - let mut outlines = HashMap::new(); - for c in &keys { - for (id, _) in &c.glyphs { - if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) { - entry.insert(outline(c.script, *id).map_err(|e| match c.script { - ShapedScript::Arabic => arabic_error(e, &c.text), - ShapedScript::Hebrew => hebrew_error(e, &c.text), - ShapedScript::Thai => thai_error(e, &c.text), - })?); - } - } - } - // The names the page and this item already use: a new font never takes one of them. - let mut taken_names: HashSet> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect(); - let mut names = Vec::new(); - for chunk in keys.chunks(GLYPHS_PER_FONT) { - let font = type3_shaped(doc, chunk, &outlines); - // A name no font on the page has, so items never replace each other's fonts (object - // numbers alone don't do: a full save renumbers objects but keeps resource names). - let mut name = format!("PCAr{}", font.num); - let mut suffix = 0usize; - while taken_names.contains(name.as_bytes()) { - suffix = suffix.saturating_add(1); - name = format!("PCAr{}_{suffix}", font.num); - } - taken_names.insert(name.clone().into_bytes()); - fonts.set(name.clone().into_bytes(), Object::Ref(font)); - names.push(name); - } - let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); - out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes()); - for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() { - // Shaped glyphs are painted in visual order. Keep the original line's typing order - // explicitly, including marks, ligatures, digits and mixed-script punctuation. - let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect(); - out.extend(format!("/Span <>> BDC\n").bytes()); - let spaces = line.matches(' ').count(); - // Justified: the spaces of every line but the last stretch to fill the box. - let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; - let x = match t.align { - // A right-to-left paragraph's unstretched last line ends at the right edge. - Align::Justify if *rtl => r[2] - w - tw * spaces as f64, - Align::Left | Align::Justify => r[0], - Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, - Align::Right => r[2] - w, - }; - let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; - let mut pen = x; - let mut current: Option<&str> = None; - for piece in pieces { - match piece { - Piece::Latin(s) => { - out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes()); - out.extend(literal(&win_ansi(s))); - out.extend_from_slice(b" Tj\n"); - current = Some(latin.as_str()); - pen += t.family.width(s, t.size, t.bold, t.italic) + tw * s.matches(' ').count() as f64; - } - Piece::Shaped(clusters) => { - for c in clusters { - let Some(&k) = index.get(&cluster_key(c)) else { continue }; - let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue }; - // Codes start at 1; k % 240 + 1 is at most 240. - let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1); - if current != Some(name.as_str()) { - out.extend(format!("/{name} {} Tf ", n(t.size)).bytes()); - current = Some(name.as_str()); - } - out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes()); - out.extend(literal(&[code])); - out.extend_from_slice(b" Tj\n"); - pen += c.advance * t.size + if c.text == " " { tw } else { 0.0 }; - } - } - } - } - out.extend_from_slice(b"EMC\n"); - } - out.extend_from_slice(b"ET\n"); - Ok(()) -} - -/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit; -/// equal clusters come from the same font units). -type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64); - -fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> { - (c.script, c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits()) -} - -/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`. -fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>) -> ObjRef { - let mut charprocs = Dict::new(); - let mut differences = vec![Object::Int(1)]; - let mut widths = Vec::with_capacity(clusters.len()); - let mut bbox = [0.0f64; 4]; - let mut to_unicode = Vec::new(); - for (i, c) in clusters.iter().enumerate() { - let code = i + 1; - // Glyph space is 1000 units per em. - let mut paths = Vec::new(); - let mut ink: Option<[f64; 4]> = None; - for (id, [dx, dy]) in &c.glyphs { - let Some(g) = outlines.get(&(c.script, *id)) else { continue }; - for contour in &g.contours { - for (j, p) in contour.iter().enumerate() { - paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes()); - } - paths.extend_from_slice(b"h\n"); - let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy]; - ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])); - } - } - // Rounded outwards so no ink is clipped. - let [x0, y0, x1, y1] = - ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]); - bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)]; - let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes(); - if !paths.is_empty() { - proc.extend(paths); - proc.extend_from_slice(b"f\n"); - } - let glyph = format!("g{code:02X}"); - charprocs.set(glyph.clone().into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &proc))))); - differences.push(Object::name(&glyph)); - widths.push(Object::Real((c.advance * 1000.0).round())); - if !c.text.is_empty() { - let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect(); - to_unicode.push(format!("<{code:02X}> <{hex}>\n")); - } - } - let mut cmap = String::from( - "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> \nendcodespacerange\n", - ); - // A CMap block holds at most 100 entries. - for block in to_unicode.chunks(100) { - cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat())); - } - cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); - let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); - // The face's family when every glyph comes from one script; without the face, the script's - // name (as Arabic items were always named). - let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) { - Some(script) => script.font().map_or( - match script { - ShapedScript::Arabic => "Arabic", - ShapedScript::Hebrew => "Hebrew", - ShapedScript::Thai => "Thai", - }, - |f| f.family, - ), - None => "PdfCraft Shaped Text", - }; - let mut descriptor = Dict::new(); - descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); - descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', ""))); - descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec()))); - descriptor.set(b"Flags".to_vec(), Object::Int(4)); - descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); - let mut encoding = Dict::new(); - encoding.set(b"Type".to_vec(), Object::name("Encoding")); - encoding.set(b"Differences".to_vec(), Object::Array(differences)); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type3")); - font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor)))); - font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect())); - font.set( - b"FontMatrix".to_vec(), - Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), - ); - font.set(b"FirstChar".to_vec(), Object::Int(1)); - font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64)); - font.set(b"Widths".to_vec(), Object::Array(widths)); - font.set(b"Encoding".to_vec(), Object::Dict(encoding)); - font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); - font.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); - doc.add(Object::Dict(font)) -} - -/// Content and resources for an item; `view` maps display space to user space. `taken` holds the -/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or -/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to -/// move it. -fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec, Dict), EditError> { - let mut res = Dict::new(); - let mut out = format!( - "q {} {} {} {} {} {} cm\n", - matrix_number(view[0]), - matrix_number(view[1]), - matrix_number(view[2]), - matrix_number(view[3]), - matrix_number(view[4]), - matrix_number(view[5]) - ) - .into_bytes(); - match c { - Content::Text(t) => { - let base = t.family.base_font(t.bold, t.italic); - let name = font_name(base); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name(base)); - if t.family != Family::Courier || !t.text.is_ascii() { - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - } - let mut fonts = Dict::new(); - fonts.set(name.clone().into_bytes(), Object::Dict(font)); - let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing); - if t.text.chars().any(is_thai) - || face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some()) - || face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some()) - { - draw_shaped(doc, t, taken, &mut fonts, &mut out)?; - res.set(b"Font".to_vec(), Object::Dict(fonts)); - out.extend_from_slice(b"Q\n"); - return Ok((out, res)); - } - res.set(b"Font".to_vec(), Object::Dict(fonts)); - let r = text_rect(t); - let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); - out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes()); - for (i, line) in lines(t).iter().enumerate() { - let w = t.family.width(line, t.size, t.bold, t.italic); - let x = match t.align { - Align::Left | Align::Justify => r[0], - Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, - Align::Right => r[2] - w, - }; - // Baseline: 0.8 em below the line top. - let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; - // Justified: word spacing makes every line but the last fill the box. - let all = lines(t); - let spaces = line.matches(' ').count(); - let tw = - if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; - out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes()); - out.extend(literal(&win_ansi(line))); - out.extend_from_slice(b" Tj\n"); - } - out.extend_from_slice(b"ET\n"); - } - Content::Image(i) => { - if !matches!(&*doc.get(i.image), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")) { - return Err(EditError::Invalid("not an image".into())); - } - let r = norm(i.rect); - let name = format!("PCImg{}", i.image.num); - let mut xo = Dict::new(); - xo.set(name.clone().into_bytes(), Object::Ref(i.image)); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let [a, b, c, d, e, f] = i.matrix().0; - // Clip to the box: the cropped-away parts stay hidden. - out.extend( - format!( - "{} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do\n", - n(r[0]), - n(r[1]), - n(r[2] - r[0]), - n(r[3] - r[1]), - n(a), - n(b), - n(c), - n(d), - n(e), - n(f) - ) - .bytes(), - ); - } - } - out.extend_from_slice(b"Q\n"); - Ok((out, res)) -} - -fn params(c: &Content) -> Dict { - let mut d = Dict::new(); - let arr = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect()); - match c { - Content::Text(t) => { - d.set(b"Kind".to_vec(), Object::name("Text")); - d.set(b"Text".to_vec(), PdfString::text(&t.text)); - d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic))); - d.set(b"Size".to_vec(), Object::Real(t.size)); - d.set(b"Color".to_vec(), arr(&t.color)); - d.set( - b"Align".to_vec(), - Object::Int(match t.align { - Align::Left => 0, - Align::Center => 1, - Align::Right => 2, - Align::Justify => 3, - }), - ); - d.set(b"Rect".to_vec(), arr(&text_rect(t))); - } - Content::Image(i) => { - d.set(b"Kind".to_vec(), Object::name("Image")); - d.set(b"Image".to_vec(), Object::Ref(i.image)); - d.set(b"Rect".to_vec(), arr(&norm(i.rect))); - if i.rotation % 4 != 0 { - d.set(b"Rotate".to_vec(), Object::Int(i64::from(i.rotation % 4) * 90)); - } - if i.flip_h { - d.set(b"FlipH".to_vec(), Object::Bool(true)); - } - if i.flip_v { - d.set(b"FlipV".to_vec(), Object::Bool(true)); - } - if i.crop != [0.0; 4] { - d.set(b"Crop".to_vec(), arr(&i.crop)); - } - } - } - d -} - -fn parse(doc: &Document, d: &Dict) -> Option { - let r = nums(doc, d.get(b"Rect")); - let rect: [f64; 4] = r.try_into().ok()?; - match d.name(b"Kind")? { - b"Text" => { - let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica")); - let c = nums(doc, d.get(b"Color")); - Some(Content::Text(AddedText { - rect, - text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(), - family, - bold, - italic, - size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0), - color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] }, - align: match d.int(b"Align") { - Some(1) => Align::Center, - Some(2) => Align::Right, - Some(3) => Align::Justify, - _ => Align::Left, - }, - })) - } - b"Image" => { - let crop = nums(doc, d.get(b"Crop")); - Some(Content::Image(AddedImage { - rect, - image: d.get(b"Image")?.as_ref()?, - rotation: (d.int(b"Rotate").unwrap_or(0).rem_euclid(360) / 90) as u8, - flip_h: matches!(d.get(b"FlipH"), Some(Object::Bool(true))), - flip_v: matches!(d.get(b"FlipV"), Some(Object::Bool(true))), - crop: crop.try_into().unwrap_or([0.0; 4]), - })) - } - _ => None, - } -} - -fn with_resolved(doc: &Document, object: &Object, f: impl FnOnce(&Object) -> T) -> T { - match object { - Object::Ref(reference) => { - let resolved = doc.get(*reference); - f(&resolved) - } - direct => f(direct), - } -} - -fn for_each_content_entry(doc: &Document, page: &pdfcraft_model::Page, mut visit: impl FnMut(&Object)) { - let Some(contents) = page.dict.get(b"Contents") else { return }; - with_resolved(doc, contents, |resolved| match resolved { - Object::Array(entries) => { - for entry in entries { - visit(entry); - } - } - _ => visit(contents), - }); -} - -/// Finds page marks and editable added content in one pass over the page content entries. -pub fn scan_markers(doc: &Document) -> MarkerScan { - let mut scan = MarkerScan::default(); - for (pi, page) in page_list(doc).iter().enumerate() { - for_each_content_entry(doc, page, |entry| { - with_resolved(doc, entry, |resolved| { - let Some(dict) = resolved.as_dict() else { return }; - let tag = dict.name(b"PCMark"); - let kind = if tag == Some(&b"HeaderFooter"[..]) { - Some(MarkKind::HeaderFooter) - } else if tag == Some(&b"Watermark"[..]) { - Some(MarkKind::Watermark) - } else if tag == Some(&b"Background"[..]) { - Some(MarkKind::Background) - } else { - None - }; - if let Some(kind) = kind - && !scan.marks.contains(&kind) - { - scan.marks.push(kind); - } - - let Some(obj) = Object::as_ref(entry) else { return }; - if tag != Some(TAG.as_bytes()) { - return; - } - let Some(params_object) = dict.get(b"PCAdded") else { return }; - with_resolved(doc, params_object, |resolved_params| { - let Some(params) = resolved_params.as_dict() else { return }; - if let Some(content) = parse(doc, params) { - // Display points depend on the page's /UserUnit. An item records the one it was - // written for; none means 1, as for items written before display space followed - // /UserUnit. - let written = params - .get(b"UserUnit") - .and_then(Object::as_f64) - .filter(|u| u.is_finite() && *u >= 1.0) - .map_or(1.0, |u| u.min(pdfcraft_model::MAX_USER_UNIT)); - scan.added.push(Added { page: pi, obj, content: content.scaled(page.user_unit(doc) / written) }); - } - }); - }); - }); - } - scan -} - -fn validate(c: &Content) -> Result<(), EditError> { - let r = c.rect(); - if !r.iter().all(|v| v.is_finite()) { - return Err(EditError::Invalid("invalid position".into())); - } - match c { - Content::Text(t) => { - if t.text.trim().is_empty() { - return Err(EditError::Invalid("type some text first".into())); - } - if !(t.size.is_finite() && (1.0..=500.0).contains(&t.size)) { - return Err(EditError::Invalid("the font size must be between 1 and 500 points".into())); - } - if (r[2] - r[0]).abs() < 1.0 { - return Err(EditError::Invalid("the text box is too narrow".into())); - } - if t.text.chars().any(is_thai) && pdfcraft_fonts::document_thai_font().is_none() { - return Err(thai_error(GlyphError::NoFont, &t.text)); - } - // Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new - // item is refused with what's missing, and one that already holds Arabic stays movable. - // Everything else in it is drawn in the standard font, so it still has to fit. - let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() { - t.text.split('\n').find_map(|line| { - line.chars() - .find(|c| !(shaped_arabic(*c) || is_thai(*c) && pdfcraft_fonts::thai_has(*c) || pdfcraft_fonts::win_ansi_encodable(*c))) - }) - } else { - first_undrawable(t) - }; - if let Some(c) = undrawable { - return Err(crate::undrawable(c)); - } - } - Content::Image(_) => { - if (r[2] - r[0]).abs() < 1.0 || (r[3] - r[1]).abs() < 1.0 { - return Err(EditError::Invalid("the image is too small".into())); - } - } - } - Ok(()) -} - -/// Write an item's stream (new, or replacing `obj`) and make its resources available to the page. -fn write(doc: &mut Document, page: usize, c: &Content, obj: Option) -> Result { - validate(c)?; - let all = page_list(doc); - check(&[page], all.len())?; - let p = &all[page]; - let view = p.view_matrix(doc); - let user_unit = p.user_unit(doc); - // The page gets its own copy of its resources with the item's fonts and images added, and - // without the Arabic fonts of the version it replaces. - let mut pres = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - if let Some(r) = obj { - drop_fonts(doc, &mut pres, &own_fonts(doc, r)); - } - let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let (content, res) = draw(doc, c, view, &taken, obj.is_some())?; - for (k, v) in res.iter() { - let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default(); - for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) { - sub.set(n2.clone(), o.clone()); - } - pres.set(k.clone(), Object::Dict(sub)); - } - doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(pres)))?; - let mut sd = Dict::new(); - sd.set(b"PCMark".to_vec(), Object::name(TAG)); - let mut added = params(c); - if user_unit != 1.0 { - added.set(b"UserUnit".to_vec(), Object::Real(user_unit)); - } - sd.set(b"PCAdded".to_vec(), Object::Dict(added)); - let stream = Stream::flate(sd, &content); - match obj { - Some(r) => { - doc.set(r, Object::Stream(stream)); - Ok(r) - } - None => { - // place_tagged appends a new stream; recover its reference. - let p = page_list(doc).swap_remove(page); - place_tagged(doc, &p, TAG, content.clone(), false)?; - let p = page_list(doc).swap_remove(page); - let r = contents(doc, &p)?.last().and_then(Object::as_ref).ok_or_else(|| EditError::Invalid("could not add the content".into()))?; - doc.set(r, Object::Stream(stream)); - Ok(r) - } - } -} - -/// Every added item, page by page, in drawing order. -pub fn list_added(doc: &Document) -> Vec { - scan_markers(doc).added -} - -/// Add an item to a page (0-based). Returns its index among the page's added items. -pub fn add_content(doc: &mut Document, page: usize, c: &Content) -> Result { - write(doc, page, c, None)?; - Ok(list_added(doc).iter().filter(|a| a.page == page).count() - 1) -} - -fn find(doc: &Document, page: usize, index: usize) -> Result { - list_added(doc) - .into_iter() - .filter(|a| a.page == page) - .nth(index) - .ok_or_else(|| EditError::Invalid(format!("page {} has no added item {}", page + 1, index + 1))) -} - -/// Replace item `index` of `page` (moved, resized, retyped or reformatted). -pub fn update_content(doc: &mut Document, page: usize, index: usize, c: &Content) -> Result<(), EditError> { - let a = find(doc, page, index)?; - if std::mem::discriminant(&a.content) != std::mem::discriminant(c) { - return Err(EditError::Invalid("an item can't change between text and image".into())); - } - write(doc, page, c, Some(a.obj))?; - Ok(()) -} - -/// Remove item `index` of `page`. -pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { - let a = find(doc, page, index)?; - let p = page_list(doc).swap_remove(page); - let list: Vec = contents(doc, &p)?.into_iter().filter(|o| o.as_ref() != Some(a.obj)).collect(); - let fonts = own_fonts(doc, a.obj); - // Only the page's own resources carry an item's fonts (`write` gives the page a copy). - let res = match p.dict.get(b"Resources") { - Some(Object::Dict(d)) if !fonts.is_empty() => Some(d.clone()), - _ => None, - }; - let res = res.map(|mut r| { - drop_fonts(doc, &mut r, &fonts); - r - }); - doc.update_dict(p.obj, |d| { - d.set(b"Contents".to_vec(), Object::Array(list)); - if let Some(r) = res { - d.set(b"Resources".to_vec(), Object::Dict(r)); - } - })?; - Ok(()) -} - -/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped` -/// names them so), so they go when the item is replaced or deleted. -fn own_fonts(doc: &Document, obj: ObjRef) -> Vec> { - let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() }; - let Ok(data) = s.decoded() else { return Vec::new() }; - data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).map(<[u8]>::to_vec).collect() -} - -fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec]) { - if names.is_empty() { - return; - } - let Some(mut fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else { return }; - for name in names { - fonts.remove(name); - } - res.set(b"Font".to_vec(), Object::Dict(fonts)); } #[cfg(test)] diff --git a/crates/edit/src/added/arabic_tests.rs b/crates/edit/src/added/arabic_tests.rs index d4ebb73e42..0671e3ce32 100644 --- a/crates/edit/src/added/arabic_tests.rs +++ b/crates/edit/src/added/arabic_tests.rs @@ -24,7 +24,7 @@ fn cluster(id: u32, advance: f64) -> ShapedCluster { fn line(clusters: Vec, rtl: bool) -> LaidLine { let text = clusters.iter().map(|c| c.text.as_str()).collect(); let width = clusters.iter().map(|c| c.advance).sum::() * item().size; - (text, rtl, vec![Piece::Shaped(clusters)], width) + (text, rtl, 0, vec![Piece::Shaped(clusters)], width) } /// A glyph outline: a box on the baseline, `advance` em wide. @@ -147,7 +147,7 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() { let space = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() }; let ligature = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() }; let mixed: LaidLine = - ("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0); + ("Hi ب".into(), false, 0, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0); let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true); let laid = vec![mixed, rtl]; let mut doc = Document::new_empty(); diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs new file mode 100644 index 0000000000..9404f939b1 --- /dev/null +++ b/crates/edit/src/added/draw.rs @@ -0,0 +1,388 @@ +use super::layout::{ + Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, norm, shaped_layout, text_rect, thai_error, wrapped, +}; +use super::types::{AddedText, Align, Family}; +use super::{ + Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream, + align_code, literal, matrix_number, n, shaped_glyph, win_ansi, +}; + +fn font_name(base: &str) -> String { + format!("PCF{}", base.replace('-', "")) +} + +/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. An item gets one such font +/// per 240 distinct glyphs it needs. +pub(super) const GLYPHS_PER_FONT: usize = 240; + +/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left, +/// its paragraph, its pieces in drawing order and its width at the item's size. +pub(super) type LaidLine = (String, bool, usize, Vec, f64); + +/// Where line `i` (`w` wide unstretched, with `spaces` spaces) starts in the box `r`, and its word +/// spacing. `paragraphs` holds each line's paragraph: a line takes its paragraph's alignment, and +/// every line of a justified paragraph but its last is stretched to the box's width. +fn placed(t: &AddedText, r: [f64; 4], paragraphs: &[usize], i: usize, w: f64, spaces: usize, rtl: bool) -> (f64, f64) { + let paragraph = paragraphs.get(i).copied().unwrap_or(0); + let align = t.paragraph_align(paragraph); + let ends_paragraph = paragraphs.get(i + 1) != Some(¶graph); + let tw = if align == Align::Justify && !ends_paragraph && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; + let x = match align { + // A right-to-left paragraph's unstretched last line ends at the right edge. + Align::Justify if rtl => r[2] - w - tw * spaces as f64, + Align::Left | Align::Justify => r[0], + Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, + Align::Right => r[2] - w, + }; + (x, tw) +} + +/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs +/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded), +/// with a ToUnicode map for copy and search. Other text uses the item's standard font. +fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec) -> Result<(), EditError> { + let laid = wrapped(t) + .into_iter() + .map(|(line, rtl, paragraph)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, paragraph, pieces, w))) + .collect::, _>>()?; + draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph) +} + +/// [`draw_shaped`] for lines that are already laid out. `outline` is the source of glyph outlines +/// for a script's face (the craft-fonts faces, or a stand-in in tests). +pub(super) fn draw_laid_shaped( + doc: &mut Document, + t: &AddedText, + laid: &[LaidLine], + taken: &Dict, + fonts: &mut Dict, + out: &mut Vec, + outline: impl Fn(ShapedScript, u32) -> Result, +) -> Result<(), EditError> { + let r = text_rect(t); + let latin = font_name(t.family.base_font(t.bold, t.italic)); + // Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of + // one of the fonts, so copy and search get each character exactly once. + let mut keys: Vec<&ShapedCluster> = Vec::new(); + let mut index: HashMap = HashMap::new(); + for c in laid.iter().flat_map(|(_, _, _, pieces, _)| pieces).flat_map(|p| match p { + Piece::Shaped(clusters) => clusters.as_slice(), + Piece::Latin(_) => &[], + }) { + index.entry(cluster_key(c)).or_insert_with(|| { + keys.push(c); + keys.len() - 1 + }); + } + + // Every outline is read before anything is written, so a failure leaves the document as it was. + let mut outlines = HashMap::new(); + for c in &keys { + for (id, _) in &c.glyphs { + if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) { + entry.insert(outline(c.script, *id).map_err(|e| match c.script { + ShapedScript::Arabic => arabic_error(e, &c.text), + ShapedScript::Hebrew => hebrew_error(e, &c.text), + ShapedScript::Thai => thai_error(e, &c.text), + })?); + } + } + } + // The names the page and this item already use: a new font never takes one of them. + let mut taken_names: HashSet> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect(); + let mut names = Vec::new(); + for chunk in keys.chunks(GLYPHS_PER_FONT) { + let font = type3_shaped(doc, chunk, &outlines); + // A name no font on the page has, so items never replace each other's fonts (object + // numbers alone don't do: a full save renumbers objects but keeps resource names). + let mut name = format!("PCAr{}", font.num); + let mut suffix = 0usize; + while taken_names.contains(name.as_bytes()) { + suffix = suffix.saturating_add(1); + name = format!("PCAr{}_{suffix}", font.num); + } + taken_names.insert(name.clone().into_bytes()); + fonts.set(name.clone().into_bytes(), Object::Ref(font)); + names.push(name); + } + let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); + out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes()); + if t.char_spacing != 0.0 { + out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes()); + } + let paragraphs: Vec = laid.iter().map(|(_, _, p, ..)| *p).collect(); + for (i, (line, rtl, _, pieces, w)) in laid.iter().enumerate() { + // Shaped glyphs are painted in visual order. Keep the original line's typing order + // explicitly, including marks, ligatures, digits and mixed-script punctuation. + let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect(); + out.extend(format!("/Span <>> BDC\n").bytes()); + let spaces = line.matches(' ').count(); + let (x, tw) = placed(t, r, ¶graphs, i, *w, spaces, *rtl); + let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; + let mut pen = x; + let mut current: Option<&str> = None; + for piece in pieces { + match piece { + Piece::Latin(s) => { + out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes()); + out.extend(literal(&win_ansi(s))); + out.extend_from_slice(b" Tj\n"); + current = Some(latin.as_str()); + pen += latin_width(t, s) + tw * s.matches(' ').count() as f64; + } + Piece::Shaped(clusters) => { + for c in clusters { + let Some(&k) = index.get(&cluster_key(c)) else { continue }; + let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue }; + // Codes start at 1; k % 240 + 1 is at most 240. + let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1); + if current != Some(name.as_str()) { + out.extend(format!("/{name} {} Tf ", n(t.size)).bytes()); + current = Some(name.as_str()); + } + out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes()); + out.extend(literal(&[code])); + out.extend_from_slice(b" Tj\n"); + pen += c.advance * t.size + t.char_spacing + if c.text == " " { tw } else { 0.0 }; + } + } + } + } + out.extend_from_slice(b"EMC\n"); + } + out.extend_from_slice(b"ET\n"); + Ok(()) +} + +/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit; +/// equal clusters come from the same font units). +type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64); + +fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> { + (c.script, c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits()) +} + +/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`. +fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>) -> ObjRef { + let mut charprocs = Dict::new(); + let mut differences = vec![Object::Int(1)]; + let mut widths = Vec::with_capacity(clusters.len()); + let mut bbox = [0.0f64; 4]; + let mut to_unicode = Vec::new(); + for (i, c) in clusters.iter().enumerate() { + let code = i + 1; + // Glyph space is 1000 units per em. + let mut paths = Vec::new(); + let mut ink: Option<[f64; 4]> = None; + for (id, [dx, dy]) in &c.glyphs { + let Some(g) = outlines.get(&(c.script, *id)) else { continue }; + for contour in &g.contours { + for (j, p) in contour.iter().enumerate() { + paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes()); + } + paths.extend_from_slice(b"h\n"); + let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy]; + ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])); + } + } + // Rounded outwards so no ink is clipped. + let [x0, y0, x1, y1] = + ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]); + bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)]; + let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes(); + if !paths.is_empty() { + proc.extend(paths); + proc.extend_from_slice(b"f\n"); + } + let glyph = format!("g{code:02X}"); + charprocs.set(glyph.clone().into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &proc))))); + differences.push(Object::name(&glyph)); + widths.push(Object::Real((c.advance * 1000.0).round())); + if !c.text.is_empty() { + let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect(); + to_unicode.push(format!("<{code:02X}> <{hex}>\n")); + } + } + let mut cmap = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> \nendcodespacerange\n", + ); + // A CMap block holds at most 100 entries. + for block in to_unicode.chunks(100) { + cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat())); + } + cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); + // The face's family when every glyph comes from one script; without the face, the script's + // name (as Arabic items were always named). + let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) { + Some(script) => script.font().map_or( + match script { + ShapedScript::Arabic => "Arabic", + ShapedScript::Hebrew => "Hebrew", + ShapedScript::Thai => "Thai", + }, + |f| f.family, + ), + None => "PdfCraft Shaped Text", + }; + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', ""))); + descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec()))); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + let mut encoding = Dict::new(); + encoding.set(b"Type".to_vec(), Object::name("Encoding")); + encoding.set(b"Differences".to_vec(), Object::Array(differences)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type3")); + font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor)))); + font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect())); + font.set( + b"FontMatrix".to_vec(), + Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), + ); + font.set(b"FirstChar".to_vec(), Object::Int(1)); + font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64)); + font.set(b"Widths".to_vec(), Object::Array(widths)); + font.set(b"Encoding".to_vec(), Object::Dict(encoding)); + font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); + font.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + doc.add(Object::Dict(font)) +} + +/// Content and resources for an item; `view` maps display space to user space. `taken` holds the +/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or +/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to +/// move it. +pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec, Dict), EditError> { + let mut res = Dict::new(); + let mut out = format!( + "q {} {} {} {} {} {} cm\n", + matrix_number(view[0]), + matrix_number(view[1]), + matrix_number(view[2]), + matrix_number(view[3]), + matrix_number(view[4]), + matrix_number(view[5]) + ) + .into_bytes(); + match c { + Content::Text(t) => { + let base = t.family.base_font(t.bold, t.italic); + let name = font_name(base); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(base)); + if t.family != Family::Courier || !t.text.is_ascii() { + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + } + let mut fonts = Dict::new(); + fonts.set(name.clone().into_bytes(), Object::Dict(font)); + let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing); + if t.text.chars().any(is_thai) + || face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some()) + || face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some()) + { + draw_shaped(doc, t, taken, &mut fonts, &mut out)?; + res.set(b"Font".to_vec(), Object::Dict(fonts)); + out.extend_from_slice(b"Q\n"); + return Ok((out, res)); + } + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let r = text_rect(t); + let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); + out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes()); + if t.char_spacing != 0.0 { + out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes()); + } + let all = wrapped(t); + let paragraphs: Vec = all.iter().map(|(_, _, p)| *p).collect(); + for (i, (line, ..)) in all.iter().enumerate() { + let w = latin_width(t, line); + // Baseline: 0.8 em below the line top. + let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; + // Justified: word spacing makes every line of a paragraph but its last fill the box. + let (x, tw) = placed(t, r, ¶graphs, i, w, line.matches(' ').count(), false); + out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes()); + out.extend(literal(&win_ansi(line))); + out.extend_from_slice(b" Tj\n"); + } + out.extend_from_slice(b"ET\n"); + } + Content::Image(i) => { + if !matches!(&*doc.get(i.image), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")) { + return Err(EditError::Invalid("not an image".into())); + } + let r = norm(i.rect); + let name = format!("PCImg{}", i.image.num); + let mut xo = Dict::new(); + xo.set(name.clone().into_bytes(), Object::Ref(i.image)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let [a, b, c, d, e, f] = i.matrix().0; + // Clip to the box: the cropped-away parts stay hidden. + out.extend( + format!( + "{} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do\n", + n(r[0]), + n(r[1]), + n(r[2] - r[0]), + n(r[3] - r[1]), + n(a), + n(b), + n(c), + n(d), + n(e), + n(f) + ) + .bytes(), + ); + } + } + out.extend_from_slice(b"Q\n"); + Ok((out, res)) +} + +pub(super) fn params(c: &Content) -> Dict { + let mut d = Dict::new(); + let arr = |v: &[f64]| Object::Array(v.iter().map(|x| Object::Real(*x)).collect()); + match c { + Content::Text(t) => { + d.set(b"Kind".to_vec(), Object::name("Text")); + d.set(b"Text".to_vec(), PdfString::text(&t.text)); + d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic))); + d.set(b"Size".to_vec(), Object::Real(t.size)); + d.set(b"Color".to_vec(), arr(&t.color)); + d.set(b"Align".to_vec(), Object::Int(align_code(t.align))); + // Each paragraph's own alignment, or null where it follows /Align. + let aligns = crate::paragraph_align::tidy(t.aligns.clone(), t.align); + if !aligns.is_empty() { + d.set(b"Aligns".to_vec(), Object::Array(aligns.iter().map(|a| a.map_or(Object::Null, |a| Object::Int(align_code(a)))).collect())); + } + d.set(b"Rect".to_vec(), arr(&text_rect(t))); + if t.char_spacing != 0.0 { + d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing)); + } + } + Content::Image(i) => { + d.set(b"Kind".to_vec(), Object::name("Image")); + d.set(b"Image".to_vec(), Object::Ref(i.image)); + d.set(b"Rect".to_vec(), arr(&norm(i.rect))); + if i.rotation % 4 != 0 { + d.set(b"Rotate".to_vec(), Object::Int(i64::from(i.rotation % 4) * 90)); + } + if i.flip_h { + d.set(b"FlipH".to_vec(), Object::Bool(true)); + } + if i.flip_v { + d.set(b"FlipV".to_vec(), Object::Bool(true)); + } + if i.crop != [0.0; 4] { + d.set(b"Crop".to_vec(), arr(&i.crop)); + } + } + } + d +} diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs new file mode 100644 index 0000000000..31a4dc409f --- /dev/null +++ b/crates/edit/src/added/layout.rs @@ -0,0 +1,359 @@ +use super::types::{AddedImage, AddedText}; +use super::{ + Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew, + shape_thai, +}; + +pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec { + o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default() +} + +pub(super) fn norm(r: [f64; 4]) -> [f64; 4] { + [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] +} + +/// The advance of `s` in the item's standard font: its glyphs' widths and the character spacing +/// after each of them, as `Tc` moves the pen (ISO 32000-2 §9.3.2). +pub(super) fn latin_width(t: &AddedText, s: &str) -> f64 { + t.family.width(s, t.size, t.bold, t.italic) + t.char_spacing * s.chars().count() as f64 +} + +/// The lines of a text item after wrapping to its box width. +pub fn lines(t: &AddedText) -> Vec { + wrapped(t).into_iter().map(|(line, ..)| line).collect() +} + +#[cfg(test)] +thread_local! { + /// How often [`wrapped`] laid a text out on this thread, for the test that keeps it out of per-line loops. + pub(crate) static WRAP_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`lines`], each with whether its paragraph runs right to left (its first strong character +/// does) and which paragraph it is in. +pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> { + #[cfg(test)] + WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1))); + let width = (t.rect[2] - t.rect[0]).max(t.size); + let mut out = Vec::new(); + for (index, para) in t.text.split('\n').enumerate() { + // Arabic or Hebrew: laid out with the bidirectional algorithm and shaped. + let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c)); + let thai = para.chars().any(is_thai); + if thai + && !rtl_script + && let Ok(lines) = thai_lines(t, para, width) + { + out.extend(lines.into_iter().map(|line| (line, false, index))); + continue; + } + let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; + // Arabic words are shaped one by one (joining stops at spaces), so a line's width is the + // sum of its words' and shaping stays linear in the paragraph's length. The space is the + // paragraph's; a space between Latin words is drawn in the item's font, a few hundredths + // of an em apart. + let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { latin_width(t, s) }; + let space = measure(" "); + let (mut line, mut line_w) = (String::new(), 0.0); + for word in para.split(' ') { + let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + let candidate_w = + if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; + if !line.is_empty() && candidate_w > width { + out.push((std::mem::take(&mut line), rtl, index)); + line = word.to_string(); + line_w = measure(word); + } else { + line = candidate; + line_w = candidate_w; + } + } + out.push((line, rtl, index)); + } + out +} + +/// Thai wrapping at shaped cluster boundaries never detaches a tone/vowel mark from its base. +/// This is glyph-safe wrapping, not dictionary-based Thai word segmentation. +fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, EditError> { + let (pieces, _) = shaped_layout(t, para, false)?; + let mut atoms = Vec::new(); + for piece in pieces { + match piece { + Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size + t.char_spacing))), + Piece::Latin(s) => atoms.extend(s.chars().map(|c| { + let text = c.to_string(); + let w = latin_width(t, &text); + (text, w) + })), + } + } + let mut lines = Vec::new(); + let (mut line, mut used) = (String::new(), 0.0); + for (text, advance) in atoms { + if !line.is_empty() && used + advance > width { + lines.push(std::mem::take(&mut line)); + used = 0.0; + } + line.push_str(&text); + used += advance; + } + lines.push(line); + Ok(lines) +} + +pub(super) fn is_thai(c: char) -> bool { + matches!(u32::from(c), 0x0E00..=0x0E7F) +} + +/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the +/// craft-fonts Arabic face. +pub(super) fn is_arabic(c: char) -> bool { + matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC) +} + +/// Hebrew letters, points, cantillation marks and punctuation (and the presentation forms): an +/// item with any of them is drawn with the craft-fonts Hebrew face. +pub(super) fn is_hebrew(c: char) -> bool { + matches!(u32::from(c), 0x0591..=0x05F4 | 0xFB1D..=0xFB4F) +} + +/// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn +/// with the craft-fonts Arabic (#125) or Hebrew (#128) face, or Arabic or Hebrew that the face can't +/// draw (or that has no face to draw it). Page-content tools refuse such text; the Add-text editor +/// keeps it open. +pub fn first_undrawable(t: &AddedText) -> Option { + if t.text.chars().any(is_thai) { + if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) { + return Some(c); + } + let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() }; + return first_undrawable(&rest); + } + // Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it. + if let Some(c) = + t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c))) + { + return Some(c); + } + if !shaped_by_a_face(t) { + // Drawn line by line in the item's standard font (`\n` splits lines). Without the face + // that is also how `draw` redraws an existing item, so it's checked that way. + return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi); + } + // What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it. + wrapped(t).into_iter().find_map(|(line, rtl, _)| { + let (pieces, _) = shaped_layout(t, &line, rtl).ok()?; + pieces.iter().find_map(|p| match p { + Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s), + Piece::Shaped(_) => None, + }) + }) +} + +/// Whether `t` has Arabic or Hebrew that its craft-fonts face draws (so `draw` lays it out with +/// [`draw_shaped`]). +fn shaped_by_a_face(t: &AddedText) -> bool { + (pdfcraft_fonts::document_arabic_font().is_some() && t.text.chars().any(is_arabic)) + || (pdfcraft_fonts::document_hebrew_font(false).is_some() && t.text.chars().any(is_hebrew)) +} + +/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped). +pub(super) fn shaped_arabic(c: char) -> bool { + is_arabic(c) && !is_bidi_control(c) +} + +/// Direction marks, embeddings, overrides and isolates: they steer the order and are not drawn. +fn is_bidi_control(c: char) -> bool { + matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') +} + +fn mirrored(c: char) -> char { + match c { + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '«' => '»', + '»' => '«', + other => other, + } +} + +/// A piece of a line with shaped text, in drawing order. +pub(super) enum Piece { + /// Shown in the item's own standard font. + Latin(String), + Shaped(Vec), +} + +/// A line of a shaped-text item in drawing order (the Unicode bidirectional algorithm, UAX #9), and +/// its width at the item's size. `rtl` is its paragraph's direction. +pub(super) fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f64), EditError> { + // unicode-bidi indexes the first level of a line, so an empty one would panic. + if line.is_empty() { + return Ok((Vec::new(), 0.0)); + } + let base = if rtl { Level::rtl() } else { Level::ltr() }; + let info = ParagraphBidiInfo::new(line, Some(base)); + let (levels, runs) = info.visual_runs(0..line.len()); + let mut pieces = Vec::new(); + for run in runs { + let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl); + // Runs lie on character boundaries; a missing slice is skipped, never a panic. + let Some(text) = line.get(run) else { continue }; + // Arabic and Hebrew letters take their face; so do spaces and punctuation between them + // (Hebrew's when they follow Hebrew), which the shaper then mirrors. Latin text (and what + // the faces lack) keeps the item's font. + let mut segments: Vec<(Option, String)> = Vec::new(); + for c in text.chars().filter(|c| !is_bidi_control(*c)) { + let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew)); + let script = if is_thai(c) { + Some(ShapedScript::Thai) + } else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) { + Some(ShapedScript::Hebrew) + } else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) { + Some(ShapedScript::Arabic) + } else { + None + }; + match segments.last_mut() { + Some((kind, s)) if *kind == script => s.push(c), + _ => segments.push((script, c.to_string())), + } + } + if run_rtl { + segments.reverse(); + } + for (script, s) in segments { + pieces.push(if let Some(script) = script { + let shaped = match script { + ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?, + ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?, + ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?, + }; + Piece::Shaped(shaped) + } else if run_rtl { + Piece::Latin(s.chars().rev().map(mirrored).collect()) + } else { + Piece::Latin(s) + }); + } + } + let width = pieces + .iter() + .map(|p| match p { + Piece::Latin(s) => latin_width(t, s), + // Each cluster is shown on its own, so the spacing follows each once. + Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * t.size + t.char_spacing).sum::(), + }) + .sum(); + Ok((pieces, width)) +} + +/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing +/// then reports why). +fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 { + shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| latin_width(t, s)) +} + +pub(super) fn arabic_error(e: GlyphError, text: &str) -> EditError { + EditError::Invalid(match e { + GlyphError::NoFont => "Arabic text needs PdfCraft's Arabic font, which this build doesn't include \ + (to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has an Arab face)" + .into(), + GlyphError::Missing => format!("the Arabic font can't show \"{text}\""), + GlyphError::TooComplex => format!("the Arabic font's glyphs for \"{text}\" are too complex"), + }) +} + +pub(super) fn hebrew_error(e: GlyphError, text: &str) -> EditError { + EditError::Invalid(match e { + GlyphError::NoFont => "Hebrew text needs PdfCraft's Hebrew font, which this build doesn't include \ + (to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has a Hebr face)" + .into(), + GlyphError::Missing => format!("the Hebrew font can't show \"{text}\""), + GlyphError::TooComplex => format!("the Hebrew font's glyphs for \"{text}\" are too complex"), + }) +} + +pub(super) fn thai_error(e: GlyphError, text: &str) -> EditError { + EditError::Invalid(match e { + GlyphError::NoFont => { + "Thai text needs a Thai font in this build; rebuild with CRAFT_FONTS_DIR pointing to craft-fonts with a Thai face".into() + } + GlyphError::Missing => format!("the Thai font can't show \"{text}\""), + GlyphError::TooComplex => "the Thai glyphs are too complex; use a shorter text box".into(), + }) +} + +/// The box a text item occupies (height from its lines). +pub fn text_rect(t: &AddedText) -> [f64; 4] { + let r = norm(t.rect); + let h = lines(t).len().max(1) as f64 * t.size * 1.2; + [r[0], r[3] - h, r[2], r[3]] +} + +/// How far each paste onto a page already holding the copy is offset, down and to the right, so a +/// copy doesn't hide what it was copied from (points). +pub const PASTE_STEP: f64 = 18.0; + +/// Where a pasted item of box `rect` goes on a page of `size` (display space): with its top-left +/// corner at `at`, else where it was, `steps` paste steps ([`PASTE_STEP`]) down and to the right. +/// It is kept on the page; one larger than the page keeps its left and top edges on it. A page +/// or point that isn't a number leaves the box where it was. +pub fn paste_rect(rect: [f64; 4], size: [f64; 2], steps: u32, at: Option<[f64; 2]>) -> [f64; 4] { + let [x0, y0, x1, y1] = norm(rect); + let (w, h) = (x1 - x0, y1 - y0); + let (left, top) = match at { + Some([x, y]) => (x, y), + None => { + let d = PASTE_STEP * f64::from(steps); + (x0 + d, y1 - d) + } + }; + if !size.iter().chain(&[left, top, w, h]).all(|v| v.is_finite()) { + return rect; + } + let left = left.min(size[0] - w).max(0.0); + let top = top.max(h).min(size[1]); + [left, top - h, left + w, top] +} + +/// Most paste steps [`paste_place`] tries before giving up on finding a free place. +const MAX_PASTE_STEPS: u32 = 64; + +/// Where Edit ▸ Paste puts `content` on a page of `size` already holding `items` (display space): +/// where it was, or as many paste steps down and to the right as it takes not to land exactly on +/// a copy of it (the original it was copied from, or an earlier paste). +pub fn paste_place<'a>(items: impl IntoIterator, content: &Content, size: [f64; 2]) -> [f64; 4] { + let same = copy_key(content); + let copies: Vec<[f64; 4]> = items.into_iter().filter(|i| copy_key(i) == same).map(Content::rect).collect(); + let taken = |rect: [f64; 4]| copies.iter().any(|c| c.iter().zip(rect).all(|(a, b)| (a - b).abs() < 0.5)); + let mut rect = paste_rect(content.rect(), size, 0, None); + for steps in 1..=MAX_PASTE_STEPS { + if !taken(rect) { + break; + } + rect = paste_rect(content.rect(), size, steps, None); + } + rect +} + +/// What makes two items copies of each other for [`paste_place`]: all but the box, and for an +/// image all but the object holding its picture, which a paste into another document stores anew. +fn copy_key(c: &Content) -> Content { + match c.with_rect([0.0; 4]) { + Content::Image(img) => Content::Image(AddedImage { image: ObjRef::new(0, 0), ..img }), + text => text, + } +} + +/// The widest explicit line before wrapping, measured with the PDF writing fonts. +pub fn text_width(t: &AddedText) -> f64 { + t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max) +} diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs new file mode 100644 index 0000000000..7dae55596f --- /dev/null +++ b/crates/edit/src/added/store.rs @@ -0,0 +1,308 @@ +use super::draw::{draw, params}; +use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error}; +use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG}; +use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, align_from, check, contents, page_list, place_tagged}; + +fn parse(doc: &Document, d: &Dict) -> Option { + let r = nums(doc, d.get(b"Rect")); + let rect: [f64; 4] = r.try_into().ok()?; + match d.name(b"Kind")? { + b"Text" => { + let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica")); + let c = nums(doc, d.get(b"Color")); + let text = d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + let align = align_from(d.int(b"Align")); + // At most one entry per paragraph, whatever the file says. + let aligns = d + .get(b"Aligns") + .and_then(|a| { + doc.resolve(a) + .as_array() + .map(|a| a.iter().take(crate::paragraph_align::count(&text)).map(|v| v.as_int().map(|c| align_from(Some(c)))).collect()) + }) + .unwrap_or_default(); + Some(Content::Text(AddedText { + rect, + text, + family, + bold, + italic, + size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0), + color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] }, + align, + aligns: crate::paragraph_align::tidy(aligns, align), + // Items written before character spacing have none. + char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0), + })) + } + b"Image" => { + let crop = nums(doc, d.get(b"Crop")); + Some(Content::Image(AddedImage { + rect, + image: d.get(b"Image")?.as_ref()?, + rotation: (d.int(b"Rotate").unwrap_or(0).rem_euclid(360) / 90) as u8, + flip_h: matches!(d.get(b"FlipH"), Some(Object::Bool(true))), + flip_v: matches!(d.get(b"FlipV"), Some(Object::Bool(true))), + crop: crop.try_into().unwrap_or([0.0; 4]), + })) + } + _ => None, + } +} + +fn with_resolved(doc: &Document, object: &Object, f: impl FnOnce(&Object) -> T) -> T { + match object { + Object::Ref(reference) => { + let resolved = doc.get(*reference); + f(&resolved) + } + direct => f(direct), + } +} + +fn for_each_content_entry(doc: &Document, page: &pdfcraft_model::Page, mut visit: impl FnMut(&Object)) { + let Some(contents) = page.dict.get(b"Contents") else { return }; + with_resolved(doc, contents, |resolved| match resolved { + Object::Array(entries) => { + for entry in entries { + visit(entry); + } + } + _ => visit(contents), + }); +} + +/// Finds page marks and editable added content in one pass over the page content entries. +pub fn scan_markers(doc: &Document) -> MarkerScan { + let mut scan = MarkerScan::default(); + for (pi, page) in page_list(doc).iter().enumerate() { + for_each_content_entry(doc, page, |entry| { + with_resolved(doc, entry, |resolved| { + let Some(dict) = resolved.as_dict() else { return }; + let tag = dict.name(b"PCMark"); + let kind = if tag == Some(&b"HeaderFooter"[..]) { + Some(MarkKind::HeaderFooter) + } else if tag == Some(&b"Watermark"[..]) { + Some(MarkKind::Watermark) + } else if tag == Some(&b"Background"[..]) { + Some(MarkKind::Background) + } else { + None + }; + if let Some(kind) = kind + && !scan.marks.contains(&kind) + { + scan.marks.push(kind); + } + + let Some(obj) = Object::as_ref(entry) else { return }; + if tag != Some(TAG.as_bytes()) { + return; + } + let Some(params_object) = dict.get(b"PCAdded") else { return }; + with_resolved(doc, params_object, |resolved_params| { + let Some(params) = resolved_params.as_dict() else { return }; + if let Some(content) = parse(doc, params) { + // Display points depend on the page's /UserUnit. An item records the one it was + // written for; none means 1, as for items written before display space followed + // /UserUnit. + let written = params + .get(b"UserUnit") + .and_then(Object::as_f64) + .filter(|u| u.is_finite() && *u >= 1.0) + .map_or(1.0, |u| u.min(pdfcraft_model::MAX_USER_UNIT)); + scan.added.push(Added { page: pi, obj, content: content.scaled(page.user_unit(doc) / written) }); + } + }); + }); + }); + } + scan +} + +fn validate(c: &Content) -> Result<(), EditError> { + let r = c.rect(); + if !r.iter().all(|v| v.is_finite()) { + return Err(EditError::Invalid("invalid position".into())); + } + match c { + Content::Text(t) => { + if t.text.trim().is_empty() { + return Err(EditError::Invalid("type some text first".into())); + } + if !(t.size.is_finite() && (1.0..=500.0).contains(&t.size)) { + return Err(EditError::Invalid("the font size must be between 1 and 500 points".into())); + } + if !(t.char_spacing.is_finite() && (-CHAR_SPACING_LIMIT..=CHAR_SPACING_LIMIT).contains(&t.char_spacing)) { + return Err(EditError::Invalid(format!( + "the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points" + ))); + } + if (r[2] - r[0]).abs() < 1.0 { + return Err(EditError::Invalid("the text box is too narrow".into())); + } + if t.text.chars().any(is_thai) && pdfcraft_fonts::document_thai_font().is_none() { + return Err(thai_error(GlyphError::NoFont, &t.text)); + } + // Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new + // item is refused with what's missing, and one that already holds Arabic stays movable. + // Everything else in it is drawn in the standard font, so it still has to fit. + let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() { + t.text.split('\n').find_map(|line| { + line.chars() + .find(|c| !(shaped_arabic(*c) || is_thai(*c) && pdfcraft_fonts::thai_has(*c) || pdfcraft_fonts::win_ansi_encodable(*c))) + }) + } else { + first_undrawable(t) + }; + if let Some(c) = undrawable { + return Err(crate::undrawable(c)); + } + } + Content::Image(_) => { + if (r[2] - r[0]).abs() < 1.0 || (r[3] - r[1]).abs() < 1.0 { + return Err(EditError::Invalid("the image is too small".into())); + } + } + } + Ok(()) +} + +/// Write an item's stream (new, or replacing `obj`) and make its resources available to the page. +fn write(doc: &mut Document, page: usize, c: &Content, obj: Option) -> Result { + validate(c)?; + let all = page_list(doc); + check(&[page], all.len())?; + let p = &all[page]; + let view = p.view_matrix(doc); + let user_unit = p.user_unit(doc); + // The page gets its own copy of its resources with the item's fonts and images added, and + // without the Arabic fonts of the version it replaces. + let mut pres = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + if let Some(r) = obj { + drop_fonts(doc, &mut pres, &own_fonts(doc, r)); + } + let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + let (content, res) = draw(doc, c, view, &taken, obj.is_some())?; + if obj.is_none() { + crate::check_on_top(doc, p)?; + } + for (k, v) in res.iter() { + let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default(); + for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) { + sub.set(n2.clone(), o.clone()); + } + pres.set(k.clone(), Object::Dict(sub)); + } + doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(pres)))?; + let mut sd = Dict::new(); + sd.set(b"PCMark".to_vec(), Object::name(TAG)); + let mut added = params(c); + if user_unit != 1.0 { + added.set(b"UserUnit".to_vec(), Object::Real(user_unit)); + } + sd.set(b"PCAdded".to_vec(), Object::Dict(added)); + let stream = Stream::flate(sd, &content); + match obj { + Some(r) => { + doc.set(r, Object::Stream(stream)); + Ok(r) + } + None => { + // place_tagged appends a new stream; recover its reference. + let p = page_list(doc).swap_remove(page); + place_tagged(doc, &p, TAG, content.clone(), false)?; + let p = page_list(doc).swap_remove(page); + let r = contents(doc, &p)?.last().and_then(Object::as_ref).ok_or_else(|| EditError::Invalid("could not add the content".into()))?; + doc.set(r, Object::Stream(stream)); + Ok(r) + } + } +} + +/// Every added item, page by page, in drawing order. +pub fn list_added(doc: &Document) -> Vec { + scan_markers(doc).added +} + +/// Add an item to a page (0-based). Returns its index among the page's added items. +pub fn add_content(doc: &mut Document, page: usize, c: &Content) -> Result { + write(doc, page, c, None)?; + Ok(list_added(doc).iter().filter(|a| a.page == page).count() - 1) +} + +fn find(doc: &Document, page: usize, index: usize) -> Result { + list_added(doc) + .into_iter() + .filter(|a| a.page == page) + .nth(index) + .ok_or_else(|| EditError::Invalid(format!("page {} has no added item {}", page + 1, index + 1))) +} + +/// Replace item `index` of `page` (moved, resized, retyped or reformatted). +pub fn update_content(doc: &mut Document, page: usize, index: usize, c: &Content) -> Result<(), EditError> { + let a = find(doc, page, index)?; + if std::mem::discriminant(&a.content) != std::mem::discriminant(c) { + return Err(EditError::Invalid("an item can't change between text and image".into())); + } + write(doc, page, c, Some(a.obj))?; + Ok(()) +} + +/// Remove item `index` of `page`. +pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { + let a = find(doc, page, index)?; + let p = page_list(doc).swap_remove(page); + let list: Vec = contents(doc, &p)?.into_iter().filter(|o| o.as_ref() != Some(a.obj)).collect(); + let fonts = own_fonts(doc, a.obj); + // Only the page's own resources carry an item's fonts (`write` gives the page a copy). + let res = match p.dict.get(b"Resources") { + Some(Object::Dict(d)) if !fonts.is_empty() => Some(d.clone()), + _ => None, + }; + let res = res.map(|mut r| { + drop_fonts(doc, &mut r, &fonts); + r + }); + doc.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(list)); + if let Some(r) = res { + d.set(b"Resources".to_vec(), Object::Dict(r)); + } + })?; + Ok(()) +} + +/// Leave only item `index` on `page`: its contents become the item's own stream and its +/// annotations go, so a render of the page (on a transparent backdrop) is the item's pixels +/// alone. For previews on a copy of the document; the page's resources stay as they are. +pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { + let a = find(doc, page, index)?; + let p = page_list(doc).swap_remove(page); + // An item's stream starts from the page's initial graphics state: `place_tagged` closes + // everything drawn before it, so it draws the same on its own. + doc.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(a.obj)])); + d.remove(b"Annots"); + })?; + Ok(()) +} + +/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped` +/// names them so), so they go when the item is replaced or deleted. +fn own_fonts(doc: &Document, obj: ObjRef) -> Vec> { + let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() }; + let Ok(data) = s.decoded() else { return Vec::new() }; + data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).map(<[u8]>::to_vec).collect() +} + +fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec]) { + if names.is_empty() { + return; + } + let Some(mut fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else { return }; + for name in names { + fonts.remove(name); + } + res.set(b"Font".to_vec(), Object::Dict(fonts)); +} diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs new file mode 100644 index 0000000000..0caaaa863b --- /dev/null +++ b/crates/edit/src/added/types.rs @@ -0,0 +1,223 @@ +use super::layout::norm; +use super::{Content, MarkKind, ObjRef, Std14}; + +pub(super) const TAG: &str = "Added"; + +/// The most character spacing an added text takes either way, in points: as far as the font size goes. +pub const CHAR_SPACING_LIMIT: f64 = 500.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Family { + #[default] + Helvetica, + Times, + Courier, +} + +impl Family { + pub fn label(self) -> &'static str { + match self { + Family::Helvetica => "Helvetica", + Family::Times => "Times", + Family::Courier => "Courier", + } + } + + /// The standard-14 font name for this family and style. + pub fn base_font(self, bold: bool, italic: bool) -> &'static str { + match (self, bold, italic) { + (Family::Helvetica, false, false) => "Helvetica", + (Family::Helvetica, true, false) => "Helvetica-Bold", + (Family::Helvetica, false, true) => "Helvetica-Oblique", + (Family::Helvetica, true, true) => "Helvetica-BoldOblique", + (Family::Times, false, false) => "Times-Roman", + (Family::Times, true, false) => "Times-Bold", + (Family::Times, false, true) => "Times-Italic", + (Family::Times, true, true) => "Times-BoldItalic", + (Family::Courier, false, false) => "Courier", + (Family::Courier, true, false) => "Courier-Bold", + (Family::Courier, false, true) => "Courier-Oblique", + (Family::Courier, true, true) => "Courier-BoldOblique", + } + } + + pub(super) fn from_base(base: &[u8]) -> (Family, bool, bool) { + let s = String::from_utf8_lossy(base); + let family = if s.starts_with("Times") { + Family::Times + } else if s.starts_with("Courier") { + Family::Courier + } else { + Family::Helvetica + }; + (family, s.contains("Bold"), s.contains("Italic") || s.contains("Oblique")) + } + + /// Advance width of `s` at `size` points, from the published standard-14 metrics. + /// + /// `italic` matters: the item is written out as the oblique/italic face (see [`Family::base_font`]), + /// whose published widths differ from the upright face (Times-Italic `A` is 611, Times-Roman 722). + pub fn width(self, s: &str, size: f64, bold: bool, italic: bool) -> f64 { + self.std14(bold, italic).text_width(s, size) + } + + /// The standard-14 face this family means at the given style. + pub fn std14(self, bold: bool, italic: bool) -> Std14 { + match (self, bold, italic) { + (Family::Courier, false, false) => Std14::Courier, + (Family::Courier, true, false) => Std14::CourierBold, + (Family::Courier, false, true) => Std14::CourierOblique, + (Family::Courier, true, true) => Std14::CourierBoldOblique, + (Family::Times, false, false) => Std14::TimesRoman, + (Family::Times, true, false) => Std14::TimesBold, + (Family::Times, false, true) => Std14::TimesItalic, + (Family::Times, true, true) => Std14::TimesBoldItalic, + (Family::Helvetica, false, false) => Std14::Helvetica, + (Family::Helvetica, true, false) => Std14::HelveticaBold, + (Family::Helvetica, false, true) => Std14::HelveticaOblique, + (Family::Helvetica, true, true) => Std14::HelveticaBoldOblique, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Align { + #[default] + Left, + Center, + Right, + /// Lines (but the last) stretched to the box's width. + Justify, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AddedText { + /// The text box in display space; its width wraps the text and its top is the first line's + /// top. The height follows the text. + pub rect: [f64; 4], + pub text: String, + pub family: Family, + pub bold: bool, + pub italic: bool, + pub size: f64, + pub color: [f64; 3], + pub align: Align, + /// Extra space after every character, in points: the PDF's character spacing (`Tc`). + pub char_spacing: f64, + /// Paragraphs (lines typed with Enter) aligned other than [`AddedText::align`], by index; + /// `None` and paragraphs past the end follow `align` ([`crate::paragraph_align`]). + pub aligns: Vec>, +} + +impl AddedText { + /// Paragraph `i`'s alignment. + pub fn paragraph_align(&self, i: usize) -> Align { + crate::paragraph_align::align_of(&self.aligns, i, self.align) + } +} + +impl Default for AddedText { + fn default() -> Self { + AddedText { + rect: [0.0; 4], + text: String::new(), + family: Family::Helvetica, + bold: false, + italic: false, + size: 12.0, + color: [0.0; 3], + align: Align::Left, + aligns: Vec::new(), + char_spacing: 0.0, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AddedImage { + /// Display space. + pub rect: [f64; 4], + /// The image XObject. + pub image: ObjRef, + /// Quarter turns counter-clockwise (0–3). + pub rotation: u8, + pub flip_h: bool, + pub flip_v: bool, + /// The fraction trimmed from each side of the image: left, bottom, right, top (see + /// [`AddedImage::crop_valid`]). + pub crop: [f64; 4], +} + +impl AddedImage { + pub fn new(rect: [f64; 4], image: ObjRef) -> Self { + AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] } + } + + /// The least of the image's width and of its height that a crop keeps. + pub const MIN_CROP_KEEP: f64 = 0.05; + + /// How far a sum of two sides may pass the most a crop trims: `0.05 + 0.9` is + /// `0.9500000000000001` in floating point and still keeps 5 %. + const CROP_ROUNDING: f64 = 1e-9; + + /// Whether `crop` trims every side by a fraction from 0 to under 1 and keeps at least + /// [`AddedImage::MIN_CROP_KEEP`] across each axis: one side may trim 95 % when its opposite + /// trims nothing. + pub fn crop_valid(crop: [f64; 4]) -> bool { + let [l, b, r, t] = crop; + let most = 1.0 - Self::MIN_CROP_KEEP; + crop.iter().all(|v| v.is_finite() && (0.0..1.0).contains(v)) && l + r <= most + Self::CROP_ROUNDING && b + t <= most + Self::CROP_ROUNDING + } + + /// `crop` brought into [`AddedImage::crop_valid`] range, for one read from a file: each side + /// in range, and an axis that trims too much keeps its two sides' proportion. + pub fn clamp_crop(crop: [f64; 4]) -> [f64; 4] { + let most = 1.0 - Self::MIN_CROP_KEEP; + let [l, b, r, t] = crop.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + // Scaled from the halves, whose sum can't overflow, so the pair keeps its proportion; + // a pair within the limit keeps each side within it too. + let fit = |a: f64, c: f64| { + let half = a / 2.0 + c / 2.0; + if half > (most + Self::CROP_ROUNDING) / 2.0 { (a / 2.0 / half * most, c / 2.0 / half * most) } else { (a, c) } + }; + let ((l, r), (b, t)) = (fit(l, r), fit(b, t)); + [l, b, r, t] + } + + /// The matrix from image space (the unit square) to display space: crop, flip, rotate, + /// then fill the box. + pub(super) fn matrix(&self) -> pdfcraft_content::Matrix { + use pdfcraft_content::Matrix as M; + let [l, b, r, t] = Self::clamp_crop(self.crop); + let (cw, ch) = (1.0 - l - r, 1.0 - b - t); + let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]); + if self.flip_h { + m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0])); + } + if self.flip_v { + m = m.then(&M([1.0, 0.0, 0.0, -1.0, 0.0, 1.0])); + } + for _ in 0..self.rotation % 4 { + // A quarter turn counter-clockwise within the unit square. + m = m.then(&M([0.0, 1.0, -1.0, 0.0, 1.0, 0.0])); + } + let [x0, y0, x1, y1] = norm(self.rect); + m.then(&M([x1 - x0, 0.0, 0.0, y1 - y0, x0, y0])) + } +} + +/// An added item on a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Added { + pub page: usize, + /// The item's content stream. + pub obj: ObjRef, + pub content: Content, +} + +/// PdfCraft-owned page content discovered in one page/content traversal. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MarkerScan { + pub marks: Vec, + pub added: Vec, +} diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs new file mode 100644 index 0000000000..ff570d5483 --- /dev/null +++ b/crates/edit/src/apply.rs @@ -0,0 +1,301 @@ +use super::marks::{ + Background, Context, HeaderFooter, MarkKind, Watermark, add_picture_resource, bates_start, expand, matrix_number, n, picture, rgb, shrink_matrix, +}; +use super::place::{ + END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place, + place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role, +}; +use super::{Document, EditError, Object, added, backdrop, helvetica_width}; + +/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on +/// those pages are removed first (Acrobat's Replace Existing). +pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> { + let all = page_list(doc); + check(pages, all.len())?; + if hf.text.iter().all(|t| t.trim().is_empty()) { + return Err(EditError::Invalid("type the header or footer text first".into())); + } + if let Some(c) = hf.first_undrawable() { + return Err(undrawable(c)); + } + if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) { + return Err(EditError::Invalid("invalid font size or margins".into())); + } + // Page numbers count up from start_number, so the last selected page needs the most room. + // Refuse the whole run before drawing anything: headers applied to some pages and then a + // failed page number would leave the document half changed. + let last_number = u64::from(hf.start_number) + .checked_add(pages.len().saturating_sub(1) as u64) + .ok_or_else(|| EditError::Invalid("the header page numbers are too large".into()))?; + if last_number > u64::from(u32::MAX) { + return Err(EditError::Invalid(format!( + "the header start number {} leaves no room for {} pages (page numbers stop at {})", + hf.start_number, + pages.len(), + u32::MAX + ))); + } + // The same goes for Bates numbers: the last selected page must still fit in a u64. + let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1); + if bates0.checked_add(pages.len().saturating_sub(1) as u64).is_none() { + return Err(EditError::Invalid(format!( + "the Bates start number {bates0} leaves no room for {} pages (Bates numbers stop at {})", + pages.len(), + u64::MAX + ))); + } + // Shrinking needs room on every page: refuse before anything is drawn or replaced. + let shrinks = pages + .iter() + .map(|&i| hf.shrink.then(|| shrink_matrix(hf, all[i].display_size(doc), all[i].view_matrix(doc))).transpose()) + .collect::, _>>()?; + for &i in pages { + check_on_top(doc, &all[i])?; + } + if replace { + remove_marks(doc, pages, MarkKind::HeaderFooter)?; + } + let all = page_list(doc); + let count = all.len(); + for (k, &i) in pages.iter().enumerate() { + let page = &all[i]; + let (w, h) = page.display_size(doc); + let number = + u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?; + let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?; + let size = hf.font_size; + let [top, bottom, left, right] = hf.margins; + // The page resource the face is added under: the usual name, unless the page already + // draws another font by it, which must not change under the header. + let preferred = match hf.font { + added::Family::Helvetica => "PCHelv", + added::Family::Times => "PCTimes", + added::Family::Courier => "PCCour", + }; + let base = hf.font.base_font(false, false); + let resource = free_font_name(doc, &page_fonts(doc, page), preferred, base); + let mut body: Vec = format!("BT\n/{resource} {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes(); + let mut underlines = String::new(); + for (slot, template) in hf.text.iter().enumerate() { + if template.trim().is_empty() { + continue; + } + let lines: Vec = expand(template, number, count, bates, cx).lines().map(str::to_owned).collect(); + let header = slot < 3; + let line_h = size * 1.2; + for (li, line) in lines.iter().enumerate() { + // Centred and right-aligned text is placed by its width in the chosen face. + let tw = hf.font.width(line, size, false, false); + let x = match slot % 3 { + 0 => left, + 1 => (w - tw) / 2.0, + _ => w - right - tw, + }; + // Headers hang below the top margin; footers sit on the bottom margin. + let y = if header { h - top - size * 0.8 - li as f64 * line_h } else { bottom + (lines.len() - 1 - li) as f64 * line_h }; + body.extend(text_op(x, y, line)); + if hf.underline { + underlines.push_str(&format!("{} {} {} {} re f\n", n(x), n(y - size * 0.15), n(tw), n((size * 0.06).max(0.4)))); + } + } + } + body.extend_from_slice(b"ET\n"); + if !underlines.is_empty() { + body.extend(format!("{}\n{underlines}", rgb(hf.color)).bytes()); + } + let mut content = begin(MarkKind::HeaderFooter, "Header", page.view_matrix(doc)).into_bytes(); + content.extend(body); + content.extend_from_slice(END.as_bytes()); + // A name already there is that very font (see `free_font_name`): left as it is. + if !page_fonts(doc, page).contains(resource.as_bytes()) { + add_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?; + } + let page = &page_list(doc)[i]; + place(doc, page, MarkKind::HeaderFooter, content, false)?; + if let Some(Some(m)) = shrinks.get(k) { + let cm = format!("{} cm\n", m.map(matrix_number).join(" ")); + let page = &page_list(doc)[i]; + place_inside_wrapper(doc, page, MarkKind::HeaderFooter.tag(), cm.into_bytes())?; + } + } + Ok(()) +} + +/// Add a text watermark to `pages`. +pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> { + let all = page_list(doc); + check(pages, all.len())?; + let text = wm.text.trim(); + if text.is_empty() && wm.source.is_none() { + return Err(EditError::Invalid("type the watermark text first".into())); + } + if let Some(c) = wm.first_undrawable() { + return Err(undrawable(c)); + } + if !(wm.opacity.is_finite() + && wm.rotation.is_finite() + && wm.font_size.is_finite() + && wm.font_size >= 0.0 + && wm.offset.iter().all(|o| o.is_finite())) + { + return Err(EditError::Invalid("invalid watermark settings".into())); + } + if !wm.behind { + for &i in pages { + check_on_top(doc, &all[i])?; + } + } + if replace { + remove_marks(doc, pages, MarkKind::Watermark)?; + } + let opacity = wm.opacity.clamp(0.0, 1.0); + let lines: Vec<&str> = text.lines().collect(); + for &i in pages { + let page = page_list(doc)[i].clone(); + let (w, h) = page.display_size(doc); + if let Some(src) = &wm.source { + let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc)); + content.push_str(&format!("/PCGS{} gs\n", (opacity * 100.0).round() as i64)); + content.push_str(&picture(src, (w, h), wm.scale, wm.rotation, wm.offset)); + content.push_str(END); + add_resources(doc, &page, Some(opacity), None)?; + add_picture_resource(doc, &page_list(doc)[i].clone(), src)?; + let page = &page_list(doc)[i]; + place(doc, page, MarkKind::Watermark, content.into_bytes(), wm.behind)?; + continue; + } + let widest = lines.iter().map(|l| helvetica_width(l, 1.0)).fold(0.0, f64::max).max(0.01); + let size = if wm.font_size > 0.0 { wm.font_size } else { ((w * w + h * h).sqrt() * 0.5 / widest).clamp(6.0, 300.0) }; + let (s, c) = wm.rotation.to_radians().sin_cos(); + let (cx, cy) = (w / 2.0 + wm.offset[0], h / 2.0 + wm.offset[1]); + let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc)).into_bytes(); + content.extend( + format!( + "/PCGS{} gs\n{} {} {} {} {} {} cm\nBT\n/PCHelv {} Tf\n{}\n", + (opacity * 100.0).round() as i64, + n(c), + n(s), + n(-s), + n(c), + n(cx), + n(cy), + n(size), + rgb(wm.color) + ) + .bytes(), + ); + let line_h = size * 1.15; + let total = line_h * (lines.len() as f64 - 1.0); + for (li, line) in lines.iter().enumerate() { + let tw = helvetica_width(line, size); + content.extend(text_op(-tw / 2.0, total / 2.0 - li as f64 * line_h - size * 0.35, line)); + } + content.extend_from_slice(b"ET\n"); + content.extend_from_slice(END.as_bytes()); + add_resources(doc, &page, Some(opacity), None)?; + let page = &page_list(doc)[i]; + place(doc, page, MarkKind::Watermark, content, wm.behind)?; + } + Ok(()) +} + +/// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a +/// white backdrop a page paints itself is replaced first, so the background shows (see +/// [`backdrop`]); a picture backdrop (a scan) still hides it. +pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { + let all = page_list(doc); + check(pages, all.len())?; + if replace { + remove_marks(doc, pages, MarkKind::Background)?; + } + let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 }; + for &i in pages { + let mut page = page_list(doc)[i].clone(); + if bg.replace_backdrop && backdrop::replace(doc, &page)? { + page = page_list(doc)[i].clone(); + } + let (w, h) = page.display_size(doc); + let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc)); + match &bg.source { + Some(src) => { + content.push_str(&format!("/PCGS{} gs\n", (opacity * 100.0).round() as i64)); + content.push_str(&picture(src, (w, h), bg.scale, 0.0, [0.0; 2])); + } + None => content.push_str(&format!("/PCGS{} gs\n{}\n0 0 {} {} re f\n", (opacity * 100.0).round() as i64, rgb(bg.color), n(w), n(h))), + } + content.push_str(END); + add_resources(doc, &page, Some(opacity), None)?; + if let Some(src) = &bg.source { + add_picture_resource(doc, &page_list(doc)[i].clone(), src)?; + } + let page = &page_list(doc)[i]; + place(doc, page, MarkKind::Background, content.into_bytes(), true)?; + } + Ok(()) +} + +/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with +/// nothing after them go too, restoring the original content list; a wrapper that content +/// after it still relies on (another tool's appended stream) stays. Removing backgrounds also +/// turns a backdrop [`add_background`] replaced back on where that is still safe; the pages where +/// it isn't are [`backdrops_left_off`]. +pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result { + let all = page_list(doc); + check(pages, all.len())?; + let mut removed = 0; + for &i in pages { + let page = &all[i]; + if kind == MarkKind::Background { + backdrop::restore(doc, page)?; + } + let list = contents(doc, page)?; + let before = list.len(); + let mut kept: Vec = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect(); + removed += before - kept.len(); + // Strip only wrapper pairs whose close ends the list (after stripping the pairs after + // it): nothing comes after them to need the state they restore. Pairs come from the + // operators; for content that can't be read that precisely, from the wrapper streams + // alone, so updating a mark there doesn't nest another wrapper each time. + if before != kept.len() { + let wrappers = match isolation(doc, &kept) { + Isolation::Clean { wrappers, .. } => wrappers, + Isolation::Unknown { .. } | Isolation::TooDeep => { + let mut opens = Vec::new(); + let mut pairs = Vec::new(); + for (i, o) in kept.iter().enumerate() { + match wrapper_role(doc, o) { + Some(true) => opens.push(i), + Some(false) => pairs.extend(opens.pop().map(|open| (open, i))), + None => {} + } + } + pairs + } + }; + let pairs: std::collections::HashMap = wrappers.into_iter().map(|(open, close)| (close, open)).collect(); + let mut discarded = std::collections::HashSet::new(); + let mut end = kept.len(); + while let Some(close) = end.checked_sub(1) { + if discarded.contains(&close) { + end = close; + } else if let Some(&open) = pairs.get(&close) { + discarded.insert(open); + discarded.insert(close); + end = close; + } else { + break; + } + } + kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect(); + } + if before != kept.len() { + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?; + } + } + Ok(removed) +} + +/// Which kinds of marks the document has (for enabling Update/Remove). +pub fn marks_present(doc: &Document) -> Vec { + added::scan_markers(doc).marks +} diff --git a/crates/edit/src/backdrop.rs b/crates/edit/src/backdrop.rs new file mode 100644 index 0000000000..43e16b40ec --- /dev/null +++ b/crates/edit/src/backdrop.rs @@ -0,0 +1,584 @@ +//! A page's own backdrop: an opaque white fill (or a picture) covering the whole visible page +//! before anything else is drawn. Many writers paint one (browsers' Save as PDF, print drivers; +//! scans are a page-sized picture), and it hides anything placed behind the content, so a +//! background added behind such a page never shows. +//! +//! A white fill can be replaced: the one fill operator becomes `n` (end the path without +//! painting), which looks the same on white paper. The page then gets a copy of the content +//! stream that held it, tagged `/PCBackdrop << /Index n /Op (f) /Before /Was [num gen] >>`: +//! how many of the copy's operators come before the `n`, the operator it stands in for, a hash of +//! those operators (their values, not their spacing: redaction writes a stream out again with +//! other whitespace), and the number of the stream it was copied from. The stream itself is left +//! alone (other pages may share it). [`restore`] turns the fill back on when the background is +//! removed, even after the page was edited or redacted, as long as the operators before the fill +//! are unchanged and the fill would again be the first thing painted, covering the page. +//! Otherwise the fill stays off, and [`backdrops_left_off`] names those pages. A picture can't be +//! replaced without losing it, so it is only reported. +//! +//! The tag holds no page content and no reference that keeps another object alive (`/Was` is two +//! numbers, not a reference): a reference to the original stream would keep text in the file +//! that a redaction, Remove Hidden Information or Edit Text removed from the copy. +//! +//! Detection is deliberately strict: only one rectangle (`re`), in DeviceGray, DeviceRGB or +//! DeviceCMYK, opaque, unmasked, normally blended and with the identity transfer function as the +//! graphics state stands after every `gs` so far (each sets only its own keys), with an +//! axis-aligned transform, filled before any other painting, and covering the crop box. +//! Anything else is real content and stays. + +use pdfcraft_content::{Matrix, Op, Pieces}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream}; +use sha2::{Digest, Sha256}; + +use crate::{EditError, MarkKind}; + +/// The dictionary key of a backdrop copy's tag. +const TAG: &[u8] = b"PCBackdrop"; +/// The fill operators a backdrop can be painted with, and so the only ones a tag may restore. +const FILLS: [&[u8]; 3] = [b"f", b"F", b"f*"]; + +/// How much of a page's content is read looking for a backdrop: it has to come first. +const SCAN_BYTES: usize = 64 * 1024; +/// How many operators are walked before giving up. +const SCAN_OPS: usize = 4096; +/// How far (in points) a backdrop may fall short of the crop box and still count as covering it. +const COVER_EPS: f64 = 1.0; +/// Components at least this close to white (per channel) count as white: a near-white fill +/// (`0.99 g`) is a backdrop too, and replacing it changes the page by at most 2%. +const WHITE: f64 = 0.98; + +/// What paints a page's backdrop. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Backdrop { + /// An opaque white fill: replaceable. + Fill, + /// A picture covering the page (a scan): a background behind it stays hidden. + Image, +} + +/// Where a replaceable fill is: the content streams (references in `/Contents` order, untagged +/// ones only), their joined bytes, where each starts in them, and the fill operator's joined +/// byte range. +struct Found { + refs: Vec<(usize, ObjRef)>, + pieces: Pieces, + starts: Vec, + op: std::ops::Range, +} + +impl Found { + /// The piece holding the fill operator, and the operator's offset in it. + fn place(&self) -> Option<(usize, usize)> { + let piece = self.pieces.piece_of(self.op.start); + Some((piece, self.op.start.checked_sub(*self.starts.get(piece)?)?)) + } +} + +#[derive(Clone)] +struct State { + ctm: Matrix, + /// Clip in default user space, when it is a known rectangle; `None` = unknown shape. + clip: Option<[f64; 4]>, + /// The fill colour is white. + white: bool, + /// Non-stroking colour space components (1, 3, 4), or 0 for a space we don't read. + space: usize, + paint: Paint, +} + +/// The graphics-state parameters (ExtGState, ISO 32000-2 §8.4.5) that decide whether a fill +/// paints its colour as given. A `gs` sets only the keys its dictionary has, so each is kept on +/// its own: a later dictionary that leaves one out keeps the earlier value. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Paint { + /// Non-stroking alpha (`ca`) is 1. + full_alpha: bool, + /// No soft mask (`SMask` is `/None`). + no_soft_mask: bool, + /// The blend mode (`BM`) is Normal or Compatible. + normal_blend: bool, + /// The transfer function (`TR`, or `TR2`, which takes precedence) is the identity: a + /// function could turn white into any colour. + identity_transfer: bool, +} + +impl Paint { + const INITIAL: Paint = Paint { full_alpha: true, no_soft_mask: true, normal_blend: true, identity_transfer: true }; + + /// A fill paints its colour exactly, opaque. + fn plain(self) -> bool { + self.full_alpha && self.no_soft_mask && self.normal_blend && self.identity_transfer + } +} + +/// The backdrop `page` (0-based) paints, if any. Finding the page walks the page tree, so to +/// check many pages use [`page_backdrops`], which walks it once. +pub fn page_backdrop(doc: &Document, page: usize) -> Result, EditError> { + let all = pdfcraft_model::pages(doc); + let p = all.get(page).ok_or(EditError::NoSuchPage(page))?; + Ok(scan(doc, p, None).map(|(b, _)| b)) +} + +/// The backdrop each of `pages` (0-based) paints, in the same order, walking the page tree once. +/// A page past the end has none, and doesn't hide what the others paint: a page count taken +/// elsewhere (the viewer's) can disagree with the page tree's on a malformed document. +pub fn page_backdrops(doc: &Document, pages: &[usize]) -> Vec> { + let all = pdfcraft_model::pages(doc); + pages.iter().map(|&p| all.get(p).and_then(|page| scan(doc, page, None)).map(|(b, _)| b)).collect() +} + +/// Pages (0-based, ascending) whose own white backdrop [`replace`] turned off and [`restore`] +/// couldn't turn back on: a tagged copy is still in their content, with no background over it. +/// They look the same on white paper. +pub fn backdrops_left_off(doc: &Document) -> Vec { + let tag_of = |o: &Object| crate::place::stream_tag(doc, o); + let copy = |o: &Object| doc.resolve(o).as_dict().is_some_and(|d| d.get(TAG).is_some()); + pdfcraft_model::pages(doc) + .iter() + .enumerate() + .filter(|(_, p)| { + let list: Vec = match p.dict.get(b"Contents").map(|c| doc.resolve(c)).as_deref() { + Some(Object::Array(a)) => a.clone(), + Some(_) => p.dict.get(b"Contents").cloned().into_iter().collect(), + None => Vec::new(), + }; + list.iter().any(copy) && !list.iter().any(|o| tag_of(o).as_deref() == Some(MarkKind::Background.tag())) + }) + .map(|(i, _)| i) + .collect() +} + +/// Replace `page`'s white backdrop fill, if it has one (see the module doc). Returns whether it +/// did; when it did, `page` no longer matches the document. A picture backdrop is left alone. +pub(crate) fn replace(doc: &mut Document, page: &pdfcraft_model::Page) -> Result { + let Some((Backdrop::Fill, Some(found))) = scan(doc, page, None) else { return Ok(false) }; + let Some((piece, at)) = found.place() else { return Ok(false) }; + let op = found.pieces.data().get(found.op.clone()).unwrap_or_default().to_vec(); + let edited = found.pieces.splice([(found.op.clone(), b"n".to_vec())]); + let (Some(&(_, original)), Some(bytes)) = (found.refs.get(piece), edited.get(piece)) else { return Ok(false) }; + // The `n` among the copy's own operators. A copy whose fill can't be found that way again is + // not made: there would be no safe way back. + let parsed = pdfcraft_content::parse(head(bytes)); + let Some(index) = parsed.ops.iter().position(|o| o.span.start == at && o.op == b"n") else { return Ok(false) }; + let Some(before) = parsed.ops.get(..index) else { return Ok(false) }; + let mut note = Dict::new(); + note.set(b"Index".to_vec(), Object::Int(i64::try_from(index).unwrap_or(i64::MAX))); + note.set(b"Op".to_vec(), Object::String(PdfString::literal(op))); + note.set(b"Before".to_vec(), Object::String(PdfString { bytes: ops_hash(before), hex: true })); + note.set(b"Was".to_vec(), Object::Array(vec![Object::Int(original.num.into()), Object::Int(original.generation.into())])); + // The copy keeps the original's dictionary (unknown entries included) less what describes + // the old encoding; `scan` refused streams whose bytes live in an external file (`/F`). + let Object::Stream(source) = &*doc.get(original) else { return Ok(false) }; + let mut dict = source.dict.clone(); + for key in [&b"Length"[..], b"DL", TAG] { + dict.remove(key); + } + dict.set(TAG.to_vec(), Object::Dict(note)); + let copy = doc.add(Object::Stream(Stream::flate(dict, bytes))); + // scan accepts only one execution of this stream on the page. Other pages keep the + // original stream; ambiguous repeated executions are never rewritten. + let mut list = crate::place::contents(doc, page)?; + let mut replaced = false; + for entry in list.iter_mut().filter(|o| matches!(o, Object::Ref(r) if *r == original)) { + *entry = Object::Ref(copy); + replaced = true; + } + if replaced { + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + } + Ok(replaced) +} + +/// Turn the fills [`replace`] turned off on `page` back on, where [`restored_copy`] finds that +/// safe; a copy listed in several slots is restored in each. Returns how many slots it restored. +/// A copy it can't restore stays as it is, tag and all, so [`backdrops_left_off`] names its page. +pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result { + let mut list = crate::place::contents(doc, page)?; + // Per copy: the stream that takes its place, or `None` when it stays. + let mut done: Vec<(ObjRef, Option)> = Vec::new(); + let mut restored = 0; + for entry in list.iter_mut() { + let Object::Ref(copy) = *entry else { continue }; + let with = match done.iter().find(|(c, _)| *c == copy) { + Some(&(_, with)) => with, + None => { + let with = restored_copy(doc, page, copy).map(|(bytes, dict, was)| { + // The stream it was copied from, while that is still here with these bytes + // (other pages may share it): the page is then exactly as it was. `/Was` is + // only a number, and a full save renumbers objects, so the bytes decide. + let same = |w: &ObjRef| match &*doc.get(*w) { + Object::Stream(s) => { + [TAG, &b"PCMark"[..], b"Subtype"].iter().all(|k| s.dict.get(k).is_none()) + && DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok().as_deref() == Some(bytes.as_slice()) + } + _ => false, + }; + match was.filter(same) { + Some(w) => w, + None => doc.add(Object::Stream(Stream::flate(dict, &bytes))), + } + }); + done.push((copy, with)); + with + } + }; + if let Some(with) = with { + *entry = Object::Ref(with); + restored += 1; + } + } + if restored > 0 { + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + } + Ok(restored) +} + +/// `copy` (a stream [`replace`] wrote for `page`) with its fill turned back on: the bytes, its +/// dictionary without the tag, and the stream it was copied from. `None` when it isn't such a +/// copy, or when turning the fill on could paint over something: the operators before the fill +/// changed since (their hash no longer matches), or the page would no longer paint the fill +/// first, covering it. What comes after the fill (an edit, a redaction) is kept as it is. +fn restored_copy(doc: &Document, page: &pdfcraft_model::Page, copy: ObjRef) -> Option<(Vec, Dict, Option)> { + let obj = doc.get(copy); + let Object::Stream(s) = &*obj else { return None }; + let note = s.dict.get(TAG).map(|n| doc.resolve(n))?; + let note = note.as_dict()?; + let index = usize::try_from(note.get(b"Index")?.as_int()?).ok()?; + let op = note.get(b"Op")?.as_string()?.bytes.as_slice(); + if !FILLS.contains(&op) { + return None; + } + let hash = note.get(b"Before")?.as_string()?; + let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok()?; + let parsed = pdfcraft_content::parse(head(&bytes)); + // The `n` standing in for the fill, after exactly the operators that came before it. + let fill = parsed.ops.get(index)?; + if fill.op != b"n" || !fill.operands.is_empty() || ops_hash(parsed.ops.get(..index)?) != hash.bytes { + return None; + } + let mut out = Vec::with_capacity(bytes.len().saturating_add(op.len())); + out.extend_from_slice(bytes.get(..fill.span.start)?); + out.extend_from_slice(op); + out.extend_from_slice(bytes.get(fill.span.end..)?); + let (Backdrop::Fill, Some(found)) = scan(doc, page, Some((copy, &out)))? else { return None }; + let (piece, offset) = found.place()?; + if found.refs.get(piece).map(|&(_, r)| r) != Some(copy) || offset != fill.span.start || found.op.len() != op.len() { + return None; + } + let mut dict = s.dict.clone(); + for key in [&b"Length"[..], b"DL", TAG] { + dict.remove(key); + } + let was = match note.get(b"Was").and_then(Object::as_array).map(Vec::as_slice) { + Some([num, generation]) => Some(ObjRef { num: u32::try_from(num.as_int()?).ok()?, generation: u16::try_from(generation.as_int()?).ok()? }), + _ => None, + }; + Some((out, dict, was)) +} + +/// As much of a content stream as a backdrop could be in: it has to come first. +fn head(bytes: &[u8]) -> &[u8] { + bytes.get(..bytes.len().min(SCAN_BYTES)).unwrap_or_default() +} + +/// A SHA-256 of what `ops` are, not how they are written: keywords and operand values, with +/// numbers in one form (`1`, `1.0` and `1.00000` are the same), so a stream written out again +/// with other spacing or number formatting still matches. +fn ops_hash(ops: &[Op]) -> Vec { + let mut h = Sha256::new(); + for op in ops { + for o in &op.operands { + canonical(o, &mut h, 0); + } + if let Some((dict, data)) = &op.inline { + canonical_dict(dict, &mut h, 0); + bytes(b'i', data, &mut h); + } + bytes(b'k', &op.op, &mut h); + } + h.finalize().to_vec() +} + +/// How deep [`canonical`] follows nested arrays and dictionaries; deeper ones count as equal. +const CANONICAL_DEPTH: usize = 32; + +fn canonical(o: &Object, h: &mut Sha256, depth: usize) { + if depth > CANONICAL_DEPTH { + h.update(b"~"); + return; + } + match o { + Object::Null => h.update(b"z"), + Object::Bool(b) => h.update(if *b { b"T" } else { b"F" }), + Object::Int(i) => number(&i.to_string(), h), + Object::Real(r) if *r == 0.0 => number("0", h), + Object::Real(r) => number(&format!("{r:.5}"), h), + Object::String(s) => bytes(b's', &s.bytes, h), + Object::Name(n) => bytes(b'/', n, h), + Object::Array(a) => { + h.update(b"["); + for x in a { + canonical(x, h, depth.saturating_add(1)); + } + h.update(b"]"); + } + Object::Dict(d) => canonical_dict(d, h, depth), + Object::Stream(_) | Object::Ref(_) => h.update(b"?"), + } +} + +fn canonical_dict(d: &Dict, h: &mut Sha256, depth: usize) { + h.update(b"<"); + for (k, v) in d.iter() { + bytes(b'/', k, h); + canonical(v, h, depth.saturating_add(1)); + } + h.update(b">"); +} + +/// A number written with up to five decimals, without trailing zeros. +fn number(text: &str, h: &mut Sha256) { + let t = if text.contains('.') { text.trim_end_matches('0').trim_end_matches('.') } else { text }; + bytes(b'#', t.as_bytes(), h); +} + +/// A typed, length-prefixed value, so no two different sequences hash the same way. +fn bytes(kind: u8, b: &[u8], h: &mut Sha256) { + h.update([kind]); + h.update(u64::try_from(b.len()).unwrap_or(u64::MAX).to_le_bytes()); + h.update(b); +} + +/// Find the backdrop, and for a fill, where it is. With `swap`, that stream reads as those bytes +/// instead of its own (to check a change before making it). +fn scan(doc: &Document, p: &pdfcraft_model::Page, swap: Option<(ObjRef, &[u8])>) -> Option<(Backdrop, Option)> { + let crop = p.crop(doc); + let c = p.dict.get(b"Contents")?; + let list: Vec = match &*doc.resolve(c) { + Object::Array(a) if a.len() <= SCAN_OPS => a.clone(), + Object::Array(_) => return None, + _ => vec![c.clone()], + }; + // The page's own content: untagged streams (marks and the q/Q wrapper carry /PCMark). Read + // only as far as a backdrop could be. + let mut refs = Vec::new(); + let mut data = Vec::new(); + let mut starts = Vec::new(); + let mut total = 0usize; + let mut budget = DecodeBudget::new(SCAN_BYTES, 0); + for (slot, o) in list.iter().enumerate() { + if total >= SCAN_BYTES { + break; + } + let Object::Ref(r) = o else { return None }; + let obj = doc.get(*r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.get(b"PCMark").is_some() { + continue; + } + // Bytes in an external file (`/F`) replace the stream's own: what it draws is unknown here. + if s.dict.get(b"F").is_some() { + return None; + } + let bytes = match swap { + Some((with, b)) if with == *r => { + if b.len() > SCAN_BYTES.saturating_sub(total) { + return None; + } + budget.decode_strict(&Stream::from_raw(Dict::new(), b.to_vec())).ok()? + } + // Tolerant decoding can return an empty or partial stream after corruption. + // That cannot prove which paint preceded the candidate backdrop. + _ => budget.decode_strict(s).ok()?, + }; + if bytes.len() > SCAN_BYTES.saturating_sub(total).saturating_sub(1) { + return None; + } + starts.push(total); + total += bytes.len() + 1; + refs.push((slot, *r)); + data.push(bytes); + } + let pieces = Pieces::join(&data); + let parsed = pdfcraft_content::parse(head(pieces.data())); + let res = resources(doc, &p.dict); + let found = walk(doc, &res, crop, &parsed.ops); + // A match proves the graphics state for one execution only. Repeated references may + // paint real artwork under a different color or transform: leave them untouched. + if let Some((Backdrop::Fill, Some(op))) = &found { + let piece = pieces.piece_of(op.start); + let (_, reference) = refs.get(piece)?; + if list.iter().filter(|o| matches!(o, Object::Ref(r) if r == reference)).count() != 1 { + return None; + } + } + found.map(|(b, op)| match (b, op) { + (Backdrop::Fill, Some(op)) => (b, Some(Found { refs, pieces, starts, op })), + _ => (b, None), + }) +} + +fn resources(doc: &Document, page: &Dict) -> Dict { + page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default() +} + +fn sub(doc: &Document, res: &Dict, kind: &[u8], name: &[u8]) -> Option> { + let d = res.get(kind).map(|d| doc.resolve(d))?; + let o = d.as_dict()?.get(name)?.clone(); + Some(doc.resolve(&o)) +} + +/// Walk the operators until the first one that paints; a backdrop is that one, or nothing. +fn walk(doc: &Document, res: &Dict, crop: [f64; 4], ops: &[Op]) -> Option<(Backdrop, Option>)> { + let mut st = State { ctm: Matrix::IDENTITY, clip: Some([f64::MIN, f64::MIN, f64::MAX, f64::MAX]), white: false, space: 1, paint: Paint::INITIAL }; + let mut saved: Vec = Vec::new(); + // Current path: its rectangles in user space, or `None` once it is anything else. + let mut path: Option> = Some(Vec::new()); + let mut clip_next = false; + for op in ops.iter().take(SCAN_OPS) { + match op.op.as_slice() { + b"q" => { + if saved.len() >= 64 { + return None; + } + saved.push(st.clone()); + } + b"Q" => st = saved.pop()?, + b"cm" => { + let m = Matrix::from_operands(op.operands.get(op.operands.len().checked_sub(6)?..)?)?; + if !m.0.iter().all(|v| v.is_finite()) { + return None; + } + st.ctm = m.then(&st.ctm); + } + b"gs" => apply_state(doc, res, op.name(0)?, &mut st.paint)?, + b"g" => set_colour(&mut st, 1, op), + b"rg" => set_colour(&mut st, 3, op), + b"k" => set_colour(&mut st, 4, op), + b"cs" => { + st.space = match op.name(0)? { + b"DeviceGray" => 1, + b"DeviceRGB" => 3, + b"DeviceCMYK" => 4, + _ => 0, + }; + // The initial colour of these spaces is black. + st.white = false; + } + b"sc" | b"scn" => { + let n = st.space; + set_colour(&mut st, n, op) + } + b"re" => { + let [x, y, w, h] = op.nums::<4>()?; + let finite = [x, y, w, h].iter().all(|v| v.is_finite()); + match (&mut path, finite && axis_aligned(&st.ctm)) { + (Some(rects), true) => rects.push(st.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])), + _ => path = None, + } + } + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => path = None, + b"W" | b"W*" => clip_next = true, + b"n" => { + if clip_next { + st.clip = clipped(st.clip, path.as_deref()); + } + path = Some(Vec::new()); + clip_next = false; + } + b"f" | b"F" | b"f*" => { + // One rectangle: with two, a fill rule can cut a hole where content shows. + let covers = match path.as_deref() { + Some([r]) => st.clip.and_then(|c| intersect(*r, c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)), + _ => false, + }; + return (covers && st.white && st.paint.plain() && !clip_next).then(|| (Backdrop::Fill, Some(op.span.clone()))); + } + b"Do" => { + let xo = sub(doc, res, b"XObject", op.name(0)?)?; + let Object::Stream(s) = &*xo else { return None }; + // A stencil or masked picture lets what is behind it show. + let masked = [&b"SMask"[..], b"Mask"].iter().any(|k| s.dict.get(k).is_some()) + || s.dict.get(b"ImageMask").is_some_and(|m| matches!(*doc.resolve(m), Object::Bool(true))); + let image = s.dict.name(b"Subtype") == Some(b"Image") && !masked; + return covers_unit_square(&st, crop, image).then_some((Backdrop::Image, None)); + } + b"BI" => { + let masked = op + .inline + .as_ref() + .is_some_and(|(d, _)| [&b"IM"[..], b"ImageMask"].iter().any(|k| d.get(k).is_some_and(|m| matches!(m, Object::Bool(true))))); + return covers_unit_square(&st, crop, !masked).then_some((Backdrop::Image, None)); + } + // Anything else that paints comes first: no backdrop. + b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" | b"sh" | b"Tj" | b"TJ" | b"'" | b"\"" | b"EI" | b"d0" | b"d1" => return None, + _ => {} + } + } + None +} + +/// A picture placed by the CTM (onto the unit square) covering the crop box, opaque, unclipped. +fn covers_unit_square(st: &State, crop: [f64; 4], image: bool) -> bool { + image + && st.paint.plain() + && axis_aligned(&st.ctm) + && st.clip.and_then(|c| intersect(st.ctm.bbox([0.0, 0.0, 1.0, 1.0]), c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)) +} + +fn set_colour(st: &mut State, components: usize, op: &Op) { + st.space = components; + let vals: Vec = op.operands.iter().filter_map(Object::as_f64).collect(); + st.white = vals.len() == components + && match components { + 1 | 3 => vals.iter().all(|v| *v >= WHITE), + 4 => vals.iter().all(|v| *v <= 1.0 - WHITE), + _ => false, + }; +} + +/// Apply the named ExtGState's keys that [`Paint`] tracks; the keys it doesn't have leave +/// `paint` as it was. `None` when the dictionary or one of those values can't be read. +fn apply_state(doc: &Document, res: &Dict, name: &[u8], paint: &mut Paint) -> Option<()> { + let gs = sub(doc, res, b"ExtGState", name)?; + let gs = gs.as_dict()?; + if let Some(alpha) = gs.get(b"ca") { + paint.full_alpha = doc.resolve(alpha).as_f64()? >= 0.999; + } + if let Some(mask) = gs.get(b"SMask") { + paint.no_soft_mask = doc.resolve(mask).as_name() == Some(b"None"); + } + if let Some(blend) = gs.get(b"BM") { + paint.normal_blend = matches!(doc.resolve(blend).as_name(), Some(b"Normal" | b"Compatible")); + } + // TR2 takes precedence over TR in the same dictionary (§8.4.5, Table 57). Only the names + // are read: a function (or one per colourant) is taken to change white. + if let Some(transfer) = gs.get(b"TR2") { + paint.identity_transfer = matches!(doc.resolve(transfer).as_name(), Some(b"Identity" | b"Default")); + } else if let Some(transfer) = gs.get(b"TR") { + paint.identity_transfer = doc.resolve(transfer).as_name() == Some(b"Identity"); + } + Some(()) +} + +/// Rotations by multiples of 90° and scales only: rectangles stay rectangles. +fn axis_aligned(m: &Matrix) -> bool { + let [a, b, c, d, ..] = m.0; + (b.abs() < 1e-9 && c.abs() < 1e-9) || (a.abs() < 1e-9 && d.abs() < 1e-9) +} + +fn intersect(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> { + let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]; + (r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r) +} + +/// The clip after intersecting with a path: known only when the path is one rectangle. +fn clipped(clip: Option<[f64; 4]>, path: Option<&[[f64; 4]]>) -> Option<[f64; 4]> { + match path { + Some([r]) => intersect(clip?, *r).or(Some([0.0, 0.0, 0.0, 0.0])), + _ => None, + } +} + +#[cfg(test)] +#[path = "backdrop_tests.rs"] +mod tests; diff --git a/crates/edit/src/backdrop_tests.rs b/crates/edit/src/backdrop_tests.rs new file mode 100644 index 0000000000..30bc2ef8b1 --- /dev/null +++ b/crates/edit/src/backdrop_tests.rs @@ -0,0 +1,607 @@ +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental}; + +use super::*; +use crate::{Background, MarkKind, add_background, remove_marks}; + +const TEXT: &str = "BT /F1 24 Tf 40 90 Td (Hello) Tj ET"; + +/// A document from its objects (object 1 is the catalog, 2 the page tree). +fn build(objs: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn stream(dict: &str, data: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// One 300 × 200 page drawing `pieces` (objects 5…), with a font, a page-sized image `/Im0` +/// (object 4) and two graphics states, `/Half` (ca 0.5) and `/Full` (ca 1). +/// The graphics states [`page`] offers. `INVERT` is a transfer function that maps 1 to 0, so +/// `1 g` through it paints black (#2815 review). +const GRAPHICS_STATES: &str = concat!( + "/Half << /ca 0.5 >> /Full << /ca 1 /CA 1 >> /LineOnly << /LW 2 >> ", + "/Masked << /SMask << /S /Luminosity /G 4 0 R >> >> /Unmasked << /SMask /None >> ", + "/Multiply << /BM /Multiply >> /NormalBlend << /BM /Normal >> ", + "/Invert << /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> ", + "/Invert2 << /TR2 << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> ", + "/IdentityTR << /TR /Identity >> /DefaultTR2 << /TR2 /Default >> ", + "/InvertButTR2Default << /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> /TR2 /Default >> ", + "/IdentityButTR2Invert << /TR /Identity /TR2 << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >>", +); + +fn page(pieces: &[&str]) -> Document { + let refs: Vec = (0..pieces.len()).map(|i| format!("{} 0 R", i + 5)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents [{}] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 4 0 R >> /ExtGState << {GS} >> >> >>", + refs.join(" "), + GS = GRAPHICS_STATES + ), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "\u{80}"), + ]; + objs.extend(pieces.iter().map(|p| stream("", p))); + build(&objs) +} + +/// The decoded content of a page, stream by stream. +fn streams(doc: &Document) -> Vec { + let p = &pdfcraft_model::pages(doc)[0]; + let list = match p.dict.get(b"Contents").map(|c| doc.resolve(c)).as_deref() { + Some(Object::Array(a)) => a.clone(), + _ => vec![], + }; + list.iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), + _ => String::new(), + }) + .collect() +} + +fn reopen(doc: &Document) -> Document { + let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); + Document::open(Arc::new(bytes)).unwrap() +} + +fn red() -> Background { + Background { color: [1.0, 0.0, 0.0], ..Background::default() } +} + +#[test] +fn a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes() { + let content = format!("q 1 1 1 rg 0 0 300 200 re f Q {TEXT}"); + let mut doc = page(&[&content]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&reopen(&doc)); + assert_eq!(s.len(), 2, "{s:?}"); + assert!(s[0].contains("/PCMark /Background")); + assert_eq!(s[1], format!("q 1 1 1 rg 0 0 300 200 re n Q {TEXT}"), "only the fill operator changed"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "nothing hides the background now"); + // The original stream object is untouched; the page points at a copy. + let Object::Stream(orig) = &*doc.get(ObjRef { num: 5, generation: 0 }) else { panic!("object 5 is the content") }; + assert_eq!(String::from_utf8_lossy(&orig.decoded().unwrap()), content); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1); + assert_eq!(streams(&reopen(&doc)), original, "exactly the original content"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); +} + +#[test] +fn updating_the_background_replaces_the_backdrop_once_and_removing_still_restores() { + let mut doc = page(&[&format!("1 1 1 rg 0 0 300 200 re f 0 g {TEXT}")]); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], ..Background::default() }, true).unwrap(); + let s = streams(&doc); + assert_eq!(s.len(), 2, "one background, one copy: {s:?}"); + assert!(s[1].contains("re n")); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +#[test] +fn a_backdrop_split_across_content_streams_is_replaced_in_the_piece_holding_the_operator() { + let mut doc = page(&["1 1 1 rg 0 0 300 200 re", &format!("f* {TEXT}")]); + let original = streams(&doc); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&doc); + assert_eq!(s[1..], ["1 1 1 rg 0 0 300 200 re".to_string(), format!("n {TEXT}")], "{s:?}"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +#[test] +fn backdrops_writers_paint_are_recognised() { + for content in [ + // Browsers: a flipped, scaled coordinate system and a page clip first. + "q 0.5 0 0 -0.5 0 200 cm 0 0 600 400 re W n 1 1 1 rg 0 0 600 400 re f Q", + // Larger than the page, in CMYK, through an opaque graphics state. + "/Full gs 0 0 0 0 k -10 -10 320 220 re f", + "/DeviceRGB cs 1 1 1 sc 0 0 300 200 re F", + "0.99 g 0 0 300 200 re f", + // Turned a quarter: still a rectangle. + "0 1 -1 0 300 0 cm 1 g 0 0 200 300 re f", + ] { + let doc = page(&[&format!("{content} 0 g {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{content}"); + } +} + +#[test] +fn content_that_is_not_a_page_backdrop_is_left_alone() { + for content in [ + // Not the whole page. + "1 g 0 0 300 150 re f", + "q 0 0 100 100 re W n 1 g 0 0 300 200 re f Q", + // Not white, or not opaque. + "0.5 g 0 0 300 200 re f", + "1 0 0 rg 0 0 300 200 re f", + "0 0 0 0.5 k 0 0 300 200 re f", + "/Half gs 1 g 0 0 300 200 re f", + "/Missing gs 1 g 0 0 300 200 re f", + "/Pattern cs /P0 scn 0 0 300 200 re f", + // The initial colour is black. + "0 0 300 200 re f", + // Something else is painted first. + "BT /F1 9 Tf (x) Tj ET 1 g 0 0 300 200 re f", + "0 g 0 0 1 1 re f 1 g 0 0 300 200 re f", + "1 g 0 0 300 200 re S 1 g 0 0 300 200 re f", + // Not a single rectangle: a hole, a turned square, a fill-and-stroke. + "1 g 0 0 300 200 re 50 50 10 10 re f*", + "1 g 0 0 m 300 0 l 300 200 l 0 200 l h f", + "0.7 0.7 -0.7 0.7 150 -100 cm 1 g 0 0 400 400 re f", + "1 g 0 0 300 200 re B", + // Malformed. + "Q 1 g 0 0 300 200 re f", + "1 g 0 0 300 re f", + "1 g 0 0 1e400 200 re f", + ] { + let mut doc = page(&[&format!("{content} {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{content}"); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..], "{content}: the page's content is unchanged"); + } +} + +#[test] +fn a_scanned_page_is_reported_and_its_picture_kept() { + let mut doc = page(&[&format!("q 300 0 0 200 0 0 cm /Im0 Do Q {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Image)); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); + // A small picture is content, not a backdrop. + let doc = page(&["q 30 0 0 20 0 0 cm /Im0 Do Q"]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); +} + +#[test] +fn replacing_can_be_turned_off() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + let before = streams(&doc); + add_background(&mut doc, &[0], &Background { replace_backdrop: false, ..red() }, false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); +} + +/// Rewrite the page's backdrop copy (the stream after the background) as another tool would: +/// Edit text and redaction keep its dictionary and change its bytes. +fn rewrite_copy(doc: &mut Document, bytes: &str) -> ObjRef { + let p = &pdfcraft_model::pages(doc)[0]; + let Some(Object::Array(list)) = p.dict.get(b"Contents").cloned() else { panic!("contents") }; + let Object::Ref(copy) = list[1] else { panic!("a reference") }; + let Object::Stream(s) = (*doc.get(copy)).clone() else { panic!("a stream") }; + assert!(s.dict.get(b"PCBackdrop").is_some(), "the copy"); + doc.set(copy, Object::Stream(Stream::flate(s.dict.clone(), bytes.as_bytes()))); + copy +} + +#[test] +fn an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + rewrite_copy(&mut doc, "1 g 0 0 300 200 re n BT /F1 24 Tf 40 90 Td (Edited) Tj ET"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), ["1 g 0 0 300 200 re f BT /F1 24 Tf 40 90 Td (Edited) Tj ET"], "the edit survives, under the fill"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + assert_eq!(backdrops_left_off(&doc), Vec::::new()); +} + +#[test] +fn a_change_before_the_fill_leaves_it_off_and_names_the_page() { + for edited in [ + // A different colour before the fill: the hash of what comes before it no longer matches. + "0.99 g 0 0 300 200 re n BT /F1 24 Tf 40 90 Td (Edited) Tj ET", + // Something painted before it. + "BT /F1 9 Tf (x) Tj ET 1 g 0 0 300 200 re n", + // The fill is gone. + "BT /F1 24 Tf 40 90 Td (Edited) Tj ET", + ] { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(backdrops_left_off(&doc), Vec::::new(), "a background is over it"); + rewrite_copy(&mut doc, edited); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), [edited.to_string()], "{edited}: the copy stays as it is"); + assert_eq!(backdrops_left_off(&doc), [0], "{edited}"); + } +} + +#[test] +fn a_fill_that_would_cover_content_painted_before_it_stays_off() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + // A tool puts a stream of its own before the copy, untagged: turning the fill back on would + // now paint white over that text. + let over = doc.add(Object::Stream(Stream::flate(pdfcraft_cos::Dict::new(), b"BT /F1 9 Tf 10 10 Td (Over) Tj ET"))); + let p = pdfcraft_model::pages(&doc)[0].clone(); + let Some(Object::Array(mut list)) = p.dict.get(b"Contents").cloned() else { panic!("contents") }; + list.insert(1, Object::Ref(over)); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap(); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + let s = streams(&doc); + assert_eq!(s.len(), 2, "{s:?}"); + assert!(s[1].contains("re n") && !s[1].contains("re f"), "nothing white goes over the text: {s:?}"); + assert_eq!(backdrops_left_off(&doc), [0]); +} + +#[test] +fn the_tag_holds_no_reference_and_a_full_save_keeps_no_second_copy_of_the_page() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + // Every stream in the file that holds the page's text: only the copy the page draws. A + // reference from the tag to the original stream kept a second, which a redaction or Remove + // Hidden Information on the copy left in the file. + let holding: Vec<(ObjRef, Dict)> = saved + .scan_objects() + .filter_map(|(r, o)| match &*o { + Object::Stream(s) if String::from_utf8_lossy(&s.decoded().unwrap_or_default()).contains("(Hello)") => Some((r, s.dict.clone())), + _ => None, + }) + .collect(); + assert_eq!(holding.len(), 1, "{holding:?}"); + let Some(Object::Dict(note)) = holding[0].1.get(b"PCBackdrop").map(|n| (*saved.resolve(n)).clone()) else { panic!("the tag") }; + fn refs(o: &Object) -> bool { + match o { + Object::Ref(_) => true, + Object::Array(a) => a.iter().any(refs), + Object::Dict(d) => d.iter().any(|(_, v)| refs(v)), + _ => false, + } + } + assert!(!note.iter().any(|(_, v)| refs(v)), "{note:?}"); + assert_eq!(note.get(b"Op").and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"f".to_vec())); + assert_eq!(note.get(b"Before").and_then(Object::as_string).map(|s| s.bytes.len()), Some(32), "a SHA-256"); + // The original is gone, so Remove writes the fill back into a stream of its own. + let mut saved = saved; + remove_marks(&mut saved, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&saved), [format!("1 g 0 0 300 200 re f {TEXT}")]); + assert_eq!(page_backdrop(&saved, 0).unwrap(), Some(Backdrop::Fill)); +} + +#[test] +fn a_copy_written_out_again_with_other_spacing_still_comes_back() { + // Redaction writes the whole stream out again: one operator a line, numbers its own way. + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + rewrite_copy(&mut doc, "1.0 g\n0 0 300.000 200 re\nn\nBT\n/F1 24 Tf\n40 90 Td\n[-6505] TJ\nET"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), ["1.0 g\n0 0 300.000 200 re\nf\nBT\n/F1 24 Tf\n40 90 Td\n[-6505] TJ\nET"], "the fill is back, the rest as written"); + assert_eq!(backdrops_left_off(&doc), Vec::::new()); +} + +#[test] +fn a_tag_that_does_not_add_up_is_left_alone() { + let content = format!("1 g 0 0 300 200 re f {TEXT}"); + let copied = format!("1 g 0 0 300 200 re n {TEXT}"); + let good = ops_hash(&pdfcraft_content::parse(b"1 g 0 0 300 200 re").ops); + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + for tag in [ + // Not a fill operator, a wrong hash, an index that isn't the `n` or runs past the end. + format!("<< /Index 2 /Op (B) /Before <{}> >>", hex(&good)), + format!("<< /Index 2 /Op (f) /Before <{}> >>", hex(&[0; 32])), + format!("<< /Index 1 /Op (f) /Before <{}> >>", hex(&good)), + format!("<< /Index 99999 /Op (f) /Before <{}> >>", hex(&good)), + format!("<< /Index -1 /Op (f) /Before <{}> >>", hex(&good)), + // Missing entries, wrong types, and the reference earlier test builds wrote. + "<< /Index 2 /Op (f) >>".to_string(), + "<< /Index (2) /Op /f /Before 3 >>".to_string(), + "<< /Original 5 0 R /At 19 /Len 1 >>".to_string(), + "(not a dictionary)".to_string(), + ] { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream(&format!("/PCBackdrop {tag}"), &copied), + stream("", &content), + ]); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), std::slice::from_ref(&copied), "{tag}"); + assert_eq!(backdrops_left_off(&doc), [0], "{tag}"); + } + // The same page with an honest tag comes back. + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream(&format!("/PCBackdrop << /Index 2 /Op (f) /Before <{}> >>", hex(&good)), &copied), + ]); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), [content]); +} + +#[test] +fn operators_hash_by_value_not_by_spelling() { + let h = |s: &[u8]| ops_hash(&pdfcraft_content::parse(s).ops); + assert_eq!(h(b"1 g 0 0 300 200 re"), h(b"1.0 g\n0 0 300.00000 200 re")); + assert_eq!(h(b"0 g"), h(b"-0.0 g")); + assert_ne!(h(b"1 g 0 0 300 200 re"), h(b"0.99 g 0 0 300 200 re")); + assert_ne!(h(b"(ab) Tj"), h(b"(a) (b) Tj"), "operand boundaries count"); + assert_ne!(h(b"/a /b BDC"), h(b"/ab BDC")); + assert_ne!(h(b""), h(b"q")); + // Nesting is bounded: deep arrays hash without running out of stack. + let deep = format!("{}{} Tj", "[".repeat(1000), "]".repeat(1000)); + let _ = h(deep.as_bytes()); +} + +#[test] +fn a_content_stream_shared_by_two_pages_is_copied_for_the_one_page() { + let content = format!("1 g 0 0 300 200 re f {TEXT}"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + stream("", &content), + ]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + assert_eq!(page_backdrop(&doc, 1).unwrap(), Some(Backdrop::Fill), "the other page keeps its backdrop"); +} + +#[test] +fn pages_without_content_or_with_odd_contents_have_no_backdrop() { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [5 0 R 99 0 R] >>".into(), + "<< /Not /AStream >>".into(), + ]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + assert_eq!(page_backdrop(&doc, 1).unwrap(), None); + assert!(matches!(page_backdrop(&doc, 7), Err(EditError::NoSuchPage(7)))); +} + +/// `pages` 300 × 200 pages in a flat tree; even pages paint a white backdrop, odd ones don't. +fn many_pages(pages: usize) -> Document { + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", i + 5)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 300 200] >>", kids.join(" ")), + stream("", &format!("1 g 0 0 300 200 re f 0 g {TEXT}")), + stream("", TEXT), + ]; + objs.extend((0..pages).map(|i| format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R >>", 3 + i % 2))); + build(&objs) +} + +#[test] +fn checking_many_pages_walks_the_page_tree_once() { + // The Add Background dialog checks every page of a long document. Looking each page up on + // its own walked the whole tree per page, so checking them all grew with the square of the + // page count (minutes at 20,000 pages); one walk keeps it in proportion. + const PAGES: usize = 4000; + let doc = many_pages(PAGES); + let all: Vec = (0..PAGES).collect(); + let start = std::time::Instant::now(); + let found = page_backdrops(&doc, &all); + let took = start.elapsed(); + assert_eq!(found.len(), PAGES); + for (i, f) in found.iter().enumerate() { + assert_eq!(*f, (i % 2 == 0).then_some(Backdrop::Fill), "page {i}"); + } + assert!(took < std::time::Duration::from_secs(5), "checking {PAGES} pages took {took:?}"); +} + +#[test] +fn a_batch_answers_in_the_order_asked_and_a_missing_page_hides_nothing() { + let doc = many_pages(5); + assert_eq!(page_backdrops(&doc, &[3, 0, 0]), [None, Some(Backdrop::Fill), Some(Backdrop::Fill)]); + assert_eq!(page_backdrops(&doc, &[]), []); + // The viewer can count more pages than the page tree holds: the others still answer. + assert_eq!(page_backdrops(&doc, &[2, 5, usize::MAX]), [Some(Backdrop::Fill), None, None]); + assert_eq!(page_backdrop(&doc, 4).unwrap(), Some(Backdrop::Fill)); + assert!(matches!(page_backdrop(&doc, 5), Err(EditError::NoSuchPage(5)))); +} + +#[test] +fn a_background_over_many_pages_replaces_each_backdrop_and_removing_restores_them() { + // Pages share their content streams, so each replaced page gets its own copy and the rest + // are checked against the document as it is after the earlier ones. + let mut doc = many_pages(4); + let all = [0, 1, 2, 3]; + let before: Vec = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect(); + add_background(&mut doc, &all, &red(), true).unwrap(); + assert_eq!(page_backdrops(&doc, &all), [None; 4]); + // Update: the backdrops are gone already, so nothing is copied again. + add_background(&mut doc, &all, &red(), true).unwrap(); + assert_eq!(page_backdrops(&doc, &all), [None; 4]); + remove_marks(&mut doc, &all, MarkKind::Background).unwrap(); + let after: Vec = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect(); + let refs = |list: &[Object]| -> Vec> { + list.iter() + .map(|c| match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }) + .collect() + }; + assert_eq!(refs(&after), refs(&before), "every page points at its original stream again"); + assert_eq!(page_backdrops(&doc, &all), [Some(Backdrop::Fill), None, Some(Backdrop::Fill), None]); +} + +#[test] +fn a_content_stream_whose_bytes_live_in_an_external_file_is_left_alone() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream("/F (content.bin)", &format!("1 g 0 0 300 200 re f {TEXT}")), + ]; + let mut doc = build(&objs); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "the inline bytes are not what the page draws"); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); +} + +#[test] +fn the_backdrop_copy_keeps_the_original_dictionary_less_its_old_encoding() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream("/Vendor (keep me) /DL 9999", &format!("1 g 0 0 300 200 re f {TEXT}")), + ]; + let mut doc = build(&objs); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + let Some(Object::Array(list)) = p.dict.get(b"Contents").map(|c| (*doc.resolve(c)).clone()) else { panic!("contents") }; + let Object::Stream(copy) = &*doc.resolve(&list[1]) else { panic!("a stream") }; + assert!(copy.dict.get(b"Vendor").is_some(), "unknown entries survive"); + assert!(copy.dict.get(b"DL").is_none(), "the old decoded length no longer holds"); + assert!(copy.dict.get(b"PCBackdrop").is_some()); + assert_eq!(String::from_utf8_lossy(©.decoded().unwrap()), format!("1 g 0 0 300 200 re n {TEXT}")); +} + +#[test] +fn a_stream_listed_twice_is_left_untouched_as_ambiguous() { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 4 0 R] >>".into(), + stream("", &format!("q 1 g 0 0 300 200 re f Q {TEXT}")), + ]); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&doc); + assert_eq!(s.len(), 3, "{s:?}"); + assert_eq!(s[1..], original[..], "repeated executions are not qualified independently"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +/// #2815 review: a `gs` sets only the keys its dictionary has, so a translucent, masked, +/// blended or transferred fill stays that way through a later `gs` that leaves the key out, +/// and a transfer function that isn't the identity can turn `1 g` into any colour. +#[test] +fn graphics_state_a_later_gs_leaves_out_still_keeps_the_fill() { + for state in [ + "/Half gs /LineOnly gs", + "/Masked gs /LineOnly gs", + "/Multiply gs /LineOnly gs", + "/Invert gs /LineOnly gs", + "/Invert2 gs /LineOnly gs", + "/Invert gs", + "/Invert2 gs", + "/IdentityButTR2Invert gs", + "/Invert gs q /LineOnly gs Q", + ] { + let mut doc = page(&[&format!("{state} 1 g 0 0 300 200 re f {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{state}"); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..], "{state}: the page's content is unchanged"); + } +} + +/// The other side of the same rule: a later `gs` that does set the key back, `TR2` naming the +/// default over a `TR` function, and `Q` restoring what `q` saved still leave a plain white fill. +#[test] +fn graphics_state_set_back_before_the_fill_still_finds_the_backdrop() { + for state in [ + "/Half gs /Full gs", + "/Masked gs /Unmasked gs", + "/Multiply gs /NormalBlend gs", + "/Invert gs /IdentityTR gs", + "/Invert2 gs /DefaultTR2 gs", + "/InvertButTR2Default gs", + "/LineOnly gs", + "q /Half gs Q", + "q /Invert gs Q", + ] { + let doc = page(&[&format!("{state} 1 g 0 0 300 200 re f {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{state}"); + } +} + +#[test] +fn an_undecodable_stream_before_a_white_fill_keeps_the_backdrop_unknown() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] >>".into(), + stream("/Filter /FlateDecode", "not a deflate stream"), + stream("", &format!("1 g 0 0 300 200 re f {TEXT}")), + ]; + let doc = build(&objs); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "unknown earlier paint must not be treated as an empty stream"); +} + +#[test] +fn shared_fill_used_as_later_artwork_is_not_rewritten() { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R 6 0 R 5 0 R 7 0 R] >>".into(), + stream("", "1 g"), + stream("", "0 0 300 200 re f"), + stream("", "q 0 g 0.25 0 0 0.25 20 20 cm"), + stream("", "Q"), + ]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], original[..], "the later black artwork keeps its fill"); + let mut saved = reopen(&doc); + assert_eq!(streams(&saved)[1..], original[..]); + remove_marks(&mut saved, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&saved), original); + assert_eq!(streams(&reopen(&saved)), original); +} + +#[test] +fn compressed_backdrop_content_is_refused_before_expanding_past_the_scan_budget() { + let mut doc = page(&["1 g 0 0 300 200 re f"]); + let mut bytes = b"1 g 0 0 300 200 re f\n".to_vec(); + bytes.resize(2 * 1024 * 1024, b' '); + let source = doc.add(Object::Stream(Stream::flate(Dict::new(), &bytes))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(source))).unwrap(); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let Object::Stream(unchanged) = &*doc.get(source) else { panic!("stream") }; + assert_eq!(unchanged.decoded_strict().unwrap(), bytes, "refusal preserves the source"); +} diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs new file mode 100644 index 0000000000..7d745c0c10 --- /dev/null +++ b/crates/edit/src/background.rs @@ -0,0 +1,201 @@ +//! A page's background colour: the colour the page is painted with before anything else, as +//! Edit a PDF ▸ Background and many producers do it, for exports that keep a page colour (#2992). + +use pdfcraft_content::{Matrix, Op, Pieces}; +use pdfcraft_cos::{Dict, Document}; + +use crate::{EditError, Rgb}; + +/// How deep `q` may nest while looking for the background (deeper saves are not tracked). +const MAX_SAVES: usize = 64; + +/// How many clipping rectangles are kept; past that the clip counts as unknown. +const MAX_CLIPS: usize = 64; + +/// A rectangle as it lands on the page: its four corners in page space, in order. Under a +/// rotating or shearing CTM it is a parallelogram, so its bounding box says too much (#2994). +type Quad = [(f64, f64); 4]; + +/// The colour space a fill colour is given in. +#[derive(Clone, Copy, PartialEq)] +enum Space { + Gray, + Rgb, + Cmyk, + /// Anything else (Separation, ICC, patterns…): no plain colour can be read from it. + Other, +} + +#[derive(Clone)] +struct State { + ctm: Matrix, + space: Space, + fill: Option, + /// Fill opacity (`ca`), and whether a soft mask or a blend mode makes the fill other than a + /// plain paint. + alpha: f64, + plain: bool, + /// The clip, as the rectangles it is the intersection of, in page space; `None` when a clip + /// that isn't one rectangle was set (its area can't be told). + clip: Option>, +} + +/// The page's background colour: the colour of the first thing its content paints, when that is +/// an opaque fill, in a gray, RGB or CMYK colour, of a path with a rectangle that covers the +/// whole visible page (and no clip keeps it from doing so). `None` for a page that paints anything else +/// first, or nothing. Forms and later content are not looked at. +pub fn page_background(doc: &Document, page: usize) -> Result, EditError> { + let pages = pdfcraft_model::pages(doc); + let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?; + let crop = p.crop(doc); + let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + let streams = crate::text::content_streams(doc, &p.dict)?; + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + + let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) }; + let mut saved: Vec = Vec::new(); + let mut lost_saves = 0usize; + // The path being built: its rectangles in page space, and whether it has anything else. + // Each rectangle of the current path: its box on the page, and whether it winds + // counterclockwise there (its width and height and the CTM's determinant agree in sign). + let mut rects: Vec<(Quad, bool)> = Vec::new(); + let mut curved = false; + let mut clip_pending = false; + for op in &ops { + match op.op.as_slice() { + b"q" => { + if saved.len() < MAX_SAVES { + saved.push(st.clone()); + } else { + lost_saves = lost_saves.saturating_add(1); + } + } + b"Q" => { + if lost_saves > 0 { + lost_saves -= 1; + } else if let Some(s) = saved.pop() { + st = s; + } + } + b"cm" => { + if let Some(m) = Matrix::from_operands(&op.operands) { + st.ctm = m.then(&st.ctm); + } + } + b"g" => (st.space, st.fill) = (Space::Gray, op.nums::<1>().map(|[g]| [g; 3])), + b"rg" => (st.space, st.fill) = (Space::Rgb, op.nums::<3>()), + b"k" => (st.space, st.fill) = (Space::Cmyk, op.nums::<4>().map(cmyk)), + b"cs" => { + st.space = match op.name(0) { + Some(b"DeviceGray" | b"G") => Space::Gray, + Some(b"DeviceRGB" | b"RGB") => Space::Rgb, + Some(b"DeviceCMYK" | b"CMYK") => Space::Cmyk, + _ => Space::Other, + }; + st.fill = None; + } + b"sc" | b"scn" => st.fill = colour(st.space, op), + b"gs" => { + if let Some(g) = op.name(0).and_then(|n| gstates.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) { + apply_gstate(doc, &g, &mut st); + } + } + b"re" => match op.nums::<4>() { + Some([x, y, w, h]) => { + let [a, b, c, d, _, _] = st.ctm.0; + rects.push((quad(&st.ctm, [x, y, x + w, y + h]), (w * h) * (a * d - b * c) > 0.0)); + } + None => curved = true, + }, + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => curved = true, + b"W" | b"W*" => clip_pending = true, + b"n" => { + if clip_pending { + st.clip = match (st.clip.take(), rects.as_slice(), curved) { + (Some(mut c), [(r, _)], false) if c.len() < MAX_CLIPS => { + c.push(*r); + Some(c) + } + _ => None, + }; + } + (rects, curved, clip_pending) = (Vec::new(), false, false); + } + b"f" | b"F" | b"f*" | b"B" | b"B*" => { + let opaque = st.plain && st.alpha >= 0.999; + // A second rectangle can cut a hole in the one that covers the page: under the + // even-odd rule any second one, under nonzero one wound the other way (a + // frame-style page border). + let even_odd = matches!(op.op.as_slice(), b"f*" | b"B*"); + let solid = match rects.as_slice() { + [_] => true, + [(_, first), rest @ ..] => !even_odd && rest.iter().all(|(_, ccw)| ccw == first), + [] => false, + }; + let covers = !curved + && solid + && st.clip.as_ref().is_some_and(|c| c.iter().all(|q| covers(q, &crop))) + && rects.iter().any(|(r, _)| covers(r, &crop)); + return Ok(st.fill.filter(|_| opaque && covers && !clip_pending).map(|c| c.map(|v| v.clamp(0.0, 1.0)))); + } + // Anything else painted first: no page background. + b"S" | b"s" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI" => return Ok(None), + _ => {} + } + } + Ok(None) +} + +/// CMYK to RGB, as a plain conversion without colour management. +fn cmyk([c, m, y, k]: [f64; 4]) -> Rgb { + [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] +} + +/// The colour `sc`/`scn` sets in `space`, if it is a plain one. +fn colour(space: Space, op: &Op) -> Option { + match (space, op.operands.len()) { + (Space::Gray, 1) => op.nums::<1>().map(|[g]| [g; 3]), + (Space::Rgb, 3) => op.nums::<3>(), + (Space::Cmyk, 4) => op.nums::<4>().map(cmyk), + _ => None, + } +} + +/// The fill opacity, soft mask and blend mode an ExtGState sets. +fn apply_gstate(doc: &Document, g: &Dict, st: &mut State) { + if let Some(ca) = g.get(b"ca").and_then(|v| doc.resolve(v).as_f64()) { + st.alpha = if ca.is_finite() { ca } else { 0.0 }; + } + if let Some(mask) = g.get(b"SMask") { + st.plain &= doc.resolve(mask).as_name() == Some(b"None"); + } + if let Some(bm) = g.get(b"BM") { + let normal = |n: Option<&[u8]>| matches!(n, Some(b"Normal" | b"Compatible")); + st.plain &= match &*doc.resolve(bm) { + pdfcraft_cos::Object::Array(a) => a.first().is_some_and(|n| normal(doc.resolve(n).as_name())), + other => normal(other.as_name()), + }; + } +} + +/// The page-space corners of a user-space rectangle under `ctm`, in order around it. +fn quad(ctm: &Matrix, r: [f64; 4]) -> Quad { + [ctm.apply(r[0], r[1]), ctm.apply(r[2], r[1]), ctm.apply(r[2], r[3]), ctm.apply(r[0], r[3])] +} + +/// Whether `outer` covers `page`, give or take half a percent of its size: each corner of the +/// page, pulled in by that much, lies inside the parallelogram `outer`. +fn covers(outer: &Quad, page: &[f64; 4]) -> bool { + let (w, h) = ((page[2] - page[0]).abs(), (page[3] - page[1]).abs()); + let (tx, ty) = (w * 0.005, h * 0.005); + let (x0, y0, x1, y1) = (page[0].min(page[2]) + tx, page[1].min(page[3]) + ty, page[0].max(page[2]) - tx, page[1].max(page[3]) - ty); + let cross = |a: (f64, f64), b: (f64, f64), p: (f64, f64)| (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0); + // Twice the signed area: its sign says which way the corners run. + let area: f64 = (0..4).map(|i| cross((0.0, 0.0), outer[i], outer[(i + 1) % 4])).sum(); + if !area.is_finite() || area.abs() < f64::EPSILON || outer.iter().any(|p| !(p.0.is_finite() && p.1.is_finite())) { + return false; + } + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].iter().all(|&p| (0..4).all(|i| cross(outer[i], outer[(i + 1) % 4], p) * area.signum() >= 0.0)) +} diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 3ac5468057..ef86d6c67a 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -9,13 +9,14 @@ //! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays //! as it is, so no comment or field value is lost with nothing in its place. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::{EditError, check, n, page_list, place_tagged}; const HIDDEN: i64 = 2; +const NO_ROTATE: i64 = 16; const NO_VIEW: i64 = 32; fn nums(doc: &Document, o: Option<&Object>) -> Option> { @@ -37,6 +38,28 @@ fn appearance(doc: &Document, d: &Dict) -> Option { } } +/// Whether a widget's field has a value: its `/V`, or the nearest one inherited through `/Parent` +/// (ISO 32000-2 §12.7.4.1). An empty string or array is no value. Past the depth limit the answer +/// is yes: a value that wasn't checked must not be removed. +fn has_value(doc: &Document, widget: &Dict) -> bool { + let mut d = widget.clone(); + let mut seen = HashSet::new(); + for _ in 0..64 { + if let Some(v) = d.get(b"V") { + return match &*doc.resolve(v) { + Object::Null => false, + Object::String(s) => !s.to_text().is_empty(), + Object::Array(a) => !a.is_empty(), + _ => true, + }; + } + let Some(parent) = d.reference(b"Parent").filter(|p| seen.insert(*p)) else { return false }; + let Some(next) = doc.get(parent).as_dict().cloned() else { return false }; + d = next; + } + true +} + /// Algorithm 8.1: the matrix that maps the form's bounding box onto the annotation rectangle. fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> { let bbox = nums(doc, form.get(b"BBox")).filter(|b| b.len() == 4)?; @@ -52,6 +75,28 @@ fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> { Some([sx, 0.0, 0.0, sy, rect[0] - x0 * sx, rect[1] - y0 * sy]) } +/// `m` followed by a turn of `degrees` (the page's /Rotate) about the upper-left corner of +/// `rect`: a NoRotate annotation stays upright on a rotated page (ISO 32000-2 §12.5.3), its +/// upper-left corner where it was, which is how MuPDF and Poppler draw it. +fn upright(m: [f64; 6], degrees: i64, rect: [f64; 4]) -> [f64; 6] { + let (sin, cos) = match degrees.rem_euclid(360) { + 90 => (1.0, 0.0), + 180 => (0.0, -1.0), + 270 => (-1.0, 0.0), + _ => return m, + }; + let (px, py) = (rect[0], rect[3]); + let t = [cos, sin, -sin, cos, px - px * cos + py * sin, py - px * sin - py * cos]; + [ + m[0] * t[0] + m[1] * t[2], + m[0] * t[1] + m[1] * t[3], + m[2] * t[0] + m[3] * t[2], + m[2] * t[1] + m[3] * t[3], + m[4] * t[0] + m[5] * t[2] + t[4], + m[4] * t[1] + m[5] * t[3] + t[5], + ] +} + #[derive(Clone, Copy)] enum Which { /// Every comment and/or every form field. @@ -129,7 +174,16 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi None } +/// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on +/// success, so a page refused part way leaves the pages before it as they were. fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { + let mut work = doc.clone(); + let drawn = flatten_in(&mut work, pages, which)?; + *doc = work; + Ok(drawn) +} + +fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result { let all = page_list(doc); check(pages, all.len())?; // `?` in an appearance can still be fixed (a field's value, a comment's text are stored as @@ -163,6 +217,7 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = HashSet::new(); // Selected, but nothing was drawn for it: it stays, pop-up and reply handling included. let mut undrawn: HashSet = HashSet::new(); + let rotation = page.dict.get(b"Rotate").map(|r| doc.resolve(r)).and_then(|r| r.as_int()).unwrap_or(0); // A viewer draws many comments from their dictionary even when the file stores no // appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so // flattening keeps a comment that still has ink on screen instead of deleting it. @@ -199,6 +254,7 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result Result Result = const { std::cell::Cell::new(0) }; +} + /// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. With /// `on_page`, also the widgets (fields without kids) that are on no page. fn prune_fields(doc: &mut Document, widgets: &HashSet, on_page: Option<&HashSet>) -> Result<(), EditError> { @@ -303,23 +366,59 @@ fn prune_fields(doc: &mut Document, widgets: &HashSet, on_page: Option<& let af_ref = af.as_ref(); let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default(); let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - // Returns whether the node survives. - fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet, on_page: Option<&HashSet>, depth: usize) -> bool { - if widgets.contains(&r) || depth > 64 { - return depth > 64; + // Returns whether the node survives, and whether that answer depends on the depth cutoff. + // `known` holds each node's answer once it is visited, so a node listed twice is pruned once, + // and one still being visited (a cycle) survives. An answer the cutoff decided records the depth + // it was reached at, and a shallower path visits the node again: at most once per depth. + fn keep( + doc: &mut Document, + r: ObjRef, + widgets: &HashSet, + on_page: Option<&HashSet>, + known: &mut HashMap)>, + depth: usize, + ) -> (bool, bool) { + #[cfg(test)] + KEEP_CALLS.with(|c| c.set(c.get().saturating_add(1))); + if widgets.contains(&r) { + return (false, false); } - let Some(d) = doc.get(r).as_dict().cloned() else { return true }; + if depth > 64 { + return (true, true); + } + match known.get(&r) { + Some(&(survives, None)) => return (survives, false), + Some(&(survives, Some(at))) if depth >= at => return (survives, true), + _ => {} + } + known.insert(r, (true, None)); + let Some(d) = doc.get(r).as_dict().cloned() else { return (true, false) }; let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else { - return on_page.is_none_or(|p| p.contains(&r)); + let survives = on_page.is_none_or(|p| p.contains(&r)); + known.insert(r, (survives, None)); + return (survives, false); }; - let survivors: Vec = - kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, depth + 1))).cloned().collect(); + let mut cut = false; + let survivors: Vec = kids + .iter() + .filter(|k| { + k.as_ref().is_none_or(|kr| { + let (survives, kid_cut) = keep(doc, kr, widgets, on_page, known, depth + 1); + cut |= kid_cut; + survives + }) + }) + .cloned() + .collect(); if survivors.len() != kids.len() { let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone()))); } - !survivors.is_empty() + known.insert(r, (!survivors.is_empty(), cut.then_some(depth))); + (!survivors.is_empty(), cut) } - let survivors: Vec = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, 0))).cloned().collect(); + let mut known = HashMap::new(); + let survivors: Vec = + fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, &mut known, 0).0)).cloned().collect(); if survivors.len() == fields.len() && widgets.is_empty() { return Ok(()); // only looked for fields on no page, and there were none } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 79f2c2cd2a..f66e7f24ee 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -9,6 +9,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; +use crate::text::content_streams; /// An image drawn on a page. #[derive(Clone, Debug, PartialEq)] @@ -73,23 +74,12 @@ struct ClipPath { even_odd: bool, } -fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() +/// Image draws one page's inventory lists at most: a form drawn many times from other forms +/// would otherwise multiply a few hundred bytes into millions of records. +pub(crate) const MAX_PAGE_IMAGES: usize = 100_000; + +fn too_many_images() -> EditError { + EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images")) } /// The page's streams joined into the one stream they are: an operator may span two of them. @@ -165,12 +155,19 @@ impl ImageState { } } b"l" => { - if let Some([x, y]) = op.nums::<2>() { + // A segment before any m (or re) has no subpath to extend: BezPath's line_to and + // friends panic on an empty path by contract, so the segment is skipped, as the + // renderer, MuPDF and pdftotext do (#3581). + if let Some([x, y]) = op.nums::<2>() + && self.path.elements().last().is_some() + { self.path.line_to(self.point(x, y)); } } b"c" => { - if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() { + if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() + && self.path.elements().last().is_some() + { self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)); } } @@ -182,12 +179,17 @@ impl ImageState { } } b"y" => { - if let Some([x1, y1, x2, y2]) = op.nums::<4>() { + if let Some([x1, y1, x2, y2]) = op.nums::<4>() + && self.path.elements().last().is_some() + { let end = self.point(x2, y2); self.path.curve_to(self.point(x1, y1), end, end); } } - b"h" => self.path.close_path(), + // ClosePath with no subpath panics by the same contract (#3581). + b"h" if self.path.elements().last().is_some() => { + self.path.close_path(); + } b"re" => { if let Some([x, y, w, h]) = op.nums::<4>() { self.path.move_to(self.point(x, y)); @@ -223,7 +225,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let mut out = Vec::new(); // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let pieces = joined(&streams(doc, &p.dict)); + let pieces = joined(&content_streams(doc, &p.dict)?); for (i, op) in pieces.parse().ops.iter().enumerate() { state.apply(op)?; let ctm = state.placement.matrix; @@ -271,6 +273,9 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect, visible_rect: intersection(rect, state.placement.clip), @@ -300,7 +305,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi /// The forms being read (cycle guard) and how many were entered (the page's budget). struct Forms { path: Vec, - visits: usize, + budget: crate::text::FormBudget, } fn walk( doc: &Document, @@ -317,7 +322,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let ctm = st.placement.matrix; if op.is("Do") { let Some(name) = op.name(0) else { continue }; - if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.visits) { + if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.budget) { if let Some(r) = form.obj { forms.path.push(r); } @@ -344,6 +349,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi if s.dict.name(b"Subtype") != Some(b"Image") { continue; } + if out.len() >= MAX_PAGE_IMAGES { + return Err(too_many_images()); + } out.push(PageImage { rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]), visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip), @@ -367,8 +375,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let mut forms = Forms { path: Vec::new(), visits: 0 }; - let pieces = joined(&streams(doc, &p.dict)); + let mut decode = pdfcraft_cos::DecodeBudget::page(); + let pieces = joined(&crate::text::content_streams_within(doc, &p.dict, &mut decode)?); + let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::new(decode) }; walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -409,6 +418,20 @@ pub fn turn_about_centre(rect: [f64; 4], quarters: i32, flip_h: bool, flip_v: bo [a, b, c, d, cx - a * cx - c * cy, cy - b * cx - d * cy] } +/// Turn the image `degrees` clockwise (as drawn, whatever the page's `/Rotate`) about the centre +/// of `rect`. `None` for a non-finite angle; whole turns of 360 are the identity. +pub fn turn_by_degrees(rect: [f64; 4], degrees: f64) -> Option<[f64; 6]> { + if !degrees.is_finite() { + return None; + } + let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + let t = (degrees % 360.0).to_radians(); + let (s, c) = t.sin_cos(); + // Clockwise in a y-up space: (x, y) -> (x cos t + y sin t, -x sin t + y cos t). + let (a, b, cc, d) = (c, -s, s, c); + Some([a, b, cc, d, cx - a * cx - cc * cy, cy - b * cx - d * cy]) +} + /// For a `Do` at `op` that sits alone in `q re W n cm Do Q`: the index of the `re` and the `cm` matrix. /// That clip belongs to this image only, so it may move with it. A `cm` this move put before the /// `re` earlier (`q cm re W n cm Do Q`) keeps the pattern, so the image can be moved again. @@ -455,7 +478,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let images = page_images(doc, page)?; let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = content_streams(doc, &p.dict)?; let pieces = joined(&all); let ops = pieces.parse().ops; if !ops.get(img.op).is_some_and(|op| op.is("Do")) { @@ -503,7 +526,7 @@ pub(crate) fn selectable_images( excluded: &std::collections::BTreeSet, ) -> Result, EditError> { let p = page_of(doc, page)?; - let all = streams(doc, &p.dict); + let all = content_streams(doc, &p.dict)?; Ok(page_images(doc, page)? .into_iter() .enumerate() @@ -514,6 +537,11 @@ pub(crate) fn selectable_images( /// Find the saved graphics-state scope containing just this image and its crop. Applying /// the transform before that scope moves the actual clipping path as well as the pixels. /// A shared clip must never be moved along with unrelated artwork or text. +/// +/// Under a clip set outside every scope that holds only this image (a report or table drawing +/// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active +/// clip. The outermost scope holding only the image is used then: it contains all of the +/// image's private clips, which move with it, while the outer clip is shared and stays. fn image_transform_scope(ops: &[Op], target: usize) -> Result, EditError> { struct Scope { start: usize, @@ -531,6 +559,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result { @@ -541,9 +570,13 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result { if let Some(scope) = stack.pop() { - if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path { - // Prefer the innermost scope that contains every active clipping path. - isolated.get_or_insert((scope.start, index, scope.matrix)); + if scope.selected && !scope.other_paint && !scope.inherited_path { + if !scope.inherited_clip { + // Prefer the innermost scope that contains every active clipping path. + isolated.get_or_insert((scope.start, index, scope.matrix)); + } + // Scopes close inner first, so the last one recorded is the outermost. + outermost = Some((scope.start, index, scope.matrix)); } matrix = scope.matrix; clipped = scope.inherited_clip; @@ -586,7 +619,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result> BDC … EMC`. The //! `/PCMark` entry (also in the stream dictionary) names the kind of mark, which is how Update and //! Remove find them again without decompressing page content. When @@ -11,11 +12,12 @@ //! so its graphics state cannot reach the mark. The closing stream also ends known unfinished //! marked-content sequences before the new mark. //! -//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`. +//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`; headers and +//! footers may use standard Times (`/PCTimes`) or Courier (`/PCCour`) instead (#3754). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, Object, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; #[derive(Debug, thiserror::Error, PartialEq)] @@ -29,845 +31,49 @@ pub enum EditError { Cos(#[from] pdfcraft_cos::CosError), } -/// The kinds of page marks. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MarkKind { - HeaderFooter, - Watermark, - Background, -} - -impl MarkKind { - fn tag(self) -> &'static str { - match self { - MarkKind::HeaderFooter => "HeaderFooter", - MarkKind::Watermark => "Watermark", - MarkKind::Background => "Background", - } - } -} - pub type Rgb = [f64; 3]; -/// Header and footer settings (Acrobat's Add Header and Footer dialog). -#[derive(Clone, Debug, PartialEq)] -pub struct HeaderFooter { - /// Left, centre and right header text, then left, centre and right footer text. Tokens: - /// `<<1>>` page number, `<>` page count, `<<1 of n>>`, `<<1/n>>`, `<>`, - /// `<>`, dates `<>`, `<>`, `<>`, `<>`, - /// `<>`, `<>`, and Bates numbers `<>` - /// (digits, start, prefix, suffix). - pub text: [String; 6], - pub font_size: f64, - pub color: Rgb, - pub underline: bool, - /// Margins in points: top, bottom, left, right (the text's distance from the page edges). - pub margins: [f64; 4], - /// The number `<<1>>` shows on the first page of the range. - pub start_number: u32, -} - -impl Default for HeaderFooter { - /// Acrobat's defaults: 8 pt black, margins 0.5 in top and bottom, 1 in left and right. - fn default() -> Self { - Self { text: Default::default(), font_size: 8.0, color: [0.0; 3], underline: false, margins: [36.0, 36.0, 72.0, 72.0], start_number: 1 } - } -} - -impl HeaderFooter { - /// The first character [`add_header_footer`] would refuse, because the standard fonts can't - /// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the - /// template), so checking the templates, split as `expand`'s output is, covers every page - /// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview - /// never offers text that applying refuses (#2013). - pub fn first_undrawable(&self) -> Option { - self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi) - } -} - -/// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in -/// the document, an image (drawn into a unit square) or a form (a page of a PDF). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct MarkSource { - pub xobject: pdfcraft_cos::ObjRef, - /// Its natural size in points. - pub size: (f64, f64), - /// An image XObject (unit square); otherwise a form whose `/Matrix` maps it to `size`. - pub image: bool, -} - -/// A watermark: text, or a picture from a file (`source`; the text is then ignored). -#[derive(Clone, Debug, PartialEq)] -pub struct Watermark { - pub text: String, - pub source: Option, - /// Pictures: size relative to the page (1.0 fits the page). - pub scale: f64, - /// 0 = fit: about half the page diagonal. - pub font_size: f64, - pub color: Rgb, - /// 0–1. - pub opacity: f64, - /// Degrees counter-clockwise (Acrobat's 45° runs from bottom-left to top-right). - pub rotation: f64, - /// Draw behind the page content instead of on top. - pub behind: bool, - /// Offset of the centre from the page centre, in points (right, up). - pub offset: [f64; 2], -} - -impl Watermark { - /// The first character of a text watermark [`add_watermark`] would refuse, because the - /// standard fonts can't draw it (#125). A picture watermark draws no text. - pub fn first_undrawable(&self) -> Option { - match self.source { - Some(_) => None, - None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi), - } - } -} - -impl Default for Watermark { - fn default() -> Self { - Self { - text: String::new(), - source: None, - scale: 0.5, - font_size: 0.0, - color: [0.6, 0.6, 0.6], - opacity: 0.5, - rotation: 45.0, - behind: false, - offset: [0.0; 2], - } - } -} - -/// A background behind the page content: a solid colour, or a picture from a file. -#[derive(Clone, Debug, PartialEq)] -pub struct Background { - pub color: Rgb, - pub opacity: f64, - pub source: Option, - /// Pictures: size relative to the page (1.0 fits the page). - pub scale: f64, -} - -impl Default for Background { - fn default() -> Self { - Self { color: [1.0, 1.0, 0.85], opacity: 1.0, source: None, scale: 1.0 } - } -} - -/// Drawing a picture mark: fitted to the page at `scale`, centred (plus `offset`), turned by -/// `rotation` degrees. Returns the content and the resource name it uses. -fn picture(src: &MarkSource, page: (f64, f64), scale: f64, rotation: f64, offset: [f64; 2]) -> String { - let (w, h) = page; - let (sw, sh) = (src.size.0.max(0.01), src.size.1.max(0.01)); - let k = (w / sw).min(h / sh) * if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; - let (dw, dh) = (sw * k, sh * k); - let (s, c) = rotation.to_radians().sin_cos(); - let (cx, cy) = (w / 2.0 + offset[0], h / 2.0 + offset[1]); - // Centre, rotate, then place the picture's box (unit square for images). - let place = if src.image { - format!("{} 0 0 {} {} {} cm", n(dw), n(dh), n(-dw / 2.0), n(-dh / 2.0)) - } else { - format!("{} 0 0 {} {} {} cm", n(k), n(k), n(-dw / 2.0), n(-dh / 2.0)) - }; - format!("{} {} {} {} {} {} cm\n{place}\n/PCPic{} Do\n", n(c), n(s), n(-s), n(c), n(cx), n(cy), src.xobject.num) -} - -/// Register a mark picture in the page's own resources (`/PCPic`). -fn add_picture_resource(doc: &mut Document, page: &pdfcraft_model::Page, src: &MarkSource) -> Result<(), EditError> { - let p = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); - let mut res = p.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let mut xo = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - xo.set(format!("PCPic{}", src.xobject.num).into_bytes(), Object::Ref(src.xobject)); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; - Ok(()) -} - -/// Values tokens need that come from outside the document. -#[derive(Clone, Debug, PartialEq, Default)] -pub struct Context { - /// Today's date: (year, month 1–12, day 1–31). - pub date: (i64, u32, u32), -} - -// Model-derived transforms can contain tiny scale factors; retain their PDF decimal precision. -fn matrix_number(value: f64) -> String { - let mut bytes = Vec::new(); - pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); - String::from_utf8_lossy(&bytes).into_owned() -} - -fn n(v: f64) -> String { - let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -fn rgb(c: Rgb) -> String { - let c = c.map(|x| x.clamp(0.0, 1.0)); - format!("{} {} {} rg", n(c[0]), n(c[1]), n(c[2])) -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; - -/// Expand `<<…>>` tokens for page `index` (0-based within the range) of `count`. -pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &Context) -> String { - let (y, m, d) = cx.date; - let mut out = String::new(); - let mut rest = template; - while let Some(start) = rest.find("<<") { - out.push_str(&rest[..start]); - let after = &rest[start + 2..]; - let Some(end) = after.find(">>") else { - out.push_str(&rest[start..]); - return out; - }; - let token = &after[..end]; - let p = page_number; - let value = match token { - "1" => p.to_string(), - "n" => count.to_string(), - "1 of n" => format!("{p} of {count}"), - "1/n" => format!("{p}/{count}"), - "Page 1" => format!("Page {p}"), - "Page 1 of n" => format!("Page {p} of {count}"), - "m/d" => format!("{m}/{d}"), - "m/d/yy" => format!("{m}/{d}/{:02}", y.rem_euclid(100)), - "m/d/yyyy" => format!("{m}/{d}/{y}"), - "mm/dd/yy" => format!("{m:02}/{d:02}/{:02}", y.rem_euclid(100)), - "mm/dd/yyyy" => format!("{m:02}/{d:02}/{y}"), - "d/m/yy" => format!("{d}/{m}/{:02}", y.rem_euclid(100)), - "d/m/yyyy" => format!("{d}/{m}/{y}"), - "dd/mm/yyyy" => format!("{d:02}/{m:02}/{y}"), - "yyyy-mm-dd" => format!("{y}-{m:02}-{d:02}"), - "mmmm d, yyyy" => format!("{} {d}, {y}", MONTHS[(m.clamp(1, 12) - 1) as usize]), - t if t.starts_with("Bates Number") => { - // Bates Number#digits#start#prefix#suffix (start is applied by the caller). - let parts: Vec<&str> = t.split('#').collect(); - let digits = parts.get(1).and_then(|d| d.parse::().ok()).unwrap_or(6).clamp(1, 15); - format!("{}{:0digits$}{}", parts.get(3).unwrap_or(&""), bates, parts.get(4).unwrap_or(&"")) - } - _ => format!("<<{token}>>"), - }; - out.push_str(&value); - rest = &after[end + 2..]; - } - out.push_str(rest); - out -} - -/// The Bates start number in a template (`<>`), if any. -fn bates_start(template: &str) -> Option { - let i = template.find("<>")?]; - t.split('#').nth(2).and_then(|s| s.parse().ok()).or(Some(1)) -} - -fn page_list(doc: &Document) -> Vec { - pdfcraft_model::pages(doc) -} - -fn check(pages: &[usize], count: usize) -> Result<(), EditError> { - match pages.iter().find(|p| **p >= count) { - Some(p) => Err(EditError::NoSuchPage(*p)), - None => Ok(()), - } -} - -/// The page's `/Contents` as a list of stream references (inline content is promoted). -fn contents(doc: &mut Document, page: &pdfcraft_model::Page) -> Result, EditError> { - let obj = doc.get(page.obj); - let d = obj.as_dict().cloned().unwrap_or_default(); - Ok(match d.get(b"Contents").cloned() { - None => Vec::new(), - Some(Object::Ref(r)) => match doc.get(r).as_array() { - Some(a) => a.clone(), - None => vec![Object::Ref(r)], - }, - Some(Object::Array(a)) => a, - Some(other) => vec![Object::Ref(doc.add(other))], - }) -} - -/// Add the font (and an opacity state) to the page's own resources. -fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Option, content: Option<&[u8]>) -> Result<(), EditError> { - let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - for (name, base) in [(&b"PCHelv"[..], "Helvetica"), (b"PCTimes", "Times-Roman"), (b"PCCour", "Courier")] { - // Helvetica always (marks use it); the others only when the content names them. - if name != b"PCHelv" && !content.is_some_and(|c| c.windows(name.len() + 1).any(|w| w[0] == b'/' && &w[1..] == name)) { - continue; - } - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name(base)); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(name.to_vec(), Object::Dict(font)); - } - res.set(b"Font".to_vec(), Object::Dict(fonts)); - if let Some(o) = opacity { - let mut gs = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); - let mut s = Dict::new(); - s.set(b"Type".to_vec(), Object::name("ExtGState")); - s.set(b"CA".to_vec(), Object::Real(o)); - s.set(b"ca".to_vec(), Object::Real(o)); - let name = format!("PCGS{}", (o * 100.0).round() as i64); - gs.set(name.into_bytes(), Object::Dict(s)); - res.set(b"ExtGState".to_vec(), Object::Dict(gs)); - } - // The page gets its own (possibly copied) resources; shared dictionaries stay untouched. - doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; - Ok(()) -} - -const WRAP_OPEN: &[u8] = b"q %PdfCraft\n"; -/// Starts with an end of line, so a comment left open at the end of the content before it -/// can't swallow the `Q`. Older files have the close without it. -const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n"; -const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n"; - -/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing -/// graphics closures. Legacy closes without the leading newline remain valid on their own. -fn is_wrap_close(bytes: &[u8]) -> bool { - let mut rest = bytes; - while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) { - rest = shorter; - } - let legacy = rest.len() == bytes.len(); - loop { - if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) { - return true; - } - match rest.strip_prefix(b"\nQ".as_slice()) { - Some(shorter) => rest = shorter, - None => return false, - } - } -} - -/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)` -/// for a close. -fn wrapper_role(doc: &Document, o: &Object) -> Option { - if stream_tag(doc, o).as_deref() != Some("Wrap") { - return None; - } - let Object::Stream(stream) = &*doc.resolve(o) else { return None }; - // Wrappers are a few bytes; one that decodes to more isn't PdfCraft's. - let bytes = stream.decoded_within(64 * 1024).ok()?; - if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) } -} - -#[cfg(test)] -fn stream_bytes(doc: &Document, o: &Object) -> Option> { - match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().ok(), - _ => None, - } -} - -/// The `/PCMark` tag in a content stream's dictionary: a mark kind, or `Wrap` for the q/Q -/// wrapper. Read from the dictionary, so finding marks never decompresses page content. -fn stream_tag(doc: &Document, o: &Object) -> Option { - let obj = doc.resolve(o); - obj.as_dict()?.name(b"PCMark").map(|n| String::from_utf8_lossy(n).into_owned()) -} - -fn tagged(tag: &str) -> Dict { - let mut d = Dict::new(); - d.set(b"PCMark".to_vec(), Object::name(tag)); - d -} - -/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags, -/// which another writer may keep, copy or forge). -enum Isolation { - /// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths. - /// `suffix`: the stream from which the content leaves graphics state set (or a comment open) - /// that a mark on top must not inherit, so it needs a wrapper from there to the end. - /// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes. - /// `marked`: unfinished marked-content sequences to close before a new mark. - Clean { suffix: Option, wrappers: Vec<(usize, usize)>, marked: usize }, - /// The content could not be read that precisely (it doesn't decode, is too large, or has - /// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is - /// the count of `q` left open at the end, when known. `marked` counts known unfinished - /// marked-content sequences independently of graphics malformation, or zero when unknown. - Unknown { unclosed: usize, marked: usize }, -} - -const ISOLATION_MAX_STREAMS: usize = 100_000; -const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024; -const ISOLATION_MAX_OPS: usize = 1_000_000; -const ISOLATION_MAX_DEPTH: usize = 10_000; - -fn isolation(doc: &Document, list: &[Object]) -> Isolation { - let unknown = Isolation::Unknown { unclosed: 0, marked: 0 }; - if list.len() > ISOLATION_MAX_STREAMS { - return unknown; - } - let mut remaining = ISOLATION_MAX_BYTES; - let mut data = Vec::with_capacity(list.len()); - for object in list { - let resolved = doc.resolve(object); - let Object::Stream(stream) = &*resolved else { return unknown }; - let Ok(bytes) = stream.decoded_within(remaining) else { return unknown }; - let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown }; - remaining = left; - data.push(bytes); - } - let pieces = pdfcraft_content::Pieces::join(&data); - let parsed = pieces.parse(); - if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS { - return unknown; - } - let edges = pieces.comment_edges(&parsed); - let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false); - let mut stack: Vec = Vec::new(); - let (mut marked, mut marked_known) = (0usize, true); - let mut split = 0; - let mut previous_last = None; - let mut wrappers = Vec::new(); - for op in &parsed.ops { - let (first, last) = pieces.pieces_of(op); - // A wrapper can only go in before a whole stream, outside graphics and marked-content - // scopes, between operators, where no comment would swallow the wrapper's `q`. - if stack.is_empty() - && marked_known - && marked == 0 - && !text_open - && !path_open - && !dirty - && previous_last.is_none_or(|previous| first > previous) - && !edges.get(first).is_some_and(|edge| edge.0) - { - split = first; - } - previous_last = Some(last); - // Match the reader's tolerant operands: BMC uses the last name, BDC the last - // name/object pair, and EMC ignores surplus operands. Invalid begins are ignored. - let begin_marked = match op.op.as_slice() { - b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))), - b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))), - _ => false, - }; - if marked_known { - if begin_marked { - if marked < ISOLATION_MAX_DEPTH { - marked += 1; - } else { - // Once the budget is exceeded, later EMCs cannot restore a known depth. - marked_known = false; - marked = 0; - } - } else if op.is("EMC") { - marked = marked.saturating_sub(1); - } - } - match op.op.as_slice() { - b"BT" if !text_open => text_open = true, - b"ET" if text_open => text_open = false, - b"BT" | b"ET" => malformed = true, - b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true, - b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false, - _ => {} - } - match op.op.as_slice() { - b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first), - b"Q" if op.operands.is_empty() => match stack.pop() { - Some(open) => { - // Only a standalone PdfCraft wrapper pair may later be removed again. - let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap")); - if wrap(open) - && wrap(first) - && data.get(open).is_some_and(|d| d == WRAP_OPEN) - && data.get(first).is_some_and(|d| is_wrap_close(d)) - { - wrappers.push((open, first)); - } - } - None => malformed = true, - }, - b"q" | b"Q" => malformed = true, - _ if stack.is_empty() => dirty = true, - _ => {} - } - } - if malformed || text_open || path_open || !stack.is_empty() || !marked_known { - return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked }; - } - let comment_open = edges.last().is_some_and(|edge| edge.1); - Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked } -} - -/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the -/// original content in q/Q). -fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { - place_tagged(doc, page, kind.tag(), content, behind) -} - -/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the -/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including -/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of -/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content -/// sequences are closed at the end too, so hidden optional content cannot hide the new mark. -fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> { - let mut list = contents(doc, page)?; - let wrap = if behind { - None - } else { - match isolation(doc, &list) { - Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)), - // Content that can't be read precisely is wrapped whole, as before: after the - // behind-marks at the start. A `q` it leaves open is closed at its end, where - // closing it changes nothing already drawn. - Isolation::Unknown { unclosed, marked } => { - let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()]; - let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len()); - (split < list.len()).then_some((split, unclosed, marked)) - } - } - }; - let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content)))); - if behind { - list.insert(0, mark); - } else { - if let Some((split, unclosed, marked)) = wrap { - let mut close_bytes = b"\nEMC".repeat(marked); - close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed)); - close_bytes.extend_from_slice(WRAP_CLOSE); - let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec())))); - let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes)))); - list.insert(split.min(list.len()), open); - list.push(close); - } - list.push(mark); - } - doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; - Ok(()) -} - -/// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica -/// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes -/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes. -pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result<(), EditError> { - let all = page_list(doc); - check(&[page], all.len())?; - add_resources(doc, &all[page], None, Some(&content))?; - let p = page_list(doc).swap_remove(page); - place_tagged(doc, &p, tag, content, false) -} - -fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String { - format!( - "q\n/Artifact <> BDC\n{} {} {} {} {} {} cm\n", - kind.tag(), - matrix_number(matrix[0]), - matrix_number(matrix[1]), - matrix_number(matrix[2]), - matrix_number(matrix[3]), - matrix_number(matrix[4]), - matrix_number(matrix[5]) - ) -} - -const END: &str = "EMC\nQ\n"; - -/// The refusal for page text with `c`, which nothing here can draw. -pub(crate) fn undrawable(c: char) -> EditError { - EditError::Invalid(undrawable_message(c)) -} - -/// The wording of the refusal for text with `c`, which the standard fonts can't draw. -pub fn undrawable_message(c: char) -> String { - format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c)) -} - -fn text_op(x: f64, y: f64, text: &str) -> Vec { - let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes(); - v.extend(literal(&win_ansi(text))); - v.extend_from_slice(b" Tj\n"); - v -} - -/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on -/// those pages are removed first (Acrobat's Replace Existing). -pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> { - let all = page_list(doc); - check(pages, all.len())?; - if hf.text.iter().all(|t| t.trim().is_empty()) { - return Err(EditError::Invalid("type the header or footer text first".into())); - } - if let Some(c) = hf.first_undrawable() { - return Err(undrawable(c)); - } - if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) { - return Err(EditError::Invalid("invalid font size or margins".into())); - } - // Page numbers count up from start_number, so the last selected page needs the most room. - // Refuse the whole run before drawing anything: headers applied to some pages and then a - // failed page number would leave the document half changed. - let last_number = u64::from(hf.start_number) - .checked_add(pages.len().saturating_sub(1) as u64) - .ok_or_else(|| EditError::Invalid("the header page numbers are too large".into()))?; - if last_number > u64::from(u32::MAX) { - return Err(EditError::Invalid(format!( - "the header start number {} leaves no room for {} pages (page numbers stop at {})", - hf.start_number, - pages.len(), - u32::MAX - ))); - } - // The same goes for Bates numbers: the last selected page must still fit in a u64. - let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1); - if bates0.checked_add(pages.len().saturating_sub(1) as u64).is_none() { - return Err(EditError::Invalid(format!( - "the Bates start number {bates0} leaves no room for {} pages (Bates numbers stop at {})", - pages.len(), - u64::MAX - ))); - } - if replace { - remove_marks(doc, pages, MarkKind::HeaderFooter)?; - } - let all = page_list(doc); - let count = all.len(); - for (k, &i) in pages.iter().enumerate() { - let page = &all[i]; - let (w, h) = page.display_size(doc); - let number = - u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?; - let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?; - let size = hf.font_size; - let [top, bottom, left, right] = hf.margins; - let mut body: Vec = format!("BT\n/PCHelv {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes(); - let mut underlines = String::new(); - for (slot, template) in hf.text.iter().enumerate() { - if template.trim().is_empty() { - continue; - } - let lines: Vec = expand(template, number, count, bates, cx).lines().map(str::to_owned).collect(); - let header = slot < 3; - let line_h = size * 1.2; - for (li, line) in lines.iter().enumerate() { - let tw = helvetica_width(line, size); - let x = match slot % 3 { - 0 => left, - 1 => (w - tw) / 2.0, - _ => w - right - tw, - }; - // Headers hang below the top margin; footers sit on the bottom margin. - let y = if header { h - top - size * 0.8 - li as f64 * line_h } else { bottom + (lines.len() - 1 - li) as f64 * line_h }; - body.extend(text_op(x, y, line)); - if hf.underline { - underlines.push_str(&format!("{} {} {} {} re f\n", n(x), n(y - size * 0.15), n(tw), n((size * 0.06).max(0.4)))); - } - } - } - body.extend_from_slice(b"ET\n"); - if !underlines.is_empty() { - body.extend(format!("{}\n{underlines}", rgb(hf.color)).bytes()); - } - let mut content = begin(MarkKind::HeaderFooter, "Header", page.view_matrix(doc)).into_bytes(); - content.extend(body); - content.extend_from_slice(END.as_bytes()); - add_resources(doc, page, None, None)?; - let page = &page_list(doc)[i]; - place(doc, page, MarkKind::HeaderFooter, content, false)?; - } - Ok(()) -} - -/// Add a text watermark to `pages`. -pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> { - let all = page_list(doc); - check(pages, all.len())?; - let text = wm.text.trim(); - if text.is_empty() && wm.source.is_none() { - return Err(EditError::Invalid("type the watermark text first".into())); - } - if let Some(c) = wm.first_undrawable() { - return Err(undrawable(c)); - } - if !(wm.opacity.is_finite() - && wm.rotation.is_finite() - && wm.font_size.is_finite() - && wm.font_size >= 0.0 - && wm.offset.iter().all(|o| o.is_finite())) - { - return Err(EditError::Invalid("invalid watermark settings".into())); - } - if replace { - remove_marks(doc, pages, MarkKind::Watermark)?; - } - let opacity = wm.opacity.clamp(0.0, 1.0); - let lines: Vec<&str> = text.lines().collect(); - for &i in pages { - let page = page_list(doc)[i].clone(); - let (w, h) = page.display_size(doc); - if let Some(src) = &wm.source { - let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc)); - content.push_str(&format!("/PCGS{} gs\n", (opacity * 100.0).round() as i64)); - content.push_str(&picture(src, (w, h), wm.scale, wm.rotation, wm.offset)); - content.push_str(END); - add_resources(doc, &page, Some(opacity), None)?; - add_picture_resource(doc, &page_list(doc)[i].clone(), src)?; - let page = &page_list(doc)[i]; - place(doc, page, MarkKind::Watermark, content.into_bytes(), wm.behind)?; - continue; - } - let widest = lines.iter().map(|l| helvetica_width(l, 1.0)).fold(0.0, f64::max).max(0.01); - let size = if wm.font_size > 0.0 { wm.font_size } else { ((w * w + h * h).sqrt() * 0.5 / widest).clamp(6.0, 300.0) }; - let (s, c) = wm.rotation.to_radians().sin_cos(); - let (cx, cy) = (w / 2.0 + wm.offset[0], h / 2.0 + wm.offset[1]); - let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc)).into_bytes(); - content.extend( - format!( - "/PCGS{} gs\n{} {} {} {} {} {} cm\nBT\n/PCHelv {} Tf\n{}\n", - (opacity * 100.0).round() as i64, - n(c), - n(s), - n(-s), - n(c), - n(cx), - n(cy), - n(size), - rgb(wm.color) - ) - .bytes(), - ); - let line_h = size * 1.15; - let total = line_h * (lines.len() as f64 - 1.0); - for (li, line) in lines.iter().enumerate() { - let tw = helvetica_width(line, size); - content.extend(text_op(-tw / 2.0, total / 2.0 - li as f64 * line_h - size * 0.35, line)); - } - content.extend_from_slice(b"ET\n"); - content.extend_from_slice(END.as_bytes()); - add_resources(doc, &page, Some(opacity), None)?; - let page = &page_list(doc)[i]; - place(doc, page, MarkKind::Watermark, content, wm.behind)?; - } - Ok(()) -} - -/// Add a solid-colour background behind the content of `pages`. -pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { - let all = page_list(doc); - check(pages, all.len())?; - if replace { - remove_marks(doc, pages, MarkKind::Background)?; - } - let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 }; - for &i in pages { - let page = page_list(doc)[i].clone(); - let (w, h) = page.display_size(doc); - let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc)); - match &bg.source { - Some(src) => { - content.push_str(&format!("/PCGS{} gs\n", (opacity * 100.0).round() as i64)); - content.push_str(&picture(src, (w, h), bg.scale, 0.0, [0.0; 2])); - } - None => content.push_str(&format!("/PCGS{} gs\n{}\n0 0 {} {} re f\n", (opacity * 100.0).round() as i64, rgb(bg.color), n(w), n(h))), - } - content.push_str(END); - add_resources(doc, &page, Some(opacity), None)?; - if let Some(src) = &bg.source { - add_picture_resource(doc, &page_list(doc)[i].clone(), src)?; - } - let page = &page_list(doc)[i]; - place(doc, page, MarkKind::Background, content.into_bytes(), true)?; - } - Ok(()) -} - -/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with -/// nothing after them go too, restoring the original content list; a wrapper that content -/// after it still relies on (another tool's appended stream) stays. -pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result { - let all = page_list(doc); - check(pages, all.len())?; - let mut removed = 0; - for &i in pages { - let page = &all[i]; - let list = contents(doc, page)?; - let before = list.len(); - let mut kept: Vec = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect(); - removed += before - kept.len(); - // Strip only wrapper pairs whose close ends the list (after stripping the pairs after - // it): nothing comes after them to need the state they restore. Pairs come from the - // operators; for content that can't be read that precisely, from the wrapper streams - // alone, so updating a mark there doesn't nest another wrapper each time. - if before != kept.len() { - let wrappers = match isolation(doc, &kept) { - Isolation::Clean { wrappers, .. } => wrappers, - Isolation::Unknown { .. } => { - let mut opens = Vec::new(); - let mut pairs = Vec::new(); - for (i, o) in kept.iter().enumerate() { - match wrapper_role(doc, o) { - Some(true) => opens.push(i), - Some(false) => pairs.extend(opens.pop().map(|open| (open, i))), - None => {} - } - } - pairs - } - }; - let pairs: std::collections::HashMap = wrappers.into_iter().map(|(open, close)| (close, open)).collect(); - let mut discarded = std::collections::HashSet::new(); - let mut end = kept.len(); - while let Some(close) = end.checked_sub(1) { - if discarded.contains(&close) { - end = close; - } else if let Some(&open) = pairs.get(&close) { - discarded.insert(open); - discarded.insert(close); - end = close; - } else { - break; - } - } - kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect(); - } - if before != kept.len() { - doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?; - } - } - Ok(removed) -} - -/// Which kinds of marks the document has (for enabling Update/Remove). -pub fn marks_present(doc: &Document) -> Vec { - added::scan_markers(doc).marks -} - +pub mod backdrop; +pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops}; +mod background; +pub use background::page_background; pub mod objects; pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects}; mod flatten; pub use flatten::{flatten, flatten_fill_sign}; pub mod images; -pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre}; +pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees}; pub mod bidi; pub mod indic; mod linebreak; +pub mod paragraph_align; pub mod text; pub use text::{ - BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, - text_lines, + BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, + clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; pub mod added; pub use added::{ - Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers, - update_content, + Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added, + scan_markers, update_content, }; +mod apply; +mod marks; +mod place; + +pub use apply::{add_background, add_header_footer, add_watermark, marks_present, remove_marks}; +#[cfg(test)] +use marks::bates_start; +pub use marks::{Background, Context, HeaderFooter, MarkKind, MarkSource, Watermark, expand}; +use marks::{matrix_number, n}; +pub(crate) use place::undrawable; +#[cfg(test)] +use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role}; +use place::{check, check_on_top, contents, page_list, place_tagged}; +pub use place::{stamp, undrawable_message}; + #[cfg(test)] mod marked_isolation_tests; #[cfg(test)] diff --git a/crates/edit/src/marked_isolation_tests.rs b/crates/edit/src/marked_isolation_tests.rs index 6da8322296..227371bcac 100644 --- a/crates/edit/src/marked_isolation_tests.rs +++ b/crates/edit/src/marked_isolation_tests.rs @@ -96,7 +96,7 @@ fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() { } #[test] -fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() { +fn depth_budget_bounds_emitted_closures_and_overflow_is_refused_on_top() { let maximum = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH); let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH); expected.extend_from_slice(WRAP_CLOSE); @@ -114,9 +114,46 @@ fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() { place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q Q\n".to_vec(), false).unwrap(); assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); assert_eq!(contents(&mut doc, &page).unwrap(), original, "maximum combined close can be removed without nesting wrappers"); + // Past the budget the depth is unknown, so nothing can close the page's (possibly hidden) + // optional content before a mark on top: placing one there is refused, and the page is left + // as it was. Behind the content stays possible. for suffix in [b"/Artifact BMC\n".as_slice(), b"/Artifact BMC\nEMC\n", b"/Artifact BMC\nEMC\nEMC\n"] { let mut over = maximum.clone(); over.extend_from_slice(suffix); - assert_eq!(close(&[&over]), WRAP_CLOSE, "overflow cannot resume speculative depth tracking after EMC"); + let mut doc = fixture(&[&over]); + let page = page_list(&doc).remove(0); + let original = contents(&mut doc, &page).unwrap(); + assert!( + matches!(stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()), Err(EditError::Invalid(_))), + "overflow cannot resume speculative depth tracking after EMC" + ); + assert_eq!(contents(&mut doc, &page).unwrap(), original); + place_tagged(&mut doc, &page, MarkKind::Background.tag(), b"q Q\n".to_vec(), true).unwrap(); } } + +/// Marks refused on top of content nested past the depth budget leave the page as it was: old +/// marks are not removed and no resources are added before the refusal. +#[test] +fn a_mark_refused_on_top_changes_nothing() { + let mut over = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH); + over.extend_from_slice(b"/Artifact BMC\n"); + let mut doc = fixture(&[&over]); + let behind = Watermark { text: "OLD".into(), behind: true, ..Watermark::default() }; + add_watermark(&mut doc, &[0], &behind, false).unwrap(); + let page = page_list(&doc).remove(0).obj; + let before = (*doc.get(page)).clone(); + let on_top = Watermark { text: "NEW".into(), ..Watermark::default() }; + assert!(matches!(add_watermark(&mut doc, &[0], &on_top, true), Err(EditError::Invalid(_)))); + assert_eq!((*doc.get(page)).clone(), before, "the old watermark stays"); + let hf = HeaderFooter { + text: [String::new(), "Top".into(), String::new(), String::new(), String::new(), String::new()], + font: added::Family::Times, + ..HeaderFooter::default() + }; + assert!(matches!(add_header_footer(&mut doc, &[0], &hf, true, &Context::default()), Err(EditError::Invalid(_)))); + assert_eq!((*doc.get(page)).clone(), before, "no fonts are added"); + let mut stamped = doc.clone(); + assert!(matches!(stamp(&mut stamped, 0, "Probe", b"/PCTimes 9 Tf\n".to_vec()), Err(EditError::Invalid(_)))); + assert_eq!((*stamped.get(page)).clone(), before, "no fonts are added"); +} diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs new file mode 100644 index 0000000000..d936955184 --- /dev/null +++ b/crates/edit/src/marks.rs @@ -0,0 +1,304 @@ +use super::{Document, EditError, Object, Rgb, added}; + +/// The kinds of page marks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarkKind { + HeaderFooter, + Watermark, + Background, +} + +impl MarkKind { + pub(crate) fn tag(self) -> &'static str { + match self { + MarkKind::HeaderFooter => "HeaderFooter", + MarkKind::Watermark => "Watermark", + MarkKind::Background => "Background", + } + } +} + +/// Header and footer settings (Acrobat's Add Header and Footer dialog). +#[derive(Clone, Debug, PartialEq)] +pub struct HeaderFooter { + /// Left, centre and right header text, then left, centre and right footer text. Tokens: + /// `<<1>>` page number, `<>` page count, `<<1 of n>>`, `<<1/n>>`, `<>`, + /// `<>`, dates `<>`, `<>`, `<>`, `<>`, + /// `<>`, `<>`, `<>`, `<>`, the dotted day-first + /// dates of German-speaking and much of Central and Eastern Europe `<>`, + /// `<>`, `<>`, `<>`, and Bates numbers `<>` + /// (digits, start, prefix, suffix). + pub text: [String; 6], + /// The standard face the text is drawn in (#3754): Helvetica (the default), Times or Courier. + pub font: added::Family, + pub font_size: f64, + pub color: Rgb, + pub underline: bool, + /// Margins in points: top, bottom, left, right (the text's distance from the page edges). + pub margins: [f64; 4], + /// The number `<<1>>` shows on the first page of the range. + pub start_number: u32, + /// Shrink the page content so the header and footer don't cover it (Acrobat's Appearance + /// Options): the original content is scaled down evenly into the band the header and footer + /// leave free, centred across the page. Remove and Update take the scaling away again. + pub shrink: bool, +} + +impl Default for HeaderFooter { + /// Acrobat's defaults: 8 pt black, margins 0.5 in top and bottom, 1 in left and right. + fn default() -> Self { + Self { + text: Default::default(), + font: added::Family::Helvetica, + font_size: 8.0, + color: [0.0; 3], + underline: false, + margins: [36.0, 36.0, 72.0, 72.0], + start_number: 1, + shrink: false, + } + } +} + +impl HeaderFooter { + /// The first character [`add_header_footer`] would refuse, because the standard fonts can't + /// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the + /// template), so checking the templates, split as `expand`'s output is, covers every page + /// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview + /// never offers text that applying refuses (#2013). + pub fn first_undrawable(&self) -> Option { + self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi) + } + + /// The room the header and the footer take at the top and the bottom of a page, in points: + /// the margin, the lines of the tallest box, and a clearance of half the font size. A side + /// without text takes none. This is the room [`HeaderFooter::shrink`] keeps free. + pub fn bands(&self) -> (f64, f64) { + let size = self.font_size; + let line_h = size * 1.2; + let lines = |boxes: &[String]| boxes.iter().filter(|t| !t.trim().is_empty()).map(|t| t.lines().count().max(1)).max(); + let (header, footer) = self.text.split_at(3); + // Header lines hang below the top margin (a full line each); the footer's last line sits + // on the bottom margin, so only its capitals (0.8 of the size) rise above it. + let top = lines(header).map_or(0.0, |n| self.margins[0] + size + (n - 1) as f64 * line_h + size / 2.0); + let bottom = lines(footer).map_or(0.0, |n| self.margins[1] + size * 0.8 + (n - 1) as f64 * line_h + size / 2.0); + (top, bottom) + } +} + +/// The `cm` that shrinks a page's original content (user space) into the band `hf` leaves free, +/// for a page shown `size` (display space) with view matrix `view` (display to user space): an +/// even scale, centred across, the content's bottom on the footer's band. +pub(crate) fn shrink_matrix(hf: &HeaderFooter, size: (f64, f64), view: [f64; 6]) -> Result<[f64; 6], EditError> { + let (w, h) = size; + let (top, bottom) = hf.bands(); + let free = h - top - bottom; + if !(free.is_finite() && free >= 1.0 && w.is_finite() && w > 0.0) { + return Err(EditError::Invalid("the header and footer margins leave no room for the page content to shrink into".into())); + } + let k = free / h; + let v = kurbo::Affine::new(view); + let det = v.determinant(); + if !(det.is_finite() && det.abs() > 1e-12) { + return Err(EditError::Invalid("the page can't be shrunk: its view matrix is degenerate".into())); + } + let shrink = kurbo::Affine::new([k, 0.0, 0.0, k, (w - w * k) / 2.0, bottom]); + // User space to display space, shrink there, and back. + Ok((v * shrink * v.inverse()).as_coeffs()) +} + +/// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in +/// the document, an image (drawn into a unit square) or a form (a page of a PDF). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MarkSource { + pub xobject: pdfcraft_cos::ObjRef, + /// Its natural size in points. + pub size: (f64, f64), + /// An image XObject (unit square); otherwise a form whose `/Matrix` maps it to `size`. + pub image: bool, +} + +/// A watermark: text, or a picture from a file (`source`; the text is then ignored). +#[derive(Clone, Debug, PartialEq)] +pub struct Watermark { + pub text: String, + pub source: Option, + /// Pictures: size relative to the page (1.0 fits the page). + pub scale: f64, + /// 0 = fit: about half the page diagonal. + pub font_size: f64, + pub color: Rgb, + /// 0–1. + pub opacity: f64, + /// Degrees counter-clockwise (Acrobat's 45° runs from bottom-left to top-right). + pub rotation: f64, + /// Draw behind the page content instead of on top. + pub behind: bool, + /// Offset of the centre from the page centre, in points (right, up). + pub offset: [f64; 2], +} + +impl Watermark { + /// The first character of a text watermark [`add_watermark`] would refuse, because the + /// standard fonts can't draw it (#125). A picture watermark draws no text. + pub fn first_undrawable(&self) -> Option { + match self.source { + Some(_) => None, + None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi), + } + } +} + +impl Default for Watermark { + fn default() -> Self { + Self { + text: String::new(), + source: None, + scale: 0.5, + font_size: 0.0, + color: [0.6, 0.6, 0.6], + opacity: 0.5, + rotation: 45.0, + behind: false, + offset: [0.0; 2], + } + } +} + +/// A background behind the page content: a solid colour, or a picture from a file. +#[derive(Clone, Debug, PartialEq)] +pub struct Background { + pub color: Rgb, + pub opacity: f64, + pub source: Option, + /// Pictures: size relative to the page (1.0 fits the page). + pub scale: f64, + /// Replace a white backdrop the page paints itself, which would hide the background + /// ([`backdrop`](crate::backdrop)). Removing the background turns it back on where that is + /// still safe ([`backdrops_left_off`](crate::backdrops_left_off) are the pages where it isn't). + pub replace_backdrop: bool, +} + +impl Default for Background { + /// A light yellow that still reads as a colour on screen (a near-white default looked as if + /// nothing had happened). + fn default() -> Self { + Self { color: [1.0, 0.95, 0.7], opacity: 1.0, source: None, scale: 1.0, replace_backdrop: true } + } +} + +/// Drawing a picture mark: fitted to the page at `scale`, centred (plus `offset`), turned by +/// `rotation` degrees. Returns the content and the resource name it uses. +pub(crate) fn picture(src: &MarkSource, page: (f64, f64), scale: f64, rotation: f64, offset: [f64; 2]) -> String { + let (w, h) = page; + let (sw, sh) = (src.size.0.max(0.01), src.size.1.max(0.01)); + let k = (w / sw).min(h / sh) * if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + let (dw, dh) = (sw * k, sh * k); + let (s, c) = rotation.to_radians().sin_cos(); + let (cx, cy) = (w / 2.0 + offset[0], h / 2.0 + offset[1]); + // Centre, rotate, then place the picture's box (unit square for images). + let place = if src.image { + format!("{} 0 0 {} {} {} cm", n(dw), n(dh), n(-dw / 2.0), n(-dh / 2.0)) + } else { + format!("{} 0 0 {} {} {} cm", n(k), n(k), n(-dw / 2.0), n(-dh / 2.0)) + }; + format!("{} {} {} {} {} {} cm\n{place}\n/PCPic{} Do\n", n(c), n(s), n(-s), n(c), n(cx), n(cy), src.xobject.num) +} + +/// Register a mark picture in the page's own resources (`/PCPic`). +pub(crate) fn add_picture_resource(doc: &mut Document, page: &pdfcraft_model::Page, src: &MarkSource) -> Result<(), EditError> { + let p = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); + let mut res = p.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut xo = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + xo.set(format!("PCPic{}", src.xobject.num).into_bytes(), Object::Ref(src.xobject)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + Ok(()) +} + +/// Values tokens need that come from outside the document. +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Context { + /// Today's date: (year, month 1–12, day 1–31). + pub date: (i64, u32, u32), +} + +// Model-derived transforms can contain tiny scale factors; retain their PDF decimal precision. +pub(crate) fn matrix_number(value: f64) -> String { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() +} + +pub(crate) fn n(v: f64) -> String { + let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +pub(crate) fn rgb(c: Rgb) -> String { + let c = c.map(|x| x.clamp(0.0, 1.0)); + format!("{} {} {} rg", n(c[0]), n(c[1]), n(c[2])) +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +/// Expand `<<…>>` tokens for page `index` (0-based within the range) of `count`. +pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &Context) -> String { + let (y, m, d) = cx.date; + let mut out = String::new(); + let mut rest = template; + while let Some(start) = rest.find("<<") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let Some(end) = after.find(">>") else { + out.push_str(&rest[start..]); + return out; + }; + let token = &after[..end]; + let p = page_number; + let value = match token { + "1" => p.to_string(), + "n" => count.to_string(), + "1 of n" => format!("{p} of {count}"), + "1/n" => format!("{p}/{count}"), + "Page 1" => format!("Page {p}"), + "Page 1 of n" => format!("Page {p} of {count}"), + "m/d" => format!("{m}/{d}"), + "m/d/yy" => format!("{m}/{d}/{:02}", y.rem_euclid(100)), + "m/d/yyyy" => format!("{m}/{d}/{y}"), + "mm/dd/yy" => format!("{m:02}/{d:02}/{:02}", y.rem_euclid(100)), + "mm/dd/yyyy" => format!("{m:02}/{d:02}/{y}"), + "d/m/yy" => format!("{d}/{m}/{:02}", y.rem_euclid(100)), + "d/m/yyyy" => format!("{d}/{m}/{y}"), + "dd/mm/yyyy" => format!("{d:02}/{m:02}/{y}"), + "d.m.yy" => format!("{d}.{m}.{:02}", y.rem_euclid(100)), + "d.m.yyyy" => format!("{d}.{m}.{y}"), + "dd.mm.yy" => format!("{d:02}.{m:02}.{:02}", y.rem_euclid(100)), + "dd.mm.yyyy" => format!("{d:02}.{m:02}.{y}"), + "yyyy-mm-dd" => format!("{y}-{m:02}-{d:02}"), + "yyyy/mm/dd" => format!("{y}/{m:02}/{d:02}"), + "yyyy/m/d" => format!("{y}/{m}/{d}"), + "mmmm d, yyyy" => format!("{} {d}, {y}", MONTHS[(m.clamp(1, 12) - 1) as usize]), + t if t.starts_with("Bates Number") => { + // Bates Number#digits#start#prefix#suffix (start is applied by the caller). + let parts: Vec<&str> = t.split('#').collect(); + let digits = parts.get(1).and_then(|d| d.parse::().ok()).unwrap_or(6).clamp(1, 15); + format!("{}{:0digits$}{}", parts.get(3).unwrap_or(&""), bates, parts.get(4).unwrap_or(&"")) + } + _ => format!("<<{token}>>"), + }; + out.push_str(&value); + rest = &after[end + 2..]; + } + out.push_str(rest); + out +} + +/// The Bates start number in a template (`<>`), if any. +pub(crate) fn bates_start(template: &str) -> Option { + let i = template.find("<>")?]; + t.split('#').nth(2).and_then(|s| s.parse().ok()).or(Some(1)) +} diff --git a/crates/edit/src/paragraph_align.rs b/crates/edit/src/paragraph_align.rs new file mode 100644 index 0000000000..8fb0a4c5e4 --- /dev/null +++ b/crates/edit/src/paragraph_align.rs @@ -0,0 +1,174 @@ +//! Alignment paragraph by paragraph. A paragraph is the text between two hard line breaks +//! (`\n`): a line typed with Enter in added text, or a source row of an edited paragraph. Each can +//! have an alignment of its own; `None` (or no entry) follows the box's or paragraph's alignment. + +use std::ops::RangeInclusive; + +use crate::added::Align; + +/// How many paragraphs `text` has (an empty text has one). +pub fn count(text: &str) -> usize { + text.bytes().filter(|b| *b == b'\n').count().saturating_add(1) +} + +/// The paragraph the byte position `at` of `text` is in. +fn paragraph_at(text: &str, at: usize) -> usize { + let bytes = text.as_bytes(); + bytes.get(..at.min(bytes.len())).unwrap_or_default().iter().filter(|b| **b == b'\n').count() +} + +/// The paragraphs a selection (byte range) of `text` touches. An empty selection is the caret's +/// paragraph; a selection ending just after a line break doesn't reach the next paragraph. +pub fn selected(text: &str, range: std::ops::Range) -> RangeInclusive { + let first = paragraph_at(text, range.start); + let last = if range.end > range.start { paragraph_at(text, range.end - 1) } else { first }; + first..=last.max(first) +} + +/// Paragraph `i`'s alignment: its own, or `base`. +pub fn align_of(aligns: &[Option], i: usize, base: Align) -> Align { + aligns.get(i).copied().flatten().unwrap_or(base) +} + +/// Give paragraphs `which` of a text with `count` paragraphs the alignment `align`; the others +/// keep theirs. With a `base`, entries equal to it become `None` ([`tidy`]), so equal alignments +/// compare equal however they were set; without one (the base is only guessed, as for an edited +/// paragraph's own rows) every entry set stays. Trailing `None`s are dropped either way. +pub fn set(aligns: &[Option], count: usize, which: RangeInclusive, align: Align, base: Option) -> Vec> { + let mut out: Vec> = (0..count).map(|i| aligns.get(i).copied().flatten()).collect(); + for i in which { + if let Some(a) = out.get_mut(i) { + *a = Some(align); + } + } + match base { + Some(base) => tidy(out, base), + None => trimmed(out), + } +} + +/// `aligns` without entries that only repeat `base`, and without trailing `None`s. +pub fn tidy(mut aligns: Vec>, base: Align) -> Vec> { + for a in &mut aligns { + if *a == Some(base) { + *a = None; + } + } + trimmed(aligns) +} + +fn trimmed(mut aligns: Vec>) -> Vec> { + while aligns.last() == Some(&None) { + aligns.pop(); + } + aligns +} + +/// Carry the alignments of `old`'s paragraphs over to `new`: the paragraphs before and after +/// the change keep theirs, and the paragraphs the change made take the alignment of the first +/// paragraph it changed, or for lines only inserted, of the paragraph before them (so Enter in or +/// after a centred paragraph makes centred ones). +pub fn remap(old: &str, new: &str, aligns: &[Option]) -> Vec> { + remap_at(old, new, aligns, None) +} + +/// [`remap`] for an edit that left the caret at byte `caret` of `new`. Repeated paragraphs can +/// make where the edit was ambiguous ("A\nA" to "A" deletes either one); the caret, at the edit's +/// end, says which. It only chooses between readings that fit the texts, so a caret away from the +/// edit never makes it an edit of other paragraphs. +pub fn remap_at(old: &str, new: &str, aligns: &[Option], caret: Option) -> Vec> { + if aligns.is_empty() || old == new { + return aligns.to_vec(); + } + let (o, n): (Vec<&str>, Vec<&str>) = (old.split('\n').collect(), new.split('\n').collect()); + let shortest = o.len().min(n.len()); + let same_start = o.iter().zip(&n).take_while(|(a, b)| a == b).count().min(shortest); + let same_end = o.iter().rev().zip(n.iter().rev()).take_while(|(a, b)| a == b).count().min(shortest); + let fewest_kept = shortest - same_start; + let (prefix, suffix) = match caret { + Some(at) if fewest_kept <= same_end => { + // The edit's paragraphs of `new` end before the caret's paragraph when the caret starts + // one, and with it otherwise. + let starts = at == 0 || at.checked_sub(1).and_then(|i| new.as_bytes().get(i)) == Some(&b'\n'); + let end = paragraph_at(new, at).saturating_add(usize::from(!starts)); + let suffix = n.len().saturating_sub(end).clamp(fewest_kept, same_end); + (shortest - suffix, suffix) + } + _ => (same_start, same_end.min(shortest - same_start)), + }; + let at = |i: usize| aligns.get(i).copied().flatten(); + let inserted_only = o.len() - prefix - suffix == 0; + let changed = if inserted_only && prefix > 0 { at(prefix - 1) } else { at(prefix) }; + let mut out: Vec> = (0..prefix).map(at).collect(); + out.extend(std::iter::repeat_n(changed, n.len() - prefix - suffix)); + out.extend((o.len() - suffix..o.len()).map(at)); + trimmed(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use Align::*; + + #[test] + fn a_selection_names_the_paragraphs_it_touches() { + let text = "One\nTwo\nThree"; + assert_eq!(count(text), 3); + assert_eq!(count(""), 1); + assert_eq!(selected(text, 0..0), 0..=0, "the caret's paragraph"); + assert_eq!(selected(text, 5..6), 1..=1); + assert_eq!(selected(text, 2..9), 0..=2); + assert_eq!(selected(text, 4..8), 1..=1, "a whole line with its break stays on that line"); + assert_eq!(selected(text, 0..999), 0..=2, "past the end is clamped"); + // Not a character boundary: still counted by bytes, never a panic. + assert_eq!(selected("é\nb", 1..1), 0..=0); + } + + #[test] + fn setting_one_paragraph_keeps_the_others_and_tidies() { + let one = set(&[], 3, 1..=1, Center, Some(Left)); + assert_eq!(one, [None, Some(Center)]); + assert_eq!(align_of(&one, 0, Left), Left); + assert_eq!(align_of(&one, 1, Left), Center); + assert_eq!(align_of(&one, 9, Right), Right); + assert_eq!(set(&one, 3, 1..=1, Left, Some(Left)), [], "back to the box's alignment is no entry at all"); + assert_eq!(set(&one, 3, 2..=7, Right, Some(Left)), [None, Some(Center), Some(Right)], "paragraphs past the end are ignored"); + assert_eq!(set(&[Some(Right); 9], 2, 0..=0, Center, Some(Left)), [Some(Center), Some(Right)], "entries past the text are dropped"); + assert_eq!(set(&[], 3, 0..=0, Left, None), [Some(Left)], "without a base, an explicit entry stays"); + } + + #[test] + fn alignments_follow_their_paragraphs_through_an_edit() { + let aligns = [Some(Center), None, Some(Right)]; + assert_eq!(remap("A\nB\nC", "A\nB\nC", &aligns), aligns); + // A line inserted before the last keeps the last right-aligned. + assert_eq!(remap("A\nB\nC", "A\nB\nnew\nC", &aligns), [Some(Center), None, None, Some(Right)]); + // Enter inside the centred first paragraph: both halves are centred. + assert_eq!(remap("Ab\nB\nC", "A\nb\nB\nC", &aligns), [Some(Center), Some(Center), None, Some(Right)]); + // The middle paragraph deleted. + assert_eq!(remap("A\nB\nC", "A\nC", &aligns), [Some(Center), Some(Right)]); + // Everything replaced takes the first changed paragraph's alignment. + assert_eq!(remap("A\nB", "x", &[None, Some(Right)]), []); + assert_eq!(remap("A\nA", "A", &[Some(Center), Some(Right)]), [Some(Center)]); + assert_eq!(remap("A", "A\nA", &[Some(Center)]), [Some(Center), Some(Center)]); + } + + /// Repeated rows make the edit's place ambiguous: "A\nA" to "A" deletes either row. The caret, + /// where the edit ended, settles which (CodeRabbit on 648dd428). + #[test] + fn the_caret_says_which_of_two_same_rows_an_edit_took() { + let aligns = [Some(Center), Some(Right)]; + assert_eq!(remap_at("A\nA", "A", &aligns, Some(0)), [Some(Right)], "the first row and its break deleted"); + assert_eq!(remap_at("A\nA", "A", &aligns, Some(1)), [Some(Center)], "the break and the second row deleted"); + assert_eq!(remap_at("A\nA", "A", &aligns, None), [Some(Center)], "no caret: as before"); + // Enter at the start of the first of two same rows: the new row is before it. + assert_eq!(remap_at("A\nA", "\nA\nA", &aligns, Some(1)), [Some(Center), Some(Center), Some(Right)]); + // Enter at the end of the second: the new row follows it. + assert_eq!(remap_at("A\nA", "A\nA\n", &aligns, Some(4)), [Some(Center), Some(Right), Some(Right)]); + // A caret away from the edit never makes it an edit of other rows. + assert_eq!(remap_at("A\nB\nC", "A\nB\nCx", &[Some(Center), Some(Right), None], Some(0)), [Some(Center), Some(Right)]); + // A caret past the end, or not on a character boundary, is no panic. + assert_eq!(remap_at("A\nA", "A", &aligns, Some(99)), [Some(Center)]); + assert_eq!(remap_at("é\né", "é", &aligns, Some(1)), [Some(Center)]); + } +} diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs new file mode 100644 index 0000000000..2f2c24c862 --- /dev/null +++ b/crates/edit/src/place.rs @@ -0,0 +1,424 @@ +use super::marks::{MarkKind, matrix_number, n}; +use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi}; + +pub(crate) fn page_list(doc: &Document) -> Vec { + pdfcraft_model::pages(doc) +} + +pub(crate) fn check(pages: &[usize], count: usize) -> Result<(), EditError> { + match pages.iter().find(|p| **p >= count) { + Some(p) => Err(EditError::NoSuchPage(*p)), + None => Ok(()), + } +} + +/// The page's `/Contents` as a list of stream references (inline content is promoted). +pub(crate) fn contents(doc: &mut Document, page: &pdfcraft_model::Page) -> Result, EditError> { + let obj = doc.get(page.obj); + let d = obj.as_dict().cloned().unwrap_or_default(); + Ok(match d.get(b"Contents").cloned() { + None => Vec::new(), + Some(Object::Ref(r)) => match doc.get(r).as_array() { + Some(a) => a.clone(), + None => vec![Object::Ref(r)], + }, + Some(Object::Array(a)) => a, + Some(other) => vec![Object::Ref(doc.add(other))], + }) +} + +/// Add the font (and an opacity state) to the page's own resources. +pub(crate) fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Option, content: Option<&[u8]>) -> Result<(), EditError> { + let mut faces: Vec<(Vec, &str)> = Vec::new(); + for (name, base) in [(&b"PCHelv"[..], "Helvetica"), (b"PCTimes", "Times-Roman"), (b"PCCour", "Courier")] { + // Helvetica always (marks use it); the others only when the content names them. + if name != b"PCHelv" && !content.is_some_and(|c| c.windows(name.len() + 1).any(|w| w[0] == b'/' && &w[1..] == name)) { + continue; + } + faces.push((name.to_vec(), base)); + } + add_page_resources(doc, page, opacity, &faces) +} + +/// The fonts a page draws with: its own resources' or the ones it inherits. +pub(crate) fn page_fonts(doc: &Document, page: &pdfcraft_model::Page) -> Dict { + let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default() +} + +/// A name in `fonts` for the standard face `base` that can't take over a font the page already +/// draws with: `preferred` when it is free or already names exactly the dictionary +/// [`add_page_resources`] writes for that face, otherwise the first such `preferred_1`, +/// `preferred_2`… (one of the first `fonts.len() + 1` always is). A font with anything more +/// (`/ToUnicode`, `/Widths`…) is someone else's, even in the same face. +pub(crate) fn free_font_name(doc: &Document, fonts: &Dict, preferred: &str, base: &str) -> String { + let usable = |name: &str| match fonts.get(name.as_bytes()) { + None => true, + Some(font) => doc.resolve(font).as_dict().is_some_and(|d| { + d.len() == 4 + && d.name(b"Type") == Some(b"Font") + && d.name(b"Subtype") == Some(b"Type1") + && d.name(b"BaseFont") == Some(base.as_bytes()) + && d.name(b"Encoding") == Some(b"WinAnsiEncoding") + }), + }; + if usable(preferred) { + return preferred.to_string(); + } + (1..=fonts.len().saturating_add(1)).map(|k| format!("{preferred}_{k}")).find(|name| usable(name)).unwrap_or_else(|| preferred.to_string()) +} + +/// Add the standard `faces` (resource name, base font) and an opacity state to the page's own +/// resources. +pub(crate) fn add_page_resources( + doc: &mut Document, + page: &pdfcraft_model::Page, + opacity: Option, + faces: &[(Vec, &str)], +) -> Result<(), EditError> { + let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + for (name, base) in faces { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(base)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(name.clone(), Object::Dict(font)); + } + res.set(b"Font".to_vec(), Object::Dict(fonts)); + if let Some(o) = opacity { + let mut gs = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + let mut s = Dict::new(); + s.set(b"Type".to_vec(), Object::name("ExtGState")); + s.set(b"CA".to_vec(), Object::Real(o)); + s.set(b"ca".to_vec(), Object::Real(o)); + let name = format!("PCGS{}", (o * 100.0).round() as i64); + gs.set(name.into_bytes(), Object::Dict(s)); + res.set(b"ExtGState".to_vec(), Object::Dict(gs)); + } + // The page gets its own (possibly copied) resources; shared dictionaries stay untouched. + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + Ok(()) +} + +pub(crate) const WRAP_OPEN: &[u8] = b"q %PdfCraft\n"; + +/// Starts with an end of line, so a comment left open at the end of the content before it +/// can't swallow the `Q`. Older files have the close without it. +pub(crate) const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n"; + +pub(crate) const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n"; + +/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing +/// graphics closures. Legacy closes without the leading newline remain valid on their own. +pub(crate) fn is_wrap_close(bytes: &[u8]) -> bool { + let mut rest = bytes; + while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) { + rest = shorter; + } + let legacy = rest.len() == bytes.len(); + loop { + if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) { + return true; + } + match rest.strip_prefix(b"\nQ".as_slice()) { + Some(shorter) => rest = shorter, + None => return false, + } + } +} + +/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)` +/// for a close. +pub(crate) fn wrapper_role(doc: &Document, o: &Object) -> Option { + if stream_tag(doc, o).as_deref() != Some("Wrap") { + return None; + } + let Object::Stream(stream) = &*doc.resolve(o) else { return None }; + // Wrappers are a few bytes; one that decodes to more isn't PdfCraft's. + let bytes = stream.decoded_within(64 * 1024).ok()?; + if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) } +} + +#[cfg(test)] +pub(crate) fn stream_bytes(doc: &Document, o: &Object) -> Option> { + match &*doc.resolve(o) { + Object::Stream(s) => s.decoded().ok(), + _ => None, + } +} + +/// The `/PCMark` tag in a content stream's dictionary: a mark kind, or `Wrap` for the q/Q +/// wrapper. Read from the dictionary, so finding marks never decompresses page content. +pub(crate) fn stream_tag(doc: &Document, o: &Object) -> Option { + let obj = doc.resolve(o); + obj.as_dict()?.name(b"PCMark").map(|n| String::from_utf8_lossy(n).into_owned()) +} + +pub(crate) fn tagged(tag: &str) -> Dict { + let mut d = Dict::new(); + d.set(b"PCMark".to_vec(), Object::name(tag)); + d +} + +/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags, +/// which another writer may keep, copy or forge). +pub(crate) enum Isolation { + /// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths. + /// `suffix`: the stream from which the content leaves graphics state set (or a comment open) + /// that a mark on top must not inherit, so it needs a wrapper from there to the end. + /// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes. + /// `marked`: unfinished marked-content sequences to close before a new mark. + Clean { suffix: Option, wrappers: Vec<(usize, usize)>, marked: usize }, + /// The content could not be read that precisely (it doesn't decode, is too large, or has + /// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is + /// the count of `q` left open at the end, when known. `marked` counts known unfinished + /// marked-content sequences independently of graphics malformation, or zero when unknown. + Unknown { unclosed: usize, marked: usize }, + /// Marked content nests past [`ISOLATION_MAX_DEPTH`]: how many sequences a mark on top would + /// have to close (some may be hidden optional content) is unknown. + TooDeep, +} + +const ISOLATION_MAX_STREAMS: usize = 100_000; + +const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024; + +const ISOLATION_MAX_OPS: usize = 1_000_000; + +pub(crate) const ISOLATION_MAX_DEPTH: usize = 10_000; + +pub(crate) fn isolation(doc: &Document, list: &[Object]) -> Isolation { + let unknown = Isolation::Unknown { unclosed: 0, marked: 0 }; + if list.len() > ISOLATION_MAX_STREAMS { + return unknown; + } + let mut remaining = ISOLATION_MAX_BYTES; + let mut data = Vec::with_capacity(list.len()); + for object in list { + let resolved = doc.resolve(object); + let Object::Stream(stream) = &*resolved else { return unknown }; + let Ok(bytes) = stream.decoded_within(remaining) else { return unknown }; + let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown }; + remaining = left; + data.push(bytes); + } + let pieces = pdfcraft_content::Pieces::join(&data); + let parsed = pieces.parse(); + if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS { + return unknown; + } + let edges = pieces.comment_edges(&parsed); + let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false); + let mut stack: Vec = Vec::new(); + let (mut marked, mut marked_known) = (0usize, true); + let mut split = 0; + let mut previous_last = None; + let mut wrappers = Vec::new(); + for op in &parsed.ops { + let (first, last) = pieces.pieces_of(op); + // A wrapper can only go in before a whole stream, outside graphics and marked-content + // scopes, between operators, where no comment would swallow the wrapper's `q`. + if stack.is_empty() + && marked_known + && marked == 0 + && !text_open + && !path_open + && !dirty + && previous_last.is_none_or(|previous| first > previous) + && !edges.get(first).is_some_and(|edge| edge.0) + { + split = first; + } + previous_last = Some(last); + // Match the reader's tolerant operands: BMC uses the last name, BDC the last + // name/object pair, and EMC ignores surplus operands. Invalid begins are ignored. + let begin_marked = match op.op.as_slice() { + b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))), + b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))), + _ => false, + }; + if marked_known { + if begin_marked { + if marked < ISOLATION_MAX_DEPTH { + marked += 1; + } else { + // Once the budget is exceeded, later EMCs cannot restore a known depth. + marked_known = false; + marked = 0; + } + } else if op.is("EMC") { + marked = marked.saturating_sub(1); + } + } + match op.op.as_slice() { + b"BT" if !text_open => text_open = true, + b"ET" if text_open => text_open = false, + b"BT" | b"ET" => malformed = true, + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true, + b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false, + _ => {} + } + match op.op.as_slice() { + b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first), + b"Q" if op.operands.is_empty() => match stack.pop() { + Some(open) => { + // Only a standalone PdfCraft wrapper pair may later be removed again. + let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap")); + if wrap(open) + && wrap(first) + && data.get(open).is_some_and(|d| d == WRAP_OPEN) + && data.get(first).is_some_and(|d| is_wrap_close(d)) + { + wrappers.push((open, first)); + } + } + None => malformed = true, + }, + b"q" | b"Q" => malformed = true, + _ if stack.is_empty() => dirty = true, + _ => {} + } + } + if !marked_known { + return Isolation::TooDeep; + } + if malformed || text_open || path_open || !stack.is_empty() { + return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked }; + } + let comment_open = edges.last().is_some_and(|edge| edge.1); + Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked } +} + +/// Put a mark's content on a page: behind (with the behind-marks at the start) or on top +/// (appended, after wrapping the original content in q/Q). +pub(crate) fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> { + place_tagged(doc, page, kind.tag(), content, behind).map(drop) +} + +/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the +/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including +/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of +/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content +/// sequences are closed at the end too, so hidden optional content cannot hide the new mark; when +/// their number is unknown, nothing is placed on top. Returns the new content stream. +pub(crate) fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result { + let mut list = contents(doc, page)?; + // The behind-marks at the start: backgrounds in the order added, then watermarks. + let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()]; + let behind_marks = list.iter().take_while(|o| stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).count(); + let wrap = if behind { + None + } else { + match isolation(doc, &list) { + Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)), + // Content that can't be read precisely is wrapped whole, as before: after the + // behind-marks at the start. A `q` it leaves open is closed at its end, where + // closing it changes nothing already drawn. + Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)), + // Content left inside an optional-content sequence it cannot close could be hidden. + Isolation::TooDeep => return Err(too_deep_on_top()), + } + }; + let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content))); + let mark = Object::Ref(mark_ref); + if behind { + // Right after the backgrounds already there, so a new background is painted over the + // older ones (#3591) and a watermark over all of them. Not after the watermarks: on a + // page with no content, one on top is at the start too and can't be told apart, so a + // newer watermark behind goes under the older ones. + let at = list.iter().take(behind_marks).rposition(|o| stream_tag(doc, o).as_deref() == Some(MarkKind::Background.tag())).map_or(0, |i| i + 1); + list.insert(at, mark); + } else { + if let Some((split, unclosed, marked)) = wrap { + let mut close_bytes = b"\nEMC".repeat(marked); + close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed)); + close_bytes.extend_from_slice(WRAP_CLOSE); + let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec())))); + let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes)))); + list.insert(split.min(list.len()), open); + list.push(close); + } + list.push(mark); + } + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + Ok(mark_ref) +} + +fn too_deep_on_top() -> EditError { + EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into()) +} + +/// The refusal [`place_tagged`] gives for a mark on top of `page`, checked before an edit changes +/// anything (removing old marks, adding resources) so a refused edit leaves the document as it was. +pub(crate) fn check_on_top(doc: &Document, page: &pdfcraft_model::Page) -> Result<(), EditError> { + let obj = doc.get(page.obj); + let list = match obj.as_dict().and_then(|d| d.get(b"Contents")) { + None => return Ok(()), + Some(Object::Ref(r)) => doc.get(*r).as_array().cloned().unwrap_or_else(|| vec![Object::Ref(*r)]), + Some(Object::Array(a)) => a.clone(), + Some(other) => vec![other.clone()], + }; + match isolation(doc, &list) { + Isolation::TooDeep => Err(too_deep_on_top()), + _ => Ok(()), + } +} + +/// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original +/// content and nothing else (a header's shrink). A page without a wrapper has no original content +/// on it and is left alone. +pub(crate) fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec) -> Result<(), EditError> { + let mut list = contents(doc, page)?; + let Some(open) = list.iter().position(|o| stream_tag(doc, o).as_deref() == Some("Wrap")) else { return Ok(()) }; + let stream = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content)))); + list.insert(open + 1, stream); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + Ok(()) +} + +/// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica +/// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes +/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes. +/// Returns the new content stream. +pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result { + let all = page_list(doc); + check(&[page], all.len())?; + check_on_top(doc, &all[page])?; + add_resources(doc, &all[page], None, Some(&content))?; + let p = page_list(doc).swap_remove(page); + place_tagged(doc, &p, tag, content, false) +} + +pub(crate) fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String { + format!( + "q\n/Artifact <> BDC\n{} {} {} {} {} {} cm\n", + kind.tag(), + matrix_number(matrix[0]), + matrix_number(matrix[1]), + matrix_number(matrix[2]), + matrix_number(matrix[3]), + matrix_number(matrix[4]), + matrix_number(matrix[5]) + ) +} + +pub(crate) const END: &str = "EMC\nQ\n"; + +/// The refusal for page text with `c`, which nothing here can draw. +pub(crate) fn undrawable(c: char) -> EditError { + EditError::Invalid(undrawable_message(c)) +} + +/// The wording of the refusal for text with `c`, which the standard fonts can't draw. +pub fn undrawable_message(c: char) -> String { + format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c)) +} + +pub(crate) fn text_op(x: f64, y: f64, text: &str) -> Vec { + let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes(); + v.extend(literal(&win_ansi(text))); + v.extend_from_slice(b" Tj\n"); + v +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f2def2fd6a..d45e983a2e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -54,12 +54,60 @@ fn cx() -> Context { Context { date: (2026, 10, 1) } } +#[test] +fn an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix() { + // 100,100..300,200 is 200 x 100 around (200, 150). + let mut doc = image_page(); + let r0 = images::page_images(&doc, 0).unwrap()[0].rect; + let t = images::turn_by_degrees(r0, 30.0).unwrap(); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap(); + let doc = reopen(&doc); + let r = images::page_images(&doc, 0).unwrap()[0].rect; + let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + let (w, h) = (200.0 * c + 100.0 * s, 200.0 * s + 100.0 * c); + assert!(close(r, [200.0 - w / 2.0, 150.0 - h / 2.0, 200.0 + w / 2.0, 150.0 + h / 2.0]), "{r:?}"); + // 90 degrees is the quarter turn; whole turns and non-finite angles are harmless. + let q = images::turn_about_centre(r0, 1, false, false); + let d = images::turn_by_degrees(r0, 90.0).unwrap(); + assert!(q.iter().zip(&d).all(|(a, b)| (a - b).abs() < 1e-9), "{q:?} {d:?}"); + let full = images::turn_by_degrees(r0, 720.0).unwrap(); + assert!(full.iter().zip([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9)); + assert!(images::turn_by_degrees(r0, f64::NAN).is_none() && images::turn_by_degrees(r0, f64::INFINITY).is_none()); +} + +#[test] +fn an_added_image_crop_keeps_some_of_each_axis() { + use crate::added::AddedImage; + assert!(AddedImage::crop_valid([0.7, 0.0, 0.0, 0.95])); + assert!(AddedImage::crop_valid([0.5, 0.45, 0.45, 0.5])); + // 0.05 + 0.9 rounds to just above 0.95: still the 5 % kept, accepted and left as it is. + assert!(AddedImage::crop_valid([0.05, 0.0, 0.9, 0.0])); + assert_eq!(AddedImage::clamp_crop([0.05, 0.0, 0.9, 0.0]), [0.05, 0.0, 0.9, 0.0]); + assert!(!AddedImage::crop_valid([0.06, 0.0, 0.9, 0.0]), "more than 95 % across"); + assert!(!AddedImage::crop_valid([0.6, 0.0, 0.4, 0.0]), "nothing left across"); + assert!(!AddedImage::crop_valid([-0.1, 0.0, 0.0, 0.0])); + assert!(!AddedImage::crop_valid([f64::NAN, 0.0, 0.0, 0.0])); + assert_eq!(AddedImage::clamp_crop([0.7, 0.0, 0.0, 0.0]), [0.7, 0.0, 0.0, 0.0], "a valid crop stays as it is"); + // From a file: too much across keeps the two sides' proportion; nonsense trims nothing. + let [l, b, r, t] = AddedImage::clamp_crop([0.6, f64::INFINITY, 0.6, -1.0]); + assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12 && b == 0.0 && t == 0.0); + // Sides beyond the whole image keep their proportion too, and huge ones don't overflow. + let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.5, 0.0]); + assert!((l - 0.7125).abs() < 1e-12 && (r - 0.2375).abs() < 1e-12, "{l} {r}"); + let [l, _, r, _] = AddedImage::clamp_crop([f64::MAX, 0.0, f64::MAX, 0.0]); + assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12, "{l} {r}"); + let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.0, 0.0]); + assert!((l - 0.95).abs() < 1e-12 && r == 0.0, "one side alone is capped at the limit"); +} + #[test] fn tokens_expand() { let c = cx(); assert_eq!(expand("Page <<1>> of <>", 3, 10, 0, &c), "Page 3 of 10"); assert_eq!(expand("<<1 of n>> · <> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5"); assert_eq!(expand("<> <> <>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026"); + assert_eq!(expand("<> <>", 1, 1, 0, &c), "2026/10/01 2026/10/1"); + assert_eq!(expand("<> <> <> <>", 1, 1, 0, &c), "01.10.2026 1.10.2026 01.10.26 1.10.26"); assert_eq!(expand("<>", 1, 1, 104, &c), "ABC000104-X"); assert_eq!(expand("keep <> and <> and <>"), Some(100)); @@ -97,6 +145,99 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() assert_eq!(marks_present(&doc), [MarkKind::HeaderFooter]); } +#[test] +fn header_and_footer_are_drawn_in_the_chosen_standard_face() { + // #3754: Times or Courier instead of Helvetica. The content names the face, the page's fonts + // gain it, and right-aligned text is placed by that face's widths (not Helvetica's). + let text = "Wide words"; + for (font, resource, base) in [(added::Family::Times, "PCTimes", "Times-Roman"), (added::Family::Courier, "PCCour", "Courier")] { + let mut doc = fixture(); + let hf = HeaderFooter { + text: [String::new(), String::new(), text.into(), String::new(), String::new(), String::new()], + font, + ..HeaderFooter::default() + }; + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + let doc = reopen(&doc); + let mark = streams(&doc, 0).last().cloned().unwrap(); + assert!(mark.contains(&format!("/{resource} 8 Tf")), "{base}: {mark}"); + let x = 600.0 - 72.0 - font.width(text, 8.0, false, false); + assert!((x - (600.0 - 72.0 - pdfcraft_fonts::helvetica_width(text, 8.0))).abs() > 1.0, "{base}: the faces differ in width"); + assert!(mark.contains(&format!("1 0 0 1 {} ", n(x))), "{base}: right-aligned by its own widths: {mark}"); + let page = &pdfcraft_model::pages(&doc)[0]; + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap(); + let face = doc.resolve(fonts.get(resource.as_bytes()).unwrap()).as_dict().cloned().unwrap(); + assert_eq!(face.name(b"BaseFont"), Some(base.as_bytes()), "{base}"); + } + // The default is still Helvetica, and only Helvetica is added. + let mut doc = fixture(); + let hf = + HeaderFooter { text: [text.into(), String::new(), String::new(), String::new(), String::new(), String::new()], ..HeaderFooter::default() }; + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + let mark = streams(&reopen(&doc), 0).last().cloned().unwrap(); + assert!(mark.contains("/PCHelv 8 Tf") && !mark.contains("/PCTimes") && !mark.contains("/PCCour"), "{mark}"); +} + +#[test] +fn a_header_never_takes_over_a_font_name_the_page_already_draws_with() { + // From review of #3756: a page that already draws a different font as /PCTimes keeps it; the + // header's Times goes under a free name, and an update reuses that name, not a third one. + let mut doc = fixture(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut fonts = Dict::new(); + let mut theirs = Dict::new(); + theirs.set(b"Type".to_vec(), Object::name("Font")); + theirs.set(b"Subtype".to_vec(), Object::name("Type1")); + theirs.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold")); + fonts.set(b"PCTimes".to_vec(), Object::Dict(theirs)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap(); + let hf = HeaderFooter { + text: ["Serif".into(), String::new(), String::new(), String::new(), String::new(), String::new()], + font: added::Family::Times, + ..HeaderFooter::default() + }; + let base_of = |doc: &Document, name: &[u8]| { + let fonts = page_fonts(doc, &pdfcraft_model::pages(doc)[0]); + fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().and_then(|d| d.name(b"BaseFont")).map(<[u8]>::to_vec)) + }; + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + let doc = reopen(&doc); + assert_eq!(base_of(&doc, b"PCTimes").as_deref(), Some(&b"Helvetica-Bold"[..]), "the page's own font is untouched"); + assert_eq!(base_of(&doc, b"PCTimes_1").as_deref(), Some(&b"Times-Roman"[..])); + let mark = streams(&doc, 0).last().cloned().unwrap(); + assert!(mark.contains("/PCTimes_1 8 Tf"), "{mark}"); + // Updating the header finds its own name again. + let mut doc = doc; + add_header_footer(&mut doc, &[0], &hf, true, &cx()).unwrap(); + let doc = reopen(&doc); + assert!(streams(&doc, 0).last().unwrap().contains("/PCTimes_1 8 Tf")); + assert_eq!(base_of(&doc, b"PCTimes_2"), None, "no new name on update"); + assert_eq!(base_of(&doc, b"PCTimes").as_deref(), Some(&b"Helvetica-Bold"[..])); + // The same face with more to it (here a /ToUnicode map) is the page's own too: kept whole. + let mut doc = fixture(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let map = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/CIDInit /ProcSet findresource begin"))); + let mut theirs = Dict::new(); + for (k, v) in [("Type", "Font"), ("Subtype", "Type1"), ("BaseFont", "Times-Roman"), ("Encoding", "WinAnsiEncoding")] { + theirs.set(k.as_bytes().to_vec(), Object::name(v)); + } + theirs.set(b"ToUnicode".to_vec(), Object::Ref(map)); + let mut fonts = Dict::new(); + fonts.set(b"PCTimes".to_vec(), Object::Dict(theirs)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap(); + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + let doc = reopen(&doc); + let fonts = page_fonts(&doc, &pdfcraft_model::pages(&doc)[0]); + let kept = doc.resolve(fonts.get(b"PCTimes").unwrap()).as_dict().cloned().unwrap(); + assert!(kept.contains(b"ToUnicode"), "the page's map is kept"); + assert!(streams(&doc, 0).last().unwrap().contains("/PCTimes_1 8 Tf")); +} + #[test] fn header_start_number_that_would_wrap_is_refused_without_drawing() { // u32::MAX is a valid start_number on its own, but there is no room for a second page @@ -156,6 +297,100 @@ fn replace_and_remove_restore_the_original_content() { assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content"); } +/// The six numbers of the first `… cm` in `content`. +fn first_cm(content: &str) -> [f64; 6] { + let line = content.lines().find(|l| l.ends_with(" cm")).unwrap_or_else(|| panic!("no cm in {content:?}")); + let v: Vec = line.split_whitespace().take(6).map(|x| x.parse().unwrap()).collect(); + <[f64; 6]>::try_from(v).unwrap() +} + +fn map_point(m: [f64; 6], x: f64, y: f64) -> (f64, f64) { + (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]) +} + +fn near(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01 +} + +fn shrinking(header: &str, footer: &str) -> HeaderFooter { + let mut hf = HeaderFooter { shrink: true, ..HeaderFooter::default() }; + hf.text[1] = header.into(); + hf.text[5] = footer.into(); + hf +} + +#[test] +fn shrink_scales_the_original_content_into_the_band_between_header_and_footer() { + let mut doc = fixture(); + // 8 pt text, margins 36 top and bottom: the header takes 36 + 8 (+ 4 clearance) at the top, + // the footer 36 + 6.4 (its capitals above the baseline, + 4) at the bottom. + add_header_footer(&mut doc, &[0], &shrinking("ACME", "<>"), false, &cx()).unwrap(); + let doc = reopen(&doc); + let s = streams(&doc, 0); + // q-wrapper, shrink, original, Q-wrapper, header/footer. + assert_eq!(s.len(), 5, "{s:?}"); + assert_eq!((s[0].as_str(), s[3].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n")); + assert!(s[2].contains("(Hi) Tj"), "the original content follows the shrink: {s:?}"); + let m = first_cm(&s[1]); + let k = (800.0 - 48.0 - 46.4) / 800.0; + assert!((m[0] - k).abs() < 1e-3 && (m[3] - k).abs() < 1e-3 && m[1] == 0.0 && m[2] == 0.0, "uniform scale {k}: {m:?}"); + // The page's corners land on the band, centred across. + assert!(near(map_point(m, 0.0, 0.0), ((600.0 - 600.0 * k) / 2.0, 46.4)), "{m:?}"); + assert!(near(map_point(m, 600.0, 800.0), ((600.0 + 600.0 * k) / 2.0, 800.0 - 48.0)), "{m:?}"); + assert!(s[4].contains("(000001) Tj"), "{}", s[4]); +} + +#[test] +fn shrink_follows_the_display_space_of_a_rotated_page() { + let mut doc = fixture(); + // Page 2 is rotated 90°: shown 800 wide and 600 high. A footer alone keeps the top. + add_header_footer(&mut doc, &[1], &shrinking("", "<<1>>"), false, &cx()).unwrap(); + let s = streams(&doc, 1); + let m = first_cm(&s[1]); + let page = &pdfcraft_model::pages(&doc)[1]; + let v = page.view_matrix(&doc); + let k = (600.0 - 46.4) / 600.0; + // Display (x, y) is user v·(x, y): the shrunk content's display corners. + let to_user = |x: f64, y: f64| map_point(v, x, y); + assert!(near(map_point(m, to_user(0.0, 0.0).0, to_user(0.0, 0.0).1), to_user((800.0 - 800.0 * k) / 2.0, 46.4)), "{m:?}"); + assert!(near(map_point(m, to_user(800.0, 600.0).0, to_user(800.0, 600.0).1), to_user((800.0 + 800.0 * k) / 2.0, 600.0)), "{m:?}"); +} + +#[test] +fn shrink_is_undone_by_remove_and_not_doubled_by_update() { + let mut doc = fixture(); + let original = streams(&doc, 0); + add_background(&mut doc, &[0], &Background::default(), false).unwrap(); + add_header_footer(&mut doc, &[0], &shrinking("First", ""), false, &cx()).unwrap(); + add_header_footer(&mut doc, &[0], &shrinking("Second", ""), true, &cx()).unwrap(); + let s = streams(&doc, 0); + assert_eq!(s.iter().filter(|x| x.contains(" cm") && !x.contains("/PCMark")).count(), 2, "one shrink besides the original's own cm: {s:?}"); + assert!(s[0].contains("/PCMark /Background"), "the background stays behind, full size: {s:?}"); + // Update without shrinking: back to full size. + let mut plain = shrinking("Third", ""); + plain.shrink = false; + add_header_footer(&mut doc, &[0], &plain, true, &cx()).unwrap(); + assert_eq!(streams(&doc, 0).len(), 5, "background, wrapper, original, wrapper, header: {:?}", streams(&doc, 0)); + add_header_footer(&mut doc, &[0], &shrinking("Fourth", ""), true, &cx()).unwrap(); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 2, "the header and its shrink"); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1); + assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content"); +} + +#[test] +fn shrink_with_no_room_left_is_refused_without_drawing() { + let mut doc = fixture(); + let mut hf = shrinking("Top", "Bottom"); + hf.margins = [400.0, 400.0, 72.0, 72.0]; + let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err(); + assert!(err.to_string().contains("no room"), "{err}"); + assert!(!doc.is_modified()); + // A page without content has nothing to shrink: it just gets the header. + let mut doc = fixture(); + add_header_footer(&mut doc, &[2], &shrinking("Top", ""), false, &cx()).unwrap(); + assert_eq!(streams(&doc, 2).len(), 1); +} + #[test] fn watermarks_rotate_fade_and_can_go_behind() { let mut doc = fixture(); @@ -168,6 +403,45 @@ fn watermarks_rotate_fade_and_can_go_behind() { assert_eq!(s.len(), 2, "no wrapper needed for content behind"); } +#[test] +fn a_second_background_is_drawn_over_the_first() { + // #3591: the new background went in front of the old one in /Contents, so the old colour + // was painted over it and stayed. + let mut doc = fixture(); + add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], opacity: 1.0, ..Background::default() }, false).unwrap(); + add_background(&mut doc, &[0], &Background { color: [0.0, 1.0, 0.0], opacity: 1.0, ..Background::default() }, false).unwrap(); + let s = streams(&doc, 0); + assert_eq!(s.len(), 3, "{s:?}"); + assert!(s[0].contains("0 0 1 rg") && s[1].contains("0 1 0 rg"), "the newer background is painted last: {s:?}"); + assert!(s[2].contains("(Hi) Tj"), "both stay behind the content: {s:?}"); +} + +#[test] +fn backgrounds_stay_below_watermarks_behind_the_content() { + // Behind the content, backgrounds come first, in the order added, and then watermarks, so a + // watermark added after a background shows and a later background doesn't cover it. + let mut doc = fixture(); + add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], opacity: 1.0, ..Background::default() }, false).unwrap(); + add_watermark(&mut doc, &[0], &Watermark { text: "DRAFT".into(), behind: true, ..Watermark::default() }, false).unwrap(); + add_background(&mut doc, &[0], &Background { color: [0.0, 1.0, 0.0], opacity: 1.0, ..Background::default() }, false).unwrap(); + let s = streams(&doc, 0); + assert_eq!(s.len(), 4, "{s:?}"); + assert!(s[0].contains("0 0 1 rg") && s[1].contains("0 1 0 rg"), "backgrounds first, oldest first: {s:?}"); + assert!(s[2].contains("/PCMark /Watermark") && s[3].contains("(Hi) Tj"), "then the watermark, then the content: {s:?}"); +} + +#[test] +fn a_watermark_behind_goes_under_one_on_top_of_a_page_without_content() { + // With no content of its own, the page's watermark on top is its first stream, which looks + // like a behind-mark; a watermark added behind later must still be painted under it. + let mut doc = fixture(); + add_watermark(&mut doc, &[2], &Watermark { text: "TOP".into(), ..Watermark::default() }, false).unwrap(); + add_watermark(&mut doc, &[2], &Watermark { text: "BEHIND".into(), behind: true, ..Watermark::default() }, false).unwrap(); + let s = streams(&doc, 2); + assert_eq!(s.len(), 2, "{s:?}"); + assert!(s[0].contains("(BEHIND) Tj") && s[1].contains("(TOP) Tj"), "the watermark behind is painted first: {s:?}"); +} + #[test] fn watermark_offset_moves_the_centre_right_and_up() { let mut doc = fixture(); @@ -233,6 +507,42 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() { assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark"); } +/// #2368: a widget with /NoRotate on a rotated page is shown upright, turned with the page's +/// /Rotate about its rectangle's upper-left corner (§12.5.3, as MuPDF and Poppler draw it). +/// Flattening drew its appearance at the rectangle as it is, so the widget turned sideways and +/// moved; a neighbour without the flag is unchanged, and so is everything on an unrotated page. +#[test] +fn flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page() { + let page = stream("", " "); + let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 15 >>\nstream\n0 0 100 50 re f\nendstream"; + for (rotate, plain, upright) in [ + (0, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 1 0 0 1 100 500 cm /PCFl1 Do Q"), + (90, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 0 1 -1 0 150 550 cm /PCFl1 Do Q"), + (180, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q -1 0 0 -1 100 600 cm /PCFl1 Do Q"), + (270, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 0 -1 1 0 50 550 cm /PCFl1 Do Q"), + ] { + let widget = + |flags: i64| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{flags}) /Rect [100 500 200 550] /F {flags} /AP << /N 7 0 R >> >>"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << >> /Contents 6 0 R /Annots [4 0 R 5 0 R] >>" + ), + &widget(4), + &widget(4 | 16), + &page, + form, + ]); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 2); + let doc = reopen(&doc); + let s = streams(&doc, 0); + let flat = s.last().unwrap(); + assert!(flat.contains(plain), "/Rotate {rotate}: {flat}"); + assert!(flat.contains(upright), "/Rotate {rotate}: {flat}"); + } +} + /// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden /// cloudy square and a cloudy square that already has an appearance keep their previous results. #[test] @@ -284,6 +594,27 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() { assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}"); } +/// #3175: a note without an appearance is drawn as a fixed-size icon at the upper-left corner of +/// its `/Rect`. A rectangle with no width (LaTeX `pdfcomment`) used to scale the icon to nothing +/// while flattening still counted it as drawn and deleted the note. +#[test] +fn flattening_a_note_with_a_zero_width_rect_draws_a_fixed_size_icon() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Text /Rect [50 60 50 72] /Contents (Zero width) /Name /Comment /F 4 >>", + "<< /Type /Annot /Subtype /Text /Rect [10 20 150 120] /Contents (Large) /F 4 >>", + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 2, "both icons are drawn"); + let doc = reopen(&doc); + let flat = streams(&doc, 0).pop().unwrap(); + assert!(flat.contains("q 1 0 0 1 50 52 cm /PCFl0 Do Q"), "{flat}"); + assert!(flat.contains("q 1 0 0 1 10 100 cm /PCFl1 Do Q"), "{flat}"); +} + /// #1470: a form field whose widget is on no page (left behind by page extraction or combining) /// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`, /// or other software keeps treating the flattened file as a form. A hidden widget on a page is @@ -342,6 +673,55 @@ fn flattening_keeps_a_valued_widget_it_cannot_draw() { assert_eq!(value(6), Some(Object::name("Yes"))); } +/// #4017: a widget with no appearance whose field has no value (none, or an empty string) has +/// nothing to lose, so flattening removes it instead of leaving a fillable field behind. One whose +/// value it inherits from its parent field still stays (#1794). +#[test] +fn flattening_removes_an_empty_widget_it_cannot_draw() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R 8 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 9 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (no_value) /Rect [10 10 150 40] /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (empty) /V () /Rect [10 50 150 80] /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (drawn) /Rect [10 90 150 120] /F 4 /AP << /N 10 0 R >> >>", + "<< /FT /Tx /T (parent) /V (Ada) /Kids [9 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /Rect [10 130 150 160] /F 4 >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream", + ]); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field with an appearance is drawn"); + let doc = reopen(&doc); + let refs = |o: &Object| -> Vec { doc.resolve(o).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect() }; + let p = &pdfcraft_model::pages(&doc)[0]; + assert_eq!(refs(p.dict.get(b"Annots").unwrap()), [9], "only the widget with an inherited value stays on the page"); + let root = doc.root().unwrap(); + let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap()); + assert_eq!(refs(af.as_dict().unwrap().get(b"Fields").unwrap()), [8], "and its field in the form"); +} + +/// #4017: when the walk up the parent fields reaches its limit before finding a value, the widget +/// stays: an unverified value must not be removed. Here `/V` sits on the 64th ancestor. +#[test] +fn flattening_keeps_a_widget_whose_value_is_past_the_parent_limit() { + let page = stream("", " "); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [69 0 R] >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>".into(), + page, + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [10 10 150 40] /F 4 >>".into(), + ]; + objs.extend((6..69).map(|n| format!("<< /Parent {} 0 R /Kids [{} 0 R] >>", n + 1, n - 1))); + objs.push("<< /FT /Tx /T (deep) /V (Ada) /Kids [68 0 R] >>".into()); + let mut doc = build(&objs.iter().map(String::as_str).collect::>()); + flatten(&mut doc, &[0], false, true).unwrap(); + let doc = reopen(&doc); + let annots = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").map(|a| doc.resolve(a).as_array().map_or(0, Vec::len)); + assert_eq!(annots, Some(1), "the widget stays"); +} + /// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to /// is flattened; the comment's pop-up still goes with it. #[test] @@ -665,6 +1045,74 @@ fn added_text_and_images_are_page_content_that_stays_editable() { assert!(marks_present(&doc).is_empty()); } +/// #4464: a page left with one added item draws that item and nothing else. +#[test] +fn an_isolated_item_is_all_that_is_left_on_its_page() { + let mut doc = fixture(); + let first = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "First".into(), size: 14.0, ..AddedText::default() }; + let second = AddedText { rect: [72.0, 400.0, 300.0, 500.0], text: "Second".into(), ..first.clone() }; + add_content(&mut doc, 0, &Content::Text(first)).unwrap(); + add_content(&mut doc, 0, &Content::Text(second)).unwrap(); + let before = streams(&doc, 0).join("\n"); + assert!(before.contains("(First) Tj") && before.contains("(Second) Tj"), "{before}"); + assert!(isolate_content(&mut doc, 0, 2).is_err(), "no third item"); + assert!(isolate_content(&mut doc, 9, 0).is_err(), "no such page"); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + doc.update_dict(page.obj, |d| d.set(b"Annots".to_vec(), Object::Array(Vec::new()))).unwrap(); + isolate_content(&mut doc, 0, 1).unwrap(); + let after = streams(&doc, 0).join("\n"); + assert!(after.contains("(Second) Tj") && !after.contains("(First) Tj"), "{after}"); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + assert!(page.dict.get(b"Annots").is_none(), "no annotations are drawn over it"); + assert_eq!(list_added(&doc).len(), 1); +} + +/// Edit ▸ Paste (#2636): where a pasted item lands. +#[test] +fn a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page() { + use crate::added::{PASTE_STEP, paste_rect}; + let page = [300.0, 400.0]; + let rect = [40.0, 300.0, 140.0, 320.0]; + assert_eq!(paste_rect(rect, page, 0, None), rect, "in place"); + let d = 2.0 * PASTE_STEP; + assert_eq!(paste_rect(rect, page, 2, None), [40.0 + d, 300.0 - d, 140.0 + d, 320.0 - d], "two steps down and right"); + assert_eq!(paste_rect(rect, page, 1, Some([10.0, 50.0])), [10.0, 30.0, 110.0, 50.0], "its top-left corner at the point"); + assert_eq!(paste_rect(rect, page, 0, Some([290.0, 5.0])), [200.0, 0.0, 300.0, 20.0], "pulled back onto the page"); + assert_eq!(paste_rect([0.0, 0.0, 500.0, 600.0], page, 0, Some([10.0, 10.0])), [0.0, -200.0, 500.0, 400.0], "too large: left and top on the page"); + assert_eq!(paste_rect([140.0, 320.0, 40.0, 300.0], page, 0, None), rect, "a reversed box is normalized"); + assert_eq!(paste_rect(rect, [f64::NAN, 400.0], 1, None), rect, "a page without a size leaves the box alone"); + assert_eq!(paste_rect(rect, page, 1, Some([f64::INFINITY, 5.0])), rect, "so does a point that isn't a number"); + assert_eq!(paste_rect(rect, page, u32::MAX, None), [200.0, 0.0, 300.0, 20.0], "many pastes stop at the page's corner"); +} + +/// A paste steps on past exact copies of the item (its original, earlier pastes), and only those. +#[test] +fn a_paste_steps_past_copies_of_itself_only() { + use crate::added::{PASTE_STEP, paste_place}; + let page = [300.0, 400.0]; + let text = |rect, t: &str| Content::Text(AddedText { rect, text: t.into(), ..AddedText::default() }); + let item = text([40.0, 300.0, 140.0, 314.4], "Note"); + let step = |n: f64| [40.0 + n * PASTE_STEP, 300.0 - n * PASTE_STEP, 140.0 + n * PASTE_STEP, 314.4 - n * PASTE_STEP]; + assert_eq!(paste_place([], &item, page), step(0.0), "an empty page: where it was"); + assert_eq!(paste_place([&item], &item, page), step(1.0), "beside its original"); + let first = item.with_rect(step(1.0)); + assert_eq!(paste_place([&item, &first], &item, page), step(2.0), "beside the last copy"); + assert_eq!(paste_place([&first], &item, page), step(0.0), "its place is free again (the original was cut)"); + let other = text(step(0.0), "Other text"); + assert_eq!(paste_place([&other], &item, page), step(0.0), "a different item in the way doesn't count"); + // A page full of copies at its corner: the search ends. + let corner = item.with_rect([200.0, 0.0, 300.0, 14.4]); + let stuck = paste_place([&corner], &item.with_rect([200.0, 0.0, 300.0, 14.4]), page); + assert_eq!(stuck, [200.0, 0.0, 300.0, 14.4]); + // An image pasted into another document draws its picture from an object of that document: + // still a copy, so the next paste steps past it. + let image = Content::Image(AddedImage { crop: [0.1, 0.0, 0.0, 0.0], ..AddedImage::new(step(0.0), pdfcraft_cos::ObjRef::new(7, 0)) }); + let pasted = Content::Image(AddedImage { crop: [0.1, 0.0, 0.0, 0.0], ..AddedImage::new(step(0.0), pdfcraft_cos::ObjRef::new(42, 0)) }); + assert_eq!(paste_place([&pasted], &image, page), step(1.0), "a copy whose picture is stored anew"); + let turned = Content::Image(AddedImage { rotation: 1, ..AddedImage::new(step(0.0), pdfcraft_cos::ObjRef::new(7, 0)) }); + assert_eq!(paste_place([&turned], &image, page), step(0.0), "the same picture turned or cropped otherwise isn't a copy"); +} + #[test] fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() { let mut doc = fixture(); @@ -877,17 +1325,25 @@ fn odd_arabic_text_never_panics() { } } -/// One page with Helvetica (WinAnsi) and a subset font that has only the glyphs it uses. +/// One page with Helvetica (WinAnsi) and subsets of Arial: `/F2` and `/F3` are two subsets of the +/// same font, as Word writes a font split across several resources, and `/F4` repeats Helvetica. fn text_page(content: &str) -> Document { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R /F3 7 0 R /F4 8 0 R /F5 9 0 R >> >> >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // Subset: glyphs for a (97) and b (98) only. "<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+Arial /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>" .into(), + // Another subset of the same Arial, with a subset tag of its own. + "<< /Type /Font /Subtype /TrueType /BaseFont /GHIJKL+Arial /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>" + .into(), + // Helvetica again, in a second resource. + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + // No `/BaseFont`: `Metrics::from_dict` leaves its base-font name empty. + "<< /Type /Font /Subtype /Type1 /Encoding /WinAnsiEncoding >>".into(), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); @@ -904,6 +1360,92 @@ fn text_page(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +#[test] +fn tracked_text_keeps_words_in_paragraphs_and_objects() { + let expected = "TRACKED SAMPLE TEXT"; + for spacing in [1.5, 2.0001, 3.0] { + for scale in [50.0, 100.0, 150.0] { + for positioned in [false, true] { + let mut show = String::new(); + if !positioned { + show.push('['); + } + for (i, c) in expected.chars().enumerate() { + if positioned { + show.push_str(&format!("({c}) Tj ")); + if i + 1 < expected.len() { + show.push_str(&format!("[{}] TJ ", -spacing * 100.0)); + } + } else { + show.push_str(&format!("({c}) ")); + if i + 1 < expected.len() { + show.push_str(&format!("{} ", -spacing * 100.0)); + } + } + } + if !positioned { + show.push_str("] TJ"); + } + let doc = text_page(&format!("BT /F1 10 Tf {scale} Tz 72 700 Td {show} ET")); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, expected, "{spacing}, {scale}, {positioned}"); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected); + assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected)); + } + } + let doc = text_page(&format!("BT /F1 10 Tf {spacing} Tc 72 700 Td ({expected}) Tj ET")); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected); + } +} + +#[test] +fn single_glyph_words_and_cells_are_not_tracking() { + for (content, expected) in [ + ("BT /F1 10 Tf 72 700 Td [(a) -1000 (b) -1000 (c) -1000 (d)] TJ ET", "a b c d"), + ("BT /F1 10 Tf 72 700 Td [(1) -2500 (2) -2500 (3) -2500 (4) -2500 (5)] TJ ET", "1 2 3 4 5"), + ("BT /F1 10 Tf 72 700 Td [(A) -300 (B) -1200 (C) -1200 (D) -1200 (E) -300 (F) -1200 (G)] TJ ET", "A B C D E F G"), + ] { + assert_eq!(text::text_blocks(&text_page(content), 0).unwrap()[0].text, expected, "{content}"); + } + let mut s = String::from("BT /F1 10 Tf "); + for (i, c) in "12345".chars().enumerate() { + s.push_str(&format!("1 0 0 1 {} 700 Tm ({c}) Tj ", 72 + i * 12)); + } + s.push_str("ET"); + assert_eq!(text::text_blocks(&text_page(&s), 0).unwrap()[0].text, "1 2 3 4 5"); +} + +#[test] +fn tracked_text_preserves_larger_word_gaps() { + for show in [ + "[(T) -300 (E) -300 (S) -300 (T) -600 (W) -300 (O) -300 (R) -300 (D)] TJ", + "(T) Tj [-300] TJ (E) Tj [-300] TJ (S) Tj [-300] TJ (T) Tj [-600] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj", + "[(TEST) -300 (WORD)] TJ", + "(TEST) Tj [-300] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj", + ] { + let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET")); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "TEST WORD", "{show}"); + } +} + +#[test] +fn tracked_text_positions_do_not_count_character_spacing_twice() { + let expected = "TRACKED SAMPLE TEXT"; + for char_spacing in [0.0, 2.0] { + for transform in ["1 0 0 1 0 0", "0 1 -1 0 800 0"] { + let mut content = format!("q {transform} cm BT /F1 10 Tf {char_spacing} Tc "); + let mut x = 72.0; + for c in expected.chars() { + content.push_str(&format!("1 0 0 1 {x:.4} 700 Tm ({c}) Tj ")); + x += pdfcraft_fonts::helvetica_width(&c.to_string(), 10.0) + 2.0001; + } + content.push_str("ET Q"); + let doc = text_page(&content); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected); + assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected)); + } + } +} + fn descriptor_text_page() -> Document { let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET"; let objs: Vec = vec![ @@ -1086,6 +1628,44 @@ fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { } } +/// #2593: the craft-fonts faces are Japanese, and part of the Cyrillic (Uzbek қ ғ ҳ, Kazakh ә ө ү +/// ұ һ) is in none of them. The bundled Inter face has it, so the replacement is written instead of +/// being refused, and a build without craft-fonts can do it too. +#[test] +fn uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face() { + for replacement in ["The quick brown fox jumps over the lazy dog қизил", "әже", "ўзбекча ғўза"] { + for paragraph in [false, true] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let edit = + if paragraph { text::replace_block(&mut doc, 0, 0, replacement) } else { text::replace_line(&mut doc, 0, 0, replacement) }.unwrap(); + assert_eq!(edit.substituted.as_deref(), Some("Inter Type3"), "{replacement}: {edit:?}"); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCLat"), "{replacement}: {content}"); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(lines[0].text, replacement); + assert_eq!(lines[0].base_font, "Inter-Regular", "the generated font reports the face it drew"); + } + } +} + +/// The Japanese faces keep first refusal for the Cyrillic they do carry, so Russian text keeps the +/// face it was drawn with before the bundled Latin face was added. +#[test] +fn cyrillic_the_japanese_faces_carry_keeps_them() { + if without_craft_fonts("cyrillic_the_japanese_faces_carry_keeps_them") { + return; + } + let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).unwrap(); + if pdfcraft_fonts::japanese_glyph_from(face, 'ф').is_err() { + eprintln!("skipping: the Japanese fallback face has no Cyrillic"); + return; + } + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let edit = text::replace_line(&mut doc, 0, 0, "Привет, world").unwrap(); + let expected = format!("{} Type3", face.family); + assert_eq!(edit.substituted.as_deref(), Some(expected.as_str())); +} + #[test] fn japanese_paragraph_uses_unicode_type3_fallback() { if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") { @@ -1110,6 +1690,87 @@ fn covered_japanese_chars(n: usize) -> String { .collect() } +#[test] +fn fallback_fonts_nothing_shows_any_more_leave_with_a_rewrite() { + // `PCJp1` is selected by the page, `PCJp2` only by a form that uses the page's resources, `PCJp` + // by nothing; `PCJpOther` is a Type 3 font of the file's own that only shares the prefix. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Some text) Tj ET BT /PCJp1 12 Tf ET q /Fm0 Do Q"); + let generated = |doc: &mut Document, name: &str| { + doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type3")), + (b"Name".to_vec(), Object::name(name)), + ]))) + }; + let (unused, page_shown, form_shown, own) = + (generated(&mut doc, "PCJapanese"), generated(&mut doc, "PCJapanese"), generated(&mut doc, "PCJapanese"), generated(&mut doc, "Other")); + let form = doc.add(Object::Stream(Stream::from_raw( + Dict::from_iter([(b"Subtype".to_vec(), Object::name("Form")), (b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4]))]), + b"BT /PCJp2 12 Tf ET".to_vec(), + ))); + let p = pdfcraft_model::pages(&doc)[0].obj; + let mut fonts = Dict::from_iter([(b"F1".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)))]); + for (name, font) in [("PCJp", unused), ("PCJp1", page_shown), ("PCJp2", form_shown), ("PCJpOther", own)] { + fonts.set(name.as_bytes().to_vec(), Object::Ref(font)); + } + let resources = Dict::from_iter([ + (b"Font".to_vec(), Object::Dict(fonts)), + (b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm0".to_vec(), Object::Ref(form))]))), + ]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources.clone()))).unwrap(); + let names = |doc: &Document| { + let page = pdfcraft_model::pages(doc).remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut names: Vec = + doc.resolve(res.get(b"Font").unwrap()).as_dict().unwrap().iter().map(|(n, _)| String::from_utf8_lossy(n).into_owned()).collect(); + names.sort(); + names + }; + // A rewrite that doesn't reach the fallback fonts still drops the one nothing shows. + text::rewrite_block(&mut doc, 0, 0, Some("Other text"), &text::BlockStyle::default()).unwrap(); + assert_eq!(names(&reopen(&doc)), ["F1", "PCJp1", "PCJp2", "PCJpOther"]); + + // A form using the page's resources whose content can't be read might show any of them: they + // all stay. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Some text) Tj ET q /Fm0 Do Q"); + let unused = generated(&mut doc, "PCJapanese"); + let broken = doc.add(Object::Stream(Stream::from_raw( + Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("Form")), + (b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4])), + (b"Filter".to_vec(), Object::name("FlateDecode")), + ]), + b"not deflate data".to_vec(), + ))); + let p = pdfcraft_model::pages(&doc)[0].obj; + let fonts = Dict::from_iter([(b"F1".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(5, 0))), (b"PCJp".to_vec(), Object::Ref(unused))]); + let resources = Dict::from_iter([ + (b"Font".to_vec(), Object::Dict(fonts)), + (b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm0".to_vec(), Object::Ref(broken))]))), + ]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + text::rewrite_block(&mut doc, 0, 0, Some("Other text"), &text::BlockStyle::default()).unwrap(); + assert_eq!(names(&doc), ["F1", "PCJp"]); +} + +#[test] +fn rewriting_japanese_twice_keeps_only_the_fallback_fonts_it_shows() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET"); + if text::rewrite_block(&mut doc, 0, 0, Some("日本語の文章"), &text::BlockStyle::default()).is_err() { + assert!(pdfcraft_fonts::document_japanese_font().is_none()); + eprintln!("built without a craft-fonts Japanese face: the Type 3 path is checked with CRAFT_FONTS_DIR"); + return; + } + let first: Vec<_> = fallback_fonts(&doc).into_iter().map(|(_, f)| f).collect(); + text::rewrite_block(&mut doc, 0, 0, Some("別の文章です"), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let after = fallback_fonts(&doc); + assert!(!after.is_empty()); + assert!(after.iter().all(|(_, f)| !first.contains(f)), "the first rewrite's fonts are gone"); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(after.iter().all(|(name, _)| content.contains(&format!("/{} ", String::from_utf8_lossy(name)))), "{content}"); +} + /// The page's Type 3 fallback fonts (`PCJp`, `PCJp1`, ...) by resource name. fn fallback_fonts(doc: &Document) -> Vec<(Vec, Dict)> { let p = pdfcraft_model::pages(doc).swap_remove(0); @@ -1154,6 +1815,214 @@ fn japanese_replacement_over_240_unique_glyphs_is_split_across_fallback_fonts() assert_eq!(glyphs, 300); } +#[test] +fn styled_paragraph_batches_cyrillic_without_changing_source_fonts() { + if without_craft_fonts("styled_paragraph_batches_cyrillic_without_changing_source_fonts") { + return; + } + let mut doc = text_page( + "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET", + ); + let replacement = "The quick brown fox jumps over the lazy dog шалом\nand keeps running through the long field."; + text::rewrite_block(&mut doc, 0, 0, Some(replacement), &text::BlockStyle { width: Some(520.0), ..Default::default() }).unwrap(); + let doc = reopen(&doc); + assert_eq!(fallback_fonts(&doc).len(), 1, "the five letters must share one font"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().any(|l| l.base_font == "Helvetica" && l.text.starts_with("The quick brown fox")), "{lines:?}"); + let fallback: Vec<_> = lines.iter().filter(|l| l.font.starts_with("PCJp")).collect(); + assert_eq!(fallback.len(), 1, "one word must be one text run: {fallback:?}"); + assert_eq!(fallback[0].text, "шалом"); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::().replace(' ', ""), replacement.replace([' ', '\n'], "")); +} + +#[test] +fn styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically() { + let replacement = format!("{}\nLatin", covered_japanese_chars(300)); + let mut doc = text_page("BT /F1 12 Tf 14.4 TL 72 700 Td (Original) Tj T* (Latin) Tj ET"); + let original = write_incremental(&doc, &SaveOptions::default()).unwrap(); + if without_craft_fonts("styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically") { + assert!(text::replace_block(&mut doc, 0, 0, &replacement).is_err()); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original); + return; + } + text::rewrite_block(&mut doc, 0, 0, Some(&replacement), &text::BlockStyle { width: Some(14000.0), ..Default::default() }).unwrap(); + let reopened = reopen(&doc); + let fonts = fallback_fonts(&reopened); + assert_eq!(fonts.len(), 2, "300 glyphs need two fonts, not an error at glyph 241"); + let counts: Vec<_> = fonts.iter().map(|(_, f)| reopened.dict(f.get(b"CharProcs").unwrap()).unwrap().len()).collect(); + assert_eq!(counts.iter().sum::(), 300); + assert!(counts.iter().all(|n| *n <= 240)); + assert_eq!(text::text_lines(&reopened, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), replacement.replace('\n', "")); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let snapshot = doc.clone(); + let err = text::replace_block(&mut doc, 0, 0, &format!("{replacement}\u{10ffff}")).unwrap_err(); + assert!(err.to_string().contains("U+10FFFF"), "{err}"); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), before); +} + +#[test] +fn styled_fallback_batches_keep_face_choices_and_range_styles() { + if without_craft_fonts("styled_fallback_batches_keep_face_choices_and_range_styles") { + return; + } + if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") { + eprintln!("skipping: no bold Gothic face"); + return; + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET"); + let range = |range, family, bold| text::RangeStyle { + range, + family: Some((family, bold, false)), + resource: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let style = text::BlockStyle { + width: Some(520.0), + ranges: vec![ + range(3..6, added::Family::Times, false), + range(6..9, added::Family::Helvetica, true), + range(9..11, added::Family::Times, false), + ], + ..Default::default() + }; + let original = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let err = text::rewrite_block(&mut doc, 0, 0, Some("見見見ш見ш\nLatin\u{10ffff}"), &style).unwrap_err(); + assert!(err.to_string().contains("U+10FFFF"), "{err}"); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original, "all faces must validate before writing fallback objects"); + text::rewrite_block(&mut doc, 0, 0, Some("見見見ш見ш\nLatin"), &style).unwrap(); + let doc = reopen(&doc); + let fonts = fallback_fonts(&doc); + let mut summary: Vec<_> = fonts + .iter() + .map(|(_, f)| { + let face = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, f).base_font; + let count = doc.dict(f.get(b"CharProcs").unwrap()).unwrap().len(); + (face, count) + }) + .collect(); + summary.sort(); + assert_eq!( + summary, + [ + ("BIZUDMincho-Regular".into(), 1), + ("BIZUDPGothic-Bold".into(), 1), + ("BIZUDPGothic-Regular".into(), 2), + ("ShipporiMincho-Regular".into(), 1) + ] + ); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), "見見見ш見шLatin"); +} + +#[test] +fn styled_fallback_budget_is_enforced_before_objects_are_written() { + let glyphs = covered_japanese_chars(65); + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET"); + let original = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let snapshot = doc.clone(); + let style = text::BlockStyle { width: Some(14000.0), ..Default::default() }; + let over_budget = format!("{glyphs}\nLatin"); + let err = text::rewrite_block_with_limit(&mut doc, 0, 0, Some(&over_budget), &style, 1).unwrap_err().to_string(); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), original, "refusal must add no objects"); + assert_eq!(write_incremental(&snapshot, &SaveOptions::default()).unwrap(), original); + if without_craft_fonts("styled_fallback_budget_is_enforced_before_objects_are_written") { + assert!(err.contains("CRAFT_FONTS_DIR"), "{err}"); + return; + } + assert!(err.contains("more than 64 fallback glyphs"), "{err}"); + // One glyph per font gives a small, real budget of 64 distinct glyphs. At the + // boundary a repeated glyph is free: this is not a limit on text length. + let at_budget: String = glyphs.chars().take(64).chain(glyphs.chars().take(1)).collect(); + text::rewrite_block_with_limit(&mut doc, 0, 0, Some(&format!("{at_budget}\nLatin")), &style, 1).unwrap(); + let doc = reopen(&doc); + assert_eq!(fallback_fonts(&doc).len(), 64); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.as_str()).collect::(), format!("{at_budget}Latin")); +} + +#[test] +fn styled_fallback_same_base_font_and_character_keep_both_weights() { + if without_craft_fonts("styled_fallback_same_base_font_and_character_keep_both_weights") { + return; + } + if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") { + eprintln!("skipping: no bold Gothic face"); + return; + } + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (alpha ) Tj /F2 12 Tf (beta ) Tj ET"); + let source = doc.get(pdfcraft_cos::ObjRef::new(5, 0)).as_dict().unwrap().clone(); + for (object, weight) in [(5, 400), (6, 700)] { + let mut font = source.clone(); + // The same BaseFont can have different descriptor weights. Changing a + // RangeStyle family would also rename BaseFont and miss this cache key. + font.set(b"BaseFont".to_vec(), Object::name("NeutralFace")); + let mut descriptor = Dict::new(); + descriptor.set(b"FontName".to_vec(), Object::name("NeutralFace")); + descriptor.set(b"FontWeight".to_vec(), Object::Int(weight)); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + doc.set(pdfcraft_cos::ObjRef::new(object, 0), Object::Dict(font)); + } + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].base_font, lines[1].base_font); + assert_eq!((lines[0].bold, lines[1].bold), (false, true)); + // Unchanged words anchor each inserted copy of the same letter in its own source style. + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + let mut inherited = Vec::new(); + for (range, source) in block.edited_spans("alpha ш beta ш") { + for _ in "alpha ш beta ш".get(range).unwrap().chars().filter(|ch| *ch == 'ш') { + inherited.push(lines[source].bold); + } + } + assert_eq!(inherited, [false, true], "the fixture must exercise both weights of the same BaseFont"); + text::replace_block(&mut doc, 0, 0, "alpha ш beta ш").unwrap(); + let doc = reopen(&doc); + let fonts = fallback_fonts(&doc); + let mut names: Vec<_> = fonts.iter().map(|(_, f)| pdfcraft_fonts::pdf::Metrics::from_dict(&doc, f).base_font).collect(); + names.sort(); + assert_eq!(names, ["BIZUDPGothic-Bold", "BIZUDPGothic-Regular"]); + let outlines: Vec<_> = fonts + .iter() + .map(|(_, f)| { + let procs = doc.dict(f.get(b"CharProcs").unwrap()).unwrap(); + assert_eq!(procs.len(), 1, "each weight needs just the shared letter"); + stream_bytes(&doc, procs.iter().next().unwrap().1).unwrap() + }) + .collect(); + assert_ne!(outlines[0], outlines[1], "weight must change the outline, not only the descriptor"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "alpha ш beta ш"); +} + +#[test] +fn styled_fallback_shards_keep_text_advances_and_alignment() { + if without_craft_fonts("styled_fallback_shards_keep_text_advances_and_alignment") { + return; + } + let source = "BT /F1 12 Tf 0.5 Tc 5 Tw 80 Tz 14.4 TL 72 700 Td (Original row) Tj T* (Second row) Tj ET"; + let style = + text::BlockStyle { width: Some(520.0), align: Some(added::Align::Center), char_spacing: Some(0.7), scale: Some(80.0), ..Default::default() }; + let mut whole = text_page(source); + let mut split = text_page(source); + let replacement = "Latin шалом ш\nSecond row"; + text::rewrite_block_with_limit(&mut whole, 0, 0, Some(replacement), &style, 240).unwrap(); + text::rewrite_block_with_limit(&mut split, 0, 0, Some(replacement), &style, 2).unwrap(); + let (whole, split) = (reopen(&whole), reopen(&split)); + assert_eq!(fallback_fonts(&whole).len(), 1); + assert_eq!(fallback_fonts(&split).len(), 3); + let (one, many) = (text::text_lines(&whole, 0).unwrap(), text::text_lines(&split, 0).unwrap()); + assert_eq!(one.len(), many.len(), "{one:?} vs {many:?}"); + for (a, b) in one.iter().zip(&many) { + assert_eq!(a.text, b.text); + for (a, b) in a.rect.iter().zip(b.rect) { + assert!((a - b).abs() < 0.002, "sharding must preserve alignment and advances: {a} vs {b}"); + } + } +} + /// A replacement whose last glyph no face draws (in a later font) changes nothing: neither the /// document nor a snapshot taken before the edit, which is what undo restores. #[test] @@ -1641,6 +2510,94 @@ fn word_boxes_span_whole_words_when_glyphs_run_backwards() { } } +/// PDF writers split one font into several subsets, each a font resource of its own: a word drawn +/// through two of them is still one visual line (#3661). +#[test] +fn one_line_continues_across_two_resources_of_the_same_font() { + for (content, text) in [ + // The issue's case: two resources for the same Helvetica. + ("BT /F1 12 Tf 72 700 Td (Hel) Tj /F4 12 Tf (lo) Tj ET", "Hello"), + // Word's case: two tagged subsets of one font, "abcab" through /F2, /F3, /F2. + ("BT /F2 12 Tf 72 700 Td (ab) Tj /F3 12 Tf (ca) Tj /F2 12 Tf (b) Tj ET", "abcab"), + ] { + let doc = text_page(content); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1, "{content}: {:?}", lines.iter().map(|l| &l.text).collect::>()); + assert_eq!(lines[0].text, text, "{content}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "{content}"); + assert_eq!(blocks[0].text, text, "{content}"); + } +} + +/// Two genuinely different fonts still start a new line: the subset tag is all that may differ. +#[test] +fn different_fonts_start_a_new_line() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (Hel) Tj /F2 12 Tf (lo) Tj ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2); +} + +/// A font without `/BaseFont` has no name to compare. Its lines still group by resource, as they +/// did before subset joining: an empty name must not split one paragraph into two blocks. +#[test] +fn lines_in_a_font_without_a_base_font_stay_one_block() { + let doc = text_page("BT /F5 12 Tf 72 700 Td (First) Tj 0 -15 Td (second) Tj ET"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + assert!(lines.iter().all(|l| l.base_font.is_empty()), "{:?}", lines.iter().map(|l| &l.base_font).collect::>()); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].text, "First second"); + let reading = text::reading_blocks(&doc, 0).unwrap(); + assert_eq!(reading.len(), 1, "{:?}", reading.iter().map(|b| &b.text).collect::>()); + assert_eq!(reading[0].text, "First second"); +} + +/// Two subsets of one font can carry different encodings: here the second names `-` and `l` +/// through `/Differences` (codes 33 and 34), so the same letters have different codes. The visual +/// line still joins and reads in coding order (#3661). +#[test] +fn a_reencoded_subset_of_the_same_font_stays_one_line() { + let content = "BT /F1 16 Tf 72 700 Td (Two) Tj /F2 16 Tf (!\") Tj /F1 16 Tf (ine heading) Tj ET"; + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>", + &stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /GHIJKL+Helvetica-Bold /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [33 /hyphen /l] >> >>", + ]); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1, "{:?}", lines.iter().map(|l| &l.text).collect::>()); + assert_eq!(lines[0].text, "Two-line heading"); + let reading = text::reading_blocks(&doc, 0).unwrap(); + assert_eq!(reading.len(), 1); + assert_eq!(reading[0].text, "Two-line heading"); + // Editing the one line replaces every run, including the re-encoded subset's. + text::replace_line(&mut doc, 0, 0, "Two-line heading done").unwrap(); + let reopened = reopen(&doc); + let lines = text::text_lines(&reopened, 0).unwrap(); + assert_eq!(lines.len(), 1, "{:?}", lines.iter().map(|l| &l.text).collect::>()); + assert_eq!(lines[0].text, "Two-line heading done"); +} + +/// `ABCDE+` is five letters, not the six-letter subset tag of §9.6.4, so it stays part of the +/// name: `ABCDE+Helvetica-Bold` and `Helvetica-Bold` are different fonts and their runs stay apart. +#[test] +fn a_five_letter_prefix_is_not_a_subset_tag() { + let content = "BT /F1 16 Tf 72 700 Td (Two) Tj /F2 16 Tf (-l) Tj /F1 16 Tf (ine heading) Tj ET"; + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>", + &stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDE+Helvetica-Bold >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", + ]); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 3, "{:?}", lines.iter().map(|l| &l.text).collect::>()); +} + #[test] fn text_lines_are_found_and_replaced_in_place() { let mut doc = @@ -1795,6 +2752,681 @@ fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() { assert!(text::replace_line(&mut doc, 0, 5, "x").is_err(), "no such line"); } +/// #4280: two paragraphs in subsets without Ç or Ã, one regular and one bold. Each edit that adds +/// an accent sets the paragraph in a standard font of its own weight. Editing the bold one must +/// not turn the regular one, set in its stand-in earlier, bold too, and the editor's frame for the +/// bold one must not reach the regular one. +#[test] +fn a_bold_stand_in_font_leaves_an_earlier_regular_one_alone() { + let widths = vec!["600"; 26].join(" "); + let font = |base: &str| { + format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} /FirstChar 65 /LastChar 90 /Widths [{widths}] /Encoding /WinAnsiEncoding >>") + }; + let (regular, bold) = (font("ABCDEF+Garamond"), font("ABCDEF+Garamond-Bold")); + let content = stream("", "BT /F1 12 Tf 72 700 Td (ACAO) Tj ET BT /F2 12 Tf 300 400 Td (TENSAO) Tj ET"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>", + &content, + ®ular, + &bold, + ]); + let block = |doc: &Document, text: &str| text::text_blocks(doc, 0).unwrap().iter().position(|b| b.text == text).unwrap(); + let acao = block(&doc, "ACAO"); + assert_eq!(text::replace_block(&mut doc, 0, acao, "AÇÃO").unwrap().substituted.as_deref(), Some("Times-Roman")); + let first = text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "AÇÃO").unwrap(); + + let tensao = block(&doc, "TENSAO"); + let layout = text::text_edit_layout(&doc, 0, tensao, "TENSÃO", &text::BlockStyle::default()).unwrap(); + let draft = layout.draft_rect.unwrap(); + assert!(draft[3] < first.rect[1] || draft[1] > first.rect[3], "the frame {draft:?} reaches the regular paragraph at {:?}", first.rect); + assert_eq!(text::replace_block(&mut doc, 0, tensao, "TENSÃO").unwrap().substituted.as_deref(), Some("Times-Bold")); + + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + let font_of = |text: &str| lines.iter().find(|l| l.text == text).map(|l| l.base_font.clone()); + assert_eq!(font_of("AÇÃO").as_deref(), Some("Times-Roman"), "{lines:?}"); + assert_eq!(font_of("TENSÃO").as_deref(), Some("Times-Bold"), "{lines:?}"); +} + +/// #2517: one blank line at ordinary leading (2 × 1.2 × the font size) is a paragraph break. +/// Editing the first paragraph must leave the second one in place. +#[test] +fn a_blank_line_keeps_the_next_paragraph() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET"; + let mut doc = text_page(src); + let before = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(before.len(), 2, "{:?}", before.iter().map(|b| b.text.as_str()).collect::>()); + text::replace_block(&mut doc, 0, 0, "First paragraph, edited row.\nFirst paragraph, second row.").unwrap(); + let after = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "{:?}", after.iter().map(|b| b.text.as_str()).collect::>()); + assert!(after[0].text.contains("edited row") && after[0].text.contains("second row"), "{:?}", after[0].text); + assert!(after[1].text.contains("Second paragraph"), "{:?}", after[1].text); + // The second row is left in the source stream. A marker around only the rewritten row + // would split the paragraph; either there is no marker, or it covers both rows and not the next paragraph. + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + if content.contains("/P BMC") { + let marked = region_through_matching_close(&content, "/P BMC"); + assert!(marked.contains("edited row") && marked.contains("second row"), "marker split the paragraph: {content}"); + assert!(!marked.contains("Second paragraph"), "marker swallowed the next paragraph: {content}"); + } +} + +/// #2517: adding a row must not pull in a paragraph that was 3 × the font size below. +#[test] +fn a_grown_paragraph_does_not_swallow_the_next_one() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 649.6 Tm (Neighbour) Tj ET"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One\nTwo\nThree").unwrap(); + let after = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "{:?}", after.iter().map(|b| b.text.as_str()).collect::>()); + assert!(after[1].text.contains("Neighbour"), "{:?}", after[1].text); +} + +/// An explicit `TL` is the leading `T*` moves by. Rows at that gap are one paragraph, including +/// when the gap is larger than twice the font size. A following paragraph that starts two +/// leadings lower stays separate. The numbers are the gaps under test, not a claim about how a +/// word processor derives them: this repo's Helvetica box is 0.925 em (ascent 0.718, descent +/// 0.207), so twice that box at 12 pt is 22.2 pt, while 27.6 pt is 12 × 2.3. +#[test] +fn explicit_leading_is_the_measured_baseline_gap() { + for tl in [14.4_f64, 18.0, 22.2, 24.0, 27.6, 28.8] { + let src = format!("BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET"); + let doc = text_page(&src); + let lines = text::text_lines(&doc, 0).unwrap(); + let gaps: Vec = lines.windows(2).map(|w| w[0].origin_baseline() - w[1].origin_baseline()).collect(); + assert!(gaps.iter().all(|gap| (gap - tl).abs() < 0.05), "TL {tl} did not move the baselines: {gaps:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "TL {tl}, measured gaps {gaps:?}: {:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); + } + // Two rows at the TL, then a paragraph whose first baseline is two TLs below the second row. + let tl = 14.4_f64; + let next = 700.0 - tl - 2.0 * tl; + let blank = format!( + "BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf {tl} TL 1 0 0 1 72 {next} Tm (Neighbour) Tj ET" + ); + let doc = text_page(&blank); + let lines = text::text_lines(&doc, 0).unwrap(); + let between = lines[1].origin_baseline() - lines[2].origin_baseline(); + assert!((between - 2.0 * tl).abs() < 0.05, "the blank line measured {between}, not {}", 2.0 * tl); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2, "{:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); +} + +/// The neighbour starts one blank line below (two leadings under the second row). Adding a row +/// puts the new baseline one leading above that neighbour, inside the join window. The neighbour +/// still has to survive the edit, both saves, and a second edit. +#[test] +fn growing_into_a_blank_line_keeps_the_neighbour_after_save() { + let tl = 14.4_f64; + let src = format!( + "BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf {tl} TL 1 0 0 1 72 {} Tm (Neighbour) Tj ET", + 700.0 - 3.0 * tl + ); + let mut doc = text_page(&src); + let before = text::text_lines(&doc, 0).unwrap(); + let opening = before[1].origin_baseline() - before[2].origin_baseline(); + assert!((opening - 2.0 * tl).abs() < 0.05, "fixture gap {opening} is not a blank line"); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One\nTwo\nThree").unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let bases: Vec = lines.iter().map(|l| l.origin_baseline()).collect(); + assert_eq!(bases.len(), 4, "{bases:?}"); + let grown = bases[2] - bases[3]; + assert!((grown - tl).abs() < 0.05, "the new row is {grown} pt above the neighbour, not one leading"); + assert!(grown < tl * 1.5, "this gap is outside the join window, so the test would pass without the marker"); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(texts(&doc), ["One Two Three", "Neighbour"]); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let marked = region_through_matching_close(&content, "/P BMC"); + for row in ["(One)", "(Two)", "(Three)"] { + assert!(marked.contains(row), "{row} is outside the marker: {content}"); + } + assert!(!marked.contains("Neighbour"), "the neighbour was pulled into the marker: {content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["One Two Three", "Neighbour"]); + let mut again = saved; + text::replace_block(&mut again, 0, 0, "One\nTwo\nFour").unwrap(); + assert_eq!(texts(&reopen(&again)), ["One Two Four", "Neighbour"]); + } +} + +/// No `TL`: `Td` does not set the leading (`TD` does). Three rows at one repeated step are one +/// paragraph. A single large gap, with no third row at that step, stays a break. +#[test] +fn a_repeated_step_without_tl_is_the_paragraph_leading() { + let step = 27.6_f64; + let bodies = [ + format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"), + format!( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {} Tm (Three) Tj ET", + 700.0 - step, + 700.0 - 2.0 * step + ), + format!( + "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET BT /F1 12 Tf 72 {} Td (Three) Tj ET", + 700.0 - step, + 700.0 - 2.0 * step + ), + ]; + for body in bodies { + let doc = text_page(&body); + let lines = text::text_lines(&doc, 0).unwrap(); + let gaps: Vec = lines.windows(2).map(|w| w[0].origin_baseline() - w[1].origin_baseline()).collect(); + assert!(gaps.iter().all(|gap| (gap - step).abs() < 0.05), "step {step} measured {gaps:?} in {body}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "{body} -> {:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); + } + // Two rows and nothing after them: one gap cannot be told from a paragraph break. + let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - step); + assert_eq!(text::text_blocks(&text_page(&pair), 0).unwrap().len(), 2); + // The original blank line, written with Td instead of TL: the inner step repeats, the break does not. + let inner = 14.4_f64; + let blank = format!( + "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \ + BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET", + 700.0 - inner, + 700.0 - 3.0 * inner, + 700.0 - 4.0 * inner + ); + let doc = text_page(&blank); + let lines = text::text_lines(&doc, 0).unwrap(); + let break_gap = lines[1].origin_baseline() - lines[2].origin_baseline(); + assert!((break_gap - 2.0 * inner).abs() < 0.05, "blank line measured {break_gap}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2, "{:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); +} + +/// The first row is unchanged, so the rewrite leaves it in the source stream and skips the +/// paragraph marker. The added row then sits one leading above the neighbour. +#[test] +fn a_partial_rewrite_that_adds_a_row_keeps_the_neighbour() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "Alpha first row.\nTwo\nThree").unwrap(); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!(texts(&doc), ["Alpha first row. Two Three", "Neighbor stays."], "{content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Two Three", "Neighbor stays."]); + let mut again = saved; + text::replace_block(&mut again, 0, 0, "Alpha first row.\nTwo\nFour").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Two Four", "Neighbor stays."]); + } +} + +/// Both paragraphs share one text object, with a blank line between them. The paragraph +/// marker has to close before the neighbour, not after the shared `ET`. +#[test] +fn a_partial_rewrite_inside_one_text_object_keeps_the_neighbour() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj ET"; + let mut doc = text_page(src); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Neighbor stays."], "the blank line is a paragraph break"); + text::replace_block(&mut doc, 0, 0, "Alpha first row.\nTwo\nThree").unwrap(); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!(texts(&doc), ["Alpha first row. Two Three", "Neighbor stays."], "{content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Two Three", "Neighbor stays."], "save joined the neighbour"); + let mut again = saved; + text::replace_block(&mut again, 0, 0, "Alpha first row.\nTwo\nFour").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Two Four", "Neighbor stays."]); + } +} + +/// The last paragraph of a shared text object has no neighbour after it. The marker still has +/// to open after the preceding paragraph, not at the shared `BT`, or that paragraph joins it. +#[test] +fn a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_before_it() { + let src = + "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj T* (Neighbor next.) Tj ET"; + let mut doc = text_page(src); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Neighbor next."], "the blank line is a paragraph break"); + text::replace_block(&mut doc, 0, 1, "Neighbor stays.\nChanged.").unwrap(); + let content = page_content(&doc); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."], "{content}"); + let alpha = content.find("(Alpha first row.)").expect("the preceding paragraph"); + let open = content.find("/P BMC").expect("marker"); + assert!(alpha < open, "the marker swallowed the preceding paragraph: {content}"); + let neighbour = content.find("(Neighbor stays.)").expect("the kept row"); + assert!(open < neighbour, "the kept row is outside the marker: {content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."], "save joined the preceding paragraph"); + let mut again = saved; + text::replace_block(&mut again, 0, 1, "Neighbor stays.\nEdited.").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Alpha next row.", "Neighbor stays. Edited."]); + } +} + +/// Three paragraphs share one text object. Rewriting the middle one must leave the paragraph +/// before it and the paragraph after it outside the marker. +#[test] +fn a_partial_rewrite_of_the_middle_paragraph_in_one_text_object_keeps_both_neighbours() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj \ + T* T* (Mid stays.) Tj T* (Mid next.) Tj T* T* (End stays.) Tj T* (End next.) Tj ET"; + let mut doc = text_page(src); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!( + texts(&doc), + ["Alpha first row. Alpha next row.", "Mid stays. Mid next.", "End stays. End next."], + "each blank line is a paragraph break" + ); + text::replace_block(&mut doc, 0, 1, "Mid stays.\nChanged.").unwrap(); + let content = page_content(&doc); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Mid stays. Changed.", "End stays. End next."], "{content}"); + let alpha = content.find("(Alpha first row.)").expect("the preceding paragraph"); + let open = content.find("/P BMC").expect("marker"); + let end = content.find("(End stays.)").expect("the following paragraph"); + assert!(alpha < open, "the marker swallowed the preceding paragraph: {content}"); + assert!(open < end, "the following paragraph is inside the marker: {content}"); + let close = content[open..].find("EMC").map(|at| open + at).expect("close"); + assert!(close < end, "the marker closed after the following paragraph: {content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Alpha next row.", "Mid stays. Changed.", "End stays. End next."]); + let mut again = saved; + text::replace_block(&mut again, 0, 1, "Mid stays.\nEdited.").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Alpha next row.", "Mid stays. Edited.", "End stays. End next."]); + } +} + +/// #2516's published steps: adding a row must not make the next edit-by-number rewrite Charlie. +/// The issue also asks for a stable paragraph id. That is #2508, and this test does not add one. +#[test] +fn adding_a_row_does_not_make_the_next_paragraph_number_mean_charlie() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha paragraph, first row of text.) Tj T* (Alpha paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 650 Tm (Bravo paragraph, a single row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 600 Tm (Charlie paragraph, first row of text.) Tj T* (Charlie paragraph, second row.) Tj ET"; + let mut doc = text_page(src); + let names = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(names(&doc).len(), 3, "{:?}", names(&doc)); + text::replace_block(&mut doc, 0, 0, "Alpha paragraph, first row of text.\nAlpha paragraph, second row.\nAlpha paragraph, a third row.").unwrap(); + let after = names(&doc); + assert!(after.iter().any(|t| t.contains("Bravo") && !t.contains("Alpha")), "Bravo was absorbed: {after:?}"); + assert!(after.iter().any(|t| t.contains("Charlie")), "Charlie is gone: {after:?}"); + let bravo = after.iter().position(|t| t.contains("Bravo")).unwrap(); + text::replace_block(&mut doc, 0, bravo, "Bravo paragraph, edited.").unwrap(); + let done = names(&doc); + assert!(done.iter().any(|t| t.contains("Charlie")), "editing Bravo removed Charlie: {done:?}"); + assert!(done.iter().any(|t| t.contains("Bravo paragraph, edited.")), "{done:?}"); +} + +/// The paragraph is the last thing on the page. Editing the first row and leaving the second +/// in the source must keep both rows inside one marker, including after a save. +#[test] +fn a_partial_rewrite_of_the_only_paragraph_stays_one_block() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET"; + let mut doc = text_page(src); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Alpha next row.)"); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Alpha next row.)"); + let mut doc = doc; + text::replace_block(&mut doc, 0, 0, "One.\nTwo.").unwrap(); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Two."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Two.)"); +} + +/// The same rewrite when an operator follows the paragraph's `ET`. The close belongs before +/// that operator, and still after the kept row. +#[test] +fn a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET 0 0 0 RG"; + let mut doc = text_page(src); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + let content = page_content(&doc); + assert_marker_wraps(&content, "/P BMC", "(One.)", "(Alpha next row.)"); + let start = content.find("/P BMC").expect("marker"); + let close = content[start..].find("EMC").map(|at| start + at).expect("close"); + let after = content.find("0 0 0 RG").expect("the following operator is gone"); + assert!(close < after, "the marker swallowed the operator after the paragraph: {content}"); +} + +/// The last paragraph on the page is already inside a marked region. A partial rewrite stays +/// one paragraph, and that region's close still comes after both rows. +#[test] +fn a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region() { + let src = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/MCID 0", "(One.)", "(Alpha next row.)"); +} + +/// A partial rewrite must not insert `/P BMC` across two existing marked-content regions. +/// The neighbour's own marker has to keep wrapping its text. +#[test] +fn a_partial_rewrite_keeps_the_next_marked_region_closed() { + let src = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \ + /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}"); + let content = page_content(&doc); + assert_marker_wraps(&content, "/MCID 0", "(One.)", "(Alpha next row.)"); + assert_marker_wraps(&content, "/MCID 1", "(Neighbor stays.)", "(Neighbor stays.)"); + let neighbour = content.find("/MCID 1").expect("neighbour region"); + let close = content[neighbour..].find("EMC").map(|at| neighbour + at).expect("neighbour close"); + assert!(!content[neighbour..close].contains("(One.)"), "the rewrite was pulled into the neighbour's marker: {content}"); +} + +/// The paragraph being edited is not in a marked region. The next one is. Closing the new +/// marker at the neighbour's `BT` would land after that region's `BDC` and close it early. +#[test] +fn a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}"); + let content = page_content(&doc); + assert_marker_wraps(&content, "/MCID 1", "(Neighbor stays.)", "(Neighbor stays.)"); + assert!(!region_through_matching_close(&content, "/MCID 1").contains("(One.)"), "the rewrite was pulled into the neighbour: {content}"); +} + +/// Both paragraphs already share one marked region, separated by a blank line. The extra marker +/// around the edit has to close itself, not the region, so the neighbour stays inside it. +#[test] +fn a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it() { + let src = "/P << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}"); + let content = page_content(&doc); + let region = region_through_matching_close(&content, "/MCID 0"); + assert!(region.contains("(One.)") && region.contains("(Neighbor stays.)"), "the region closed before the neighbour: {content}"); +} + +/// Bytes from `open` through the `EMC` that balances the marked region it begins. +fn region_through_matching_close<'a>(content: &'a str, open: &str) -> &'a str { + let start = content.find(open).unwrap_or_else(|| panic!("missing {open}: {content}")); + let bytes = content.as_bytes(); + let mut index = start; + let mut depth = 0i32; + while index < bytes.len() { + let rest = &bytes[index..]; + let boundary = index == 0 || !bytes[index - 1].is_ascii_alphanumeric(); + if boundary && (rest.starts_with(b"BMC") || rest.starts_with(b"BDC")) && rest.get(3).is_none_or(|b| !b.is_ascii_alphanumeric()) { + depth += 1; + index += 3; + continue; + } + if boundary && rest.starts_with(b"EMC") && rest.get(3).is_none_or(|b| !b.is_ascii_alphanumeric()) { + depth -= 1; + if depth == 0 { + return &content[start..index]; + } + index += 3; + continue; + } + index += 1; + } + panic!("unbalanced region {open}: {content}"); +} + +fn page_content(doc: &Document) -> String { + String::from_utf8_lossy(&page_content_bytes(doc, 0)).into_owned() +} + +/// `open` through the next `EMC` contains both strings, and no `EMC` sits inside a text object. +fn assert_marker_wraps(content: &str, open: &str, first: &str, second: &str) { + let marked = region_through_matching_close(content, open); + assert!(marked.contains(first) && marked.contains(second), "{open} closed too early: {content}"); + assert!(!marker_crosses_a_text_object(content), "a marker opened outside a text object and closed inside it: {content}"); +} + +/// A marker may sit inside one text object. It may not open outside one and close inside it. +fn marker_crosses_a_text_object(content: &str) -> bool { + let mut text = 0i32; + let mut opened_outside = 0i32; + let mut opened_inside = 0i32; + for token in content.split_whitespace() { + match token { + "BT" => text += 1, + "ET" => text -= 1, + "BMC" | "BDC" => { + if text == 0 { + opened_outside += 1; + } else { + opened_inside += 1; + } + } + "EMC" => { + if text > 0 { + if opened_inside > 0 { + opened_inside -= 1; + } else { + return true; + } + } else if opened_outside > 0 { + opened_outside -= 1; + } + } + _ => {} + } + } + text != 0 || opened_outside != 0 || opened_inside != 0 +} + +/// A blank line is one extra leading, so the fourth row of a grown paragraph sits on the +/// neighbour's baseline. Changing the box width must not treat that neighbour as a duplicate +/// drawing and delete it. +#[test] +fn resizing_a_grown_paragraph_keeps_the_neighbour() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET"; + let mut doc = text_page(src); + let text = "Alpha first row.\nthis is another\nalpha test\nAlpha next row."; + text::replace_block(&mut doc, 0, 0, text).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let added = lines.iter().find(|l| l.text.contains("Alpha next row.")).unwrap(); + let neighbour = lines.iter().find(|l| l.text.contains("Neighbor")).unwrap(); + assert!((added.origin_baseline() - neighbour.origin_baseline()).abs() < 0.5, "the new row is not on the neighbour"); + let width = text::text_blocks(&doc, 0).unwrap()[0].rect[2] - text::text_blocks(&doc, 0).unwrap()[0].rect[0]; + for factor in [0.6_f64, 1.6] { + let mut copy = doc.clone(); + text::rewrite_block(&mut copy, 0, 0, Some(text), &text::BlockStyle { width: Some((width * factor).max(40.0)), ..Default::default() }) + .unwrap(); + let texts = text::text_blocks(©, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert!(texts.iter().any(|t| t.contains("Neighbor stays.")), "width ×{factor} removed the neighbour: {texts:?}"); + } +} + +/// Narrowing wraps a line. Widening it again rejoins that wrap. A newline that was typed stays. +#[test] +fn a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays() { + let mut doc = text_page("BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert!(block.editor_text.contains('\n'), "the narrow box did not wrap: {}", block.editor_text); + assert_eq!(block.reflow_text, "Page 1", "a wrap was kept as a hard break: {}", block.reflow_text); + text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(200.0), ..Default::default() }).unwrap(); + let lines: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["Page 1"]); + + let mut typed = text_page("BT /F1 12 Tf 72 700 Td (One) Tj ET"); + text::replace_block(&mut typed, 0, 0, "One\nTwo\nThree\nfour").unwrap(); + let block = text::text_blocks(&typed, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.reflow_text, "One\nTwo\nThree\nfour", "a typed break was rejoined: {}", block.reflow_text); + let width = (block.rect[2] - block.rect[0]) * 6.0; + text::rewrite_block(&mut typed, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(width.max(180.0)), ..Default::default() }) + .unwrap(); + assert_eq!(text::text_blocks(&typed, 0).unwrap()[0].editor_text, "One\nTwo\nThree\nfour"); +} + +/// #2529 (reviewer): a run of typed spaces that happens to fall at a wrap point is not the same +/// as the single space an ordinary word gap leaves. Narrowing drops the whole gap, as any wrap +/// does (#4199's own test: `wrapped("aa bb cc", 6.0) == ["aa bb", "cc"]`); widening it back +/// must not quietly turn two typed spaces into one. The count is stored on the wrap marker, so +/// it has to survive an incremental save and a full rewrite, not only the in-memory rewrite. +#[test] +fn narrowing_then_widening_keeps_a_typed_double_space() { + let fresh = text_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET"); + let wide_width = { + let wide = text::text_blocks(&fresh, 0).unwrap().into_iter().next().unwrap().rect; + wide[2] - wide[0] + }; + for (save, reopen_as) in + [("incremental", reopen(&fresh)), ("full", Document::open(Arc::new(write_full(&fresh, &SaveOptions::default()).unwrap())).unwrap())] + { + let mut doc = reopen_as; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); + let doc = + if save == "incremental" { reopen(&doc) } else { Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() }; + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.editor_text, "One\nTwo", "{save}: the narrow box did not wrap between the two typed spaces: {}", block.editor_text); + assert_eq!(block.reflow_text, "One Two", "{save}: the saved wrap did not remember both spaces: {:?}", block.reflow_text); + let mut doc = doc; + text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); + let doc = + if save == "incremental" { reopen(&doc) } else { Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() }; + let rewidened = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(rewidened.editor_text, "One Two", "{save}: the second typed space was lost: {:?}", rewidened.editor_text); + } +} + +/// #2529: a Chinese/Japanese word split has no space of its own (`wrap`'s own doc comment: "those +/// pieces join without a space"). Narrowing a paragraph until it wraps inside such a word, then +/// widening it back, must restore the two pieces exactly as they were, not insert a space between +/// them. Checked on both the single-style rewrite path and the row-editor (mixed-style) path. +#[test] +fn narrowing_then_widening_a_japanese_word_adds_no_space() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping narrowing_then_widening_a_japanese_word_adds_no_space: built without craft-fonts (set CRAFT_FONTS_DIR)"); + return; + } + let text = "日本語の文章です"; + for draft in [text.to_string(), format!("見出し\n{text}")] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Placeholder text) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle { width: Some(400.0), ..Default::default() }).unwrap(); + let wide = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + let wide_width = wide.rect[2] - wide.rect[0]; + + // Narrow enough to force a wrap inside the Japanese word, not just between the two rows. + // A width-only change must pass `reflow_text` explicitly, as the editor UI does: `None` + // falls back to the paragraph's space-joined display text and would not keep a typed break. + text::rewrite_block(&mut doc, 0, 0, Some(&wide.reflow_text), &text::BlockStyle { width: Some(60.0), ..Default::default() }).unwrap(); + let narrow = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + assert!(narrow.rows.len() > draft.matches('\n').count() + 1, "the narrow box did not wrap: {:?}", narrow.editor_text); + assert_eq!( + narrow.editor_text.replace('\n', ""), + draft.replace('\n', ""), + "wrapping must not add or drop characters: {:?}", + narrow.editor_text + ); + + text::rewrite_block(&mut doc, 0, 0, Some(&narrow.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); + let rewidened = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + assert_eq!(rewidened.editor_text, draft, "a Japanese word split gained a space it never had: {:?}", rewidened.editor_text); + } +} + +/// A file that did not mark its breaks still rejoins a line that fills the measure, and keeps a +/// short line that had room. +#[test] +fn an_unmarked_full_line_rejoins_and_a_short_break_stays() { + let long = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (This sentence is long enough to fill a measure.) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 685.6 Tm (Tail) Tj ET"; + let doc = text_page(long); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.rows.len(), 2, "{}", block.editor_text); + assert_eq!(block.reflow_text, "This sentence is long enough to fill a measure. Tail", "{}", block.reflow_text); + + let short = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 685.6 Tm (Two) Tj ET"; + let doc = text_page(short); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.reflow_text, "One\nTwo", "{}", block.reflow_text); +} + +/// The letter the screenshots edit. Widening example 1, or adding a row there, must leave the +/// heading above it and both paragraphs under example 2 as they were. +fn letter_page() -> &'static str { + "BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET \ + BT /F1 12 Tf 72 700 Td (One) Tj ET \ + BT /F1 12 Tf 72 672.4 Td (Two) Tj ET \ + BT /F1 12 Tf 72 644.8 Td (Three) Tj ET \ + BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET \ + BT /F1 14 Tf 72 450 Td (3. Add a row under Alpha. Neighbor stays.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET" +} + +fn block_texts(doc: &Document) -> Vec { + text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect() +} + +#[test] +fn widening_example_one_does_not_swallow_the_heading_or_split_the_next_paragraph() { + let mut doc = text_page(letter_page()); + let before = block_texts(&doc); + assert!(before.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "{before:?}"); + assert!(before.iter().any(|t| t.contains("One") && t.contains("Three") && !t.contains("Repeated")), "{before:?}"); + assert!(before.iter().any(|t| t.contains("First paragraph, first row") && t.contains("second row")), "{before:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let idx = blocks.iter().position(|b| b.text.contains("One") && b.text.contains("Three")).unwrap(); + // What the editor committed, including the line breaks. A later width change must keep them. + text::replace_block(&mut doc, 0, idx, "One\nTwo\nThree\nfour").unwrap(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let idx = blocks.iter().position(|b| b.text.contains("four") || b.editor_text.contains("four")).unwrap(); + assert_eq!(blocks[idx].reflow_text, "One\nTwo\nThree\nfour", "a typed break was treated as a wrap: {}", blocks[idx].reflow_text); + let shown = blocks[idx].reflow_text.clone(); + let width = blocks[idx].rect[2] - blocks[idx].rect[0]; + text::rewrite_block(&mut doc, 0, idx, Some(&shown), &text::BlockStyle { width: Some((width * 6.0).max(180.0)), ..Default::default() }).unwrap(); + let doc = reopen(&doc); + let after = block_texts(&doc); + let kept = text::text_blocks(&doc, 0).unwrap().into_iter().find(|b| b.editor_text.contains("four")).unwrap(); + assert_eq!(kept.editor_text, "One\nTwo\nThree\nfour", "a wider box flattened the line breaks: {:?}", kept.editor_text); + assert!(after.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "the heading joined the resized box: {after:?}"); + assert!( + after.iter().any(|t| t.contains("First paragraph, first row") && t.contains("First paragraph, second row")), + "widening example 1 split the next paragraph: {after:?}" + ); + assert!(after.iter().any(|t| t.contains("Second paragraph, first row") && t.contains("second row")), "{after:?}"); + assert!(after.iter().any(|t| t.contains("Neighbor stays.") && !t.contains("Alpha")), "{after:?}"); +} + +#[test] +fn a_row_added_one_leading_above_the_neighbour_does_not_absorb_it() { + let mut doc = text_page(letter_page()); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let idx = blocks.iter().position(|b| b.text.contains("Alpha first row")).unwrap(); + text::replace_block(&mut doc, 0, idx, "Alpha first row.\ntest\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let after = block_texts(&doc); + assert!(after.iter().any(|t| t.contains("Neighbor stays.") && !t.contains("Alpha")), "the added row absorbed the neighbour: {after:?}"); +} + #[test] fn paragraphs_are_found_and_rewrapped() { let mut doc = text_page( @@ -1833,6 +3465,36 @@ fn paragraphs_are_found_and_rewrapped() { assert_eq!((blocks[0].text.as_str(), blocks[0].lines.len()), ("Short.", 1)); } +/// A list whose bullet is its own text object at the margin and whose text sits at a hanging +/// indent: the wrapped first item is one paragraph, and editing it keeps the items after it +/// (#2564, the edit that deleted its neighbours). +#[test] +fn a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it() { + let mut doc = text_page( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (\\225) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 86 700 Tm (First item of the list that wraps) Tj T* (onto a second row.) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 671.2 Tm (\\225) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 86 671.2 Tm (Second item, one row.) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 656.8 Tm (\\225) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 86 656.8 Tm (Third item, one row.) Tj ET", + ); + let items = ["• First item of the list that wraps onto a second row.", "• Second item, one row.", "• Third item, one row."]; + let blocks = text::text_blocks(&doc, 0).unwrap(); + let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect(); + assert_eq!(texts, items, "one paragraph per item, the continuation with its first row"); + // Editing the paragraph the wrapped row belongs to must not take the items after it with it. + let blocks = text::text_blocks(&doc, 0).unwrap(); + let wrapped = blocks.iter().position(|b| b.text.contains("onto a second row.")).unwrap(); + text::replace_block(&mut doc, 0, wrapped, "• First item of the list that wraps onto a second row, edited.").unwrap(); + let blocks = text::text_blocks(&reopen(&doc), 0).unwrap(); + let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect(); + assert_eq!( + texts, + ["• First item of the list that wraps onto a second row, edited.", "• Second item, one row.", "• Third item, one row."], + "the items after the edited one are still on the page" + ); +} + fn page_content_bytes(doc: &Document, page: usize) -> Vec { let p = pdfcraft_model::pages(doc).swap_remove(page); let c = p.dict.get(b"Contents").unwrap(); @@ -1878,6 +3540,25 @@ fn image_page_with(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +/// A path segment operator before any `m` (or `re`) has no subpath to extend: the image scan used +/// to call `BezPath`'s `line_to`/`curve_to`/`close_path` on an empty path, which panics by contract, +/// taking `object_list`, `page_images` and the Office export's image walk down with it (#3581). +/// The segment is now skipped, as the renderer, MuPDF and pdftotext do, and the page's images are +/// still listed. +#[test] +fn image_scan_skips_a_path_segment_before_any_m() { + for bad in ["1 1 l S", "1 1 2 2 3 3 c S", "1 1 2 2 4 4 y S", "1 1 2 2 3 3 v S", "h S"] { + let content = format!("q {bad} Q 100 100 200 100 cm /Im0 Do"); + let doc = image_page_with(&content); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1, "{bad}: the image is still listed"); + } + // A path started with m (or re) still extends and closes as before. + let doc = image_page_with("q 0 0 m 1 1 l h S Q 100 100 200 100 cm /Im0 Do"); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1); + let doc = image_page_with("q 10 10 20 20 re W n Q 100 100 200 100 cm /Im0 Do"); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1); +} + /// The box of the first `re` on the page in user space, after the `cm`s before it. fn first_rect_in_user_space(doc: &Document) -> [f64; 4] { let mut ctm = pdfcraft_content::Matrix::IDENTITY; @@ -2131,6 +3812,43 @@ fn paragraphs_take_new_formatting() { } } +#[test] +fn a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps() { + // A centred one-row title (its centre is at x ≈ 306 on the 600 pt page) above a body + // left-aligned at x = 72. Growing it must not drag it to the right. + let src = "BT /F1 18 Tf 1 0 0 1 252.477 700 Tm (Centered title) Tj ET\n\ + BT /F1 12 Tf 1 0 0 1 72 640 Tm (Body text below the heading starts here and runs) Tj ET\n\ + BT /F1 12 Tf 1 0 0 1 72 625.6 Tm (on for a second row of ordinary words.) Tj ET"; + let mut doc = text_page(src); + let before = text::text_blocks(&doc, 0).unwrap()[0].clone(); + let centre = (before.rect[0] + before.rect[2]) / 2.0; + text::rewrite_block(&mut doc, 0, 0, Some("Centered title with a few more words"), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(blocks[0].text, "Centered title with a few more words"); + let r = lines[blocks[0].lines[0]].rect; + assert!(((r[0] + r[2]) / 2.0 - centre).abs() < 1.0, "the grown title stays centred on {centre}, its centre is {}", (r[0] + r[2]) / 2.0); + // Long enough to have to wrap: every row stays centred on the same centre too. + let mut doc = text_page(src); + text::rewrite_block( + &mut doc, + 0, + 0, + Some("Centered title with many more words added so that the single row has to become much longer"), + &text::BlockStyle::default(), + ) + .unwrap(); + let doc = reopen(&doc); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(blocks[0].lines.len() > 1, "the long title wraps into rows: {:?}", blocks[0].text); + for i in &blocks[0].lines { + let r = lines[*i].rect; + assert!(((r[0] + r[2]) / 2.0 - centre).abs() < 2.0, "row {:?} stays centred on {centre}", lines[*i].text); + } +} + #[test] fn a_new_paragraph_colour_does_not_spill_into_the_text_after_it() { let mut doc = text_page("BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj ET"); @@ -2896,6 +4614,88 @@ fn form_xobjects_that_fan_out_stop_at_the_visit_budget() { assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed()); } +/// #3120: a one-page document whose page draws "Secret", then `content`. `forms` are (name, +/// content) form XObjects, which the page and every form can draw, as they can a 1×1 image `/Im`. +fn page_with_forms(content: &str, forms: &[(String, String)]) -> Document { + let xobjects: String = forms.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 7 + i)).collect(); + let resources = format!("<< /Font << /F1 5 0 R >> /XObject << /Im 6 0 R {xobjects}>> >>"); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {resources} /Contents 4 0 R >>"), + stream("", &format!("BT /F1 24 Tf 72 700 Td (Secret) Tj ET\n{content}")), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"), + ]; + objs.extend(forms.iter().map(|(_, body)| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources {resources}"), body))); + build(&objs.iter().map(String::as_str).collect::>()) +} + +/// #3120: export reads text from forms as deep and as often as the renderer draws them, and says +/// when a limit left some out: a text form drawn 5 000 times (past the old budget of 4 096) or +/// forms nested 13 deep (past the old 12) lost the form drawn after them, silently. +#[test] +fn reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops() { + // Covers "Secret" with a black box and writes "TAIL": drawn last, so a reader that stops + // early misses it. + const TAIL: &str = "0 g 70 692 90 30 re f BT /F1 24 Tf 72 600 Td (TAIL) Tj ET"; + let tail = |doc: &Document| { + let read = text::reading_blocks_checked(doc, 0).unwrap(); + (read.blocks.iter().any(|b| b.text.contains("TAIL")), read.truncated) + }; + let form = |name: &str, body: &str| (name.to_string(), body.to_string()); + + let rows = + |n: usize| page_with_forms(&format!("{}/C Do", "/R Do ".repeat(n)), &[form("R", "BT /F1 6 Tf 300 100 Td (row) Tj ET"), form("C", TAIL)]); + assert_eq!(tail(&rows(5_000)), (true, false)); + + // The innermost form also draws an image: an image at the depth limit is read, and leaves + // nothing out. + let chain = |depth: usize| { + let mut forms: Vec<_> = (1..depth).map(|i| form(&format!("D{i}"), &format!("/D{} Do", i + 1))).collect(); + forms.push(form(&format!("D{depth}"), &format!("{TAIL} q 20 0 0 10 5 5 cm /Im Do Q"))); + page_with_forms("/D1 Do", &forms) + }; + assert_eq!(tail(&chain(13)), (true, false)); + // The renderer's limit, written out so the test does not move with the constant. + assert_eq!(tail(&chain(50)), (true, false)); + assert_eq!(tail(&chain(51)), (false, true)); + let deepest = chain(text::MAX_FORM_DEPTH); + assert_eq!(tail(&deepest), (true, false)); + assert_eq!(images::reading_images(&deepest, 0).unwrap().len(), 1); + let deep = chain(text::MAX_FORM_DEPTH + 1); + assert_eq!(tail(&deep), (false, true)); + assert!(images::reading_images(&deep, 0).unwrap().is_empty()); + + // A wrapper form A draws a dot form B `n` times, then C. A, the dots and C use the page's + // budget up exactly; one dot more leaves C unread. + let fanout = + |n: usize| page_with_forms("/A Do", &[form("A", &format!("{}/C Do", "/B Do ".repeat(n))), form("B", "0 g 10 10 1 1 re f"), form("C", TAIL)]); + assert_eq!(tail(&fanout(text::MAX_FORM_VISITS - 2)), (true, false)); + let many = fanout(text::MAX_FORM_VISITS - 1); + assert_eq!(tail(&many), (false, true)); + assert!(images::reading_images(&many, 0).unwrap().is_empty()); + // Without the flag, reading is unchanged. + let blocks: Vec = text::reading_blocks(&many, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(blocks, ["Secret"]); +} + +/// #3120: with the renderer's limits, forms that each draw the next a hundred times, 12 levels +/// deep (100^11 forms) or past the depth cap, still stop at the page's budget quickly, and say +/// that text was left out. +#[test] +fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() { + for levels in [12, text::MAX_FORM_DEPTH + 1] { + let mut forms: Vec<(String, String)> = (1..levels).map(|i| (format!("N{i}"), format!("/N{} Do ", i + 1).repeat(100))).collect(); + forms.push((format!("N{levels}"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET".to_string())); + let doc = page_with_forms("/N1 Do", &forms); + let start = std::time::Instant::now(); + assert!(text::reading_blocks_checked(&doc, 0).unwrap().truncated, "{levels} levels"); + assert!(images::reading_images(&doc, 0).unwrap().is_empty()); + assert!(start.elapsed() < std::time::Duration::from_secs(30), "{levels} levels took {:?}", start.elapsed()); + } +} + /// Forms that draw themselves or each other, have no resources, a malformed matrix, or are /// missing are read once (or skipped) without panicking or looping. #[test] @@ -3899,6 +5699,40 @@ fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() { assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1); } +/// Report generators draw each picture as `q re W n cm Do Q` inside a section's clip that the +/// section's text shares. Resizing the picture scales its own clip with it (the enlarged picture +/// used to show only through its old box) while the section's clip and text stay put. +#[test] +fn resizing_an_image_under_a_shared_clip_moves_its_own_clip() { + // The picture's own clip as wide as the picture, and wider (both seen in one report). + for clip_width in [61.2, 85.0] { + let mut doc = image_page_with(&format!( + "q 20 20 560 760 re W n q 162 79 {clip_width} 28.3 re W n 61.2 0 0 28.3 162 79 cm /Im0 Do Q BT /F1 12 Tf 30 50 Td (Section text) Tj ET Q" + )); + let before = images::page_images(&doc, 0).unwrap()[0].clone(); + assert!(close(before.visible_rect.unwrap(), [162.0, 79.0, 223.2, 107.3]), "{:?}", before.visible_rect); + let caption = text::text_lines(&doc, 0).unwrap(); + let target = [162.0, 79.0, 284.4, 135.6]; + let transform = images::rect_to_rect(before.visible_rect.unwrap(), target); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap(); + doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap()[0].clone(); + let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.05); + assert!(near(after.visible_rect.unwrap(), target), "clip {clip_width}: the whole enlarged picture shows, {:?}", after.visible_rect); + assert_eq!(after.object, before.object, "pixels are never resampled or replaced"); + assert_eq!( + text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::>(), + caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::>(), + "the section's text stays where it was" + ); + // The section's clip is shared, so it still bounds the picture: past it, nothing shows. + let past = images::rect_to_rect(after.visible_rect.unwrap(), [500.0, 79.0, 700.0, 135.6]); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(past)).unwrap(); + let clipped = images::page_images(&reopen(&doc), 0).unwrap()[0].visible_rect.unwrap(); + assert!(clipped[2] <= 580.05, "the section's clip stays in force: {clipped:?}"); + } +} + #[test] fn private_image_crop_with_intervening_state_resizes_across_streams() { let variants: Vec> = vec![ @@ -4547,18 +6381,76 @@ fn a_new_character_comes_from_the_source_fonts_own_embedded_program() { } } +#[test] +fn a_shaped_run_is_one_text_object_with_each_glyph_where_shaping_put_it() { + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET"); + let resource = text::register_text_font(&mut doc, 0, inter).unwrap(); + // Marks shaped beside their letters (q́, x̣) and one stacked above another (ñ̄). + let typed = "q\u{301}x\u{323} n\u{303}\u{304} Cafe\u{301}"; + let style = text::BlockStyle { width: Some(400.0), resource: Some(resource), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(typed), &style).unwrap(); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, typed); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let start = ops.iter().position(|o| o.is("BDC")).unwrap(); + let end = start + ops[start..].iter().position(|o| o.is("EMC")).unwrap(); + let run = &ops[start..end]; + let count = |name: &str| run.iter().filter(|o| o.is(name)).count(); + assert_eq!((count("BT"), count("Tm"), count("Tj")), (1, 1, 0), "{run:?}"); + // Each glyph's origin, read back with the text-space displacements of ISO 32000-1 §9.4.4. + let font = run.iter().find(|o| o.is("Tf")).unwrap(); + let (resource, size) = (font.operands[0].as_name().unwrap().to_vec(), font.operands[1].as_f64().unwrap()); + let page = pdfcraft_model::pages(&doc).remove(0); + let resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let fonts = doc.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone(); + let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, doc.resolve(fonts.get(&resource).unwrap()).as_dict().unwrap()); + let (mut x, mut rise, mut drawn) = (0.0, 0.0, Vec::new()); + for op in run { + match op.op.as_slice() { + b"Ts" => rise = op.operands[0].as_f64().unwrap(), + b"Tc" | b"Tw" => assert_eq!(op.operands[0].as_f64(), Some(0.0), "{run:?}"), + b"Tz" => assert_eq!(op.operands[0].as_f64(), Some(100.0), "{run:?}"), + b"TJ" => { + for item in op.operands[0].as_array().unwrap() { + match item.as_string() { + Some(s) => { + for code in s.bytes.chunks(2) { + drawn.push((x, rise)); + x += metrics.width(u32::from(u16::from_be_bytes([code[0], code[1]]))) * size; + } + } + None => x -= item.as_f64().unwrap() / 1000.0 * size, + } + } + } + _ => {} + } + } + let shaped = pdfcraft_fonts::source_shaping::shape(inter, typed).unwrap(); + assert_eq!(drawn.len(), shaped.glyphs.len()); + assert!(shaped.glyphs.iter().any(|g| g.position[1] != 0.0), "a mark is raised"); + for (glyph, (x, y)) in shaped.glyphs.iter().zip(&drawn) { + assert!((glyph.position[0] * size - x).abs() < 1e-3 && (glyph.position[1] * size - y).abs() < 1e-3, "{glyph:?} at {x}, {y}"); + } + assert_eq!(rise, 0.0, "the rise is reset after the run"); +} + #[test] fn a_subset_program_is_not_trusted_for_new_characters_and_the_error_names_the_character() { + let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular"); + // The subset's own program is not trusted for a character: a fallback face draws it through a + // generated font instead (the subset may map a code whose outline was dropped). The Japanese + // sans face leads the fallbacks when built with craft-fonts; the bundled Inter face otherwise. + let edit = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()).unwrap(); + let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).map_or("Inter", |f| f.family); + assert_eq!(edit.substituted, Some(format!("{face} Type3"))); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, "Stable tőxt"); + // A character no fallback face has is refused, named, and left as it was. let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular"); let before = page_content_bytes(&doc, 0); - let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()); - if pdfcraft_fonts::document_japanese_font().is_some() { - eprintln!("built with craft-fonts: the fallback font draws the new character"); - assert!(result.is_ok(), "{result:?}"); - return; - } + let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable กtext"), &text::BlockStyle::default()); let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") }; - assert!(msg.contains("ő (U+0151)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); + assert!(msg.contains("ก (U+0E01)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); assert_eq!(page_content_bytes(&doc, 0), before); } @@ -4975,3 +6867,1712 @@ fn flattening_comments_keeps_annotations_that_are_not_comments() { let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect(); assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget"); } + +/// A footnote marker (`Ts` text rise, a smaller `Tf` size) does not split the paragraph around it +/// into separate editable zones (#3252): on `main` this read as three blocks — the text up to the +/// marker, the marker alone, and the text after it — because the row-joining baseline tolerance +/// was scaled by the smaller of the two runs' sizes, far tighter than a typical superscript's rise. +#[test] +fn a_superscript_footnote_marker_stays_in_its_paragraphs_row() { + let doc = text_page( + "BT /F1 12 Tf 72 700 Td (and A.) Tj 0 -14 Td (Edward Siecienski) Tj 4 Ts /F1 7.2 Tf (3) Tj 0 Ts /F1 12 Tf (. More words continue the paragraph right after the marker.) Tj ET", + ); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 4, "the marker is its own line, raised and smaller: {lines:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "one paragraph, not split at the marker: {blocks:?}"); + assert_eq!(blocks[0].rows.len(), 2, "two visual rows, the second carrying the marker"); + assert_eq!(blocks[0].text, "and A. Edward Siecienski3. More words continue the paragraph right after the marker.", "{:?}", blocks[0]); +} + +/// A page whose text is a clipping mask (`mode` Tr) with a fill painted through it. +fn clipping_text_page(mode: i64) -> Document { + text_page(&format!("q\nBT {mode} Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n")) +} + +/// Rewriting text that is a clipping path destroyed the artwork it masked: the glyphs went, the +/// mask with them, and the fill behind flooded its own rectangle, in a file that still opened +/// without a warning (#3429). Photoshop exports draw every text object this way. The rewrite is +/// refused instead, as `clear_block` already refuses a preview. +#[test] +fn rewriting_clipping_path_text_is_refused_for_every_clipping_mode() { + for mode in [4, 5, 6, 7] { + let before = text::text_lines(&clipping_text_page(mode), 0).unwrap(); + assert_eq!(before.len(), 1, "mode {mode}: the mask is one line"); + + let mut doc = clipping_text_page(mode); + let line = text::replace_line(&mut doc, 0, 0, "EDITED"); + assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {line:?}"); + + let mut doc = clipping_text_page(mode); + let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default()); + assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {block:?}"); + + // Refused means untouched: the mask is still there to shape what is painted through it. + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "mode {mode}"); + assert_eq!(after[0].text, "MASK", "mode {mode}"); + } +} + +/// The modes that paint ink are still editable: only 4 to 7 add to the clip path. +#[test] +fn painting_modes_are_still_rewritten() { + for mode in [0, 1, 2] { + let mut doc = clipping_text_page(mode); + assert!(text::replace_line(&mut doc, 0, 0, "EDITED").is_ok(), "mode {mode}"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "EDITED", "mode {mode}"); + } +} + +/// A line's runs need not share a rendering mode: nothing in the join requires it, so a line that +/// starts as ink can continue as a mask. The refusal reads the whole line, not the mode its first +/// run set -- otherwise the rewrite passed the guard and took the mask away with the ink. +#[test] +fn a_line_that_starts_as_ink_and_continues_as_a_mask_is_refused() { + let content = "q\nBT /F1 48 Tf 40 60 Td 0 Tr (TITLE) Tj 7 Tr (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n"; + let before = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(before.len(), 1, "the two runs are one line: {:?}", before.iter().map(|l| &l.text).collect::>()); + assert_eq!(before[0].text, "TITLEMASK", "and the mask is the second half of it"); + + let mut doc = text_page(content); + let line = text::replace_line(&mut doc, 0, 0, "EDITED"); + assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}"); + let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default()); + assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "TITLEMASK", "refused means untouched"); +} + +/// Another drawing of the line on the same baseline goes with it, so that the replacement is all +/// that shows -- and that copy can be the mask while the line being replaced is ink. Dropping it +/// destroys the artwork as surely as rewriting the mask itself, so this is refused too. +#[test] +fn a_clipping_copy_on_the_same_baseline_is_refused() { + let content = "q\nBT 0 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\nBT 7 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n"; + let before = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(before.len(), 2, "two drawings of the same line"); + + let mut doc = text_page(content); + let line = text::replace_line(&mut doc, 0, 0, "EDITED"); + assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}"); + let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default()); + assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}"); + assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched"); +} + +/// #2512: a session's drafts are laid out from the document as it was opened, never from each +/// other, and its commit writes what a one-shot rewrite writes, whatever was laid out before. +#[test] +fn a_text_session_commits_what_a_rewrite_writes_whatever_it_laid_out_before() { + let page = "BT /F1 12 Tf 14 TL 72 700 Td (The first row of a paragraph) Tj T* (and its second row here.) Tj ET"; + let bold = BlockStyle { bold: Some(true), ..Default::default() }; + let drafts: [(&str, &BlockStyle); 4] = [ + ("The first row of a paragraph\nand its second row here, with more words to wrap it onto a third row", &BlockStyle::default()), + ("Short", &bold), + ("The first row\nsecond", &BlockStyle::default()), + ("The first row of a paragraph\nand its second row here.", &bold), + ]; + let doc = text_page(page); + let session = text::TextSession::open(&doc, 0, 0).unwrap(); + assert_eq!(session.source().editor_text, "The first row of a paragraph\nand its second row here."); + for (text, style) in drafts { + let fresh = text::text_edit_layout(&text_page(page), 0, 0, text, style).unwrap(); + assert_eq!(session.layout(text, style).unwrap(), fresh, "{text:?} is laid out from the opened page"); + } + let (text, style) = drafts[0]; + let mut by_session = doc.clone(); + session.commit(&mut by_session, Some(text), style).unwrap(); + let mut by_rewrite = text_page(page); + text::rewrite_block(&mut by_rewrite, 0, 0, Some(text), style).unwrap(); + assert_eq!( + pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(), + pdfcraft_cos::write_full(&by_rewrite, &SaveOptions::default()).unwrap(), + "the commit writes what a rewrite writes" + ); + // A document changed since the session opened isn't written to. + let before = pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(); + let refused = session.commit(&mut by_session, Some("Again"), &BlockStyle::default()); + assert!(matches!(&refused, Err(EditError::Invalid(m)) if m.contains("changed while it was being edited")), "{refused:?}"); + assert_eq!(pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(), before); + assert!(session.is_current(&doc) && !session.is_current(&by_session)); +} + +/// A one-page document whose resources have `/GS0` (30 % opacity) and `/L1`, a layer that is OFF. +fn row_state_page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /OFF [5 0 R] /Order [5 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [6 0 R] /Count 1 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /ExtGState /ca 0.3 /CA 0.3 >>".into(), + "<< /Type /OCG /Name (Hidden layer) >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 7 0 R /Resources << /Font << /F1 3 0 R >> /ExtGState << /GS0 4 0 R /GS1 << /Type /ExtGState /ca 0.3 /CA 0.3 /BM /Normal >> /GS2 << /Type /ExtGState /ca 1 /CA 1 >> >> /Properties << /L1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// Each shown string on page 0 with the state it is drawn in: inside a `/OC` marked-content +/// sequence, after `/GS0 gs`, and its horizontal scale (percent). +fn shown_states(doc: &Document) -> Vec<(String, bool, bool, f64)> { + let joined = streams(doc, 0).join("\n"); + let (mut oc, mut marks) = (0usize, Vec::new()); + let (mut gs, mut tz, mut stack) = (false, 100.0, Vec::new()); + let mut out = Vec::new(); + for op in pdfcraft_content::parse(joined.as_bytes()).ops { + match op.op.as_slice() { + b"BDC" | b"BMC" => { + let layer = op.name(0) == Some(b"OC".as_slice()); + oc += usize::from(layer); + marks.push(layer); + } + b"EMC" => oc -= usize::from(marks.pop().unwrap()), + b"q" => stack.push((gs, tz)), + b"Q" => (gs, tz) = stack.pop().unwrap(), + b"gs" => gs |= op.name(0) == Some(b"GS0".as_slice()), + b"Tz" => tz = op.num(0).unwrap(), + b"Tj" | b"'" => { + let Some(Object::String(s)) = op.operands.last() else { continue }; + if !s.bytes.is_empty() { + out.push((String::from_utf8_lossy(&s.bytes).into_owned(), oc > 0, gs, tz)); + } + } + _ => {} + } + } + out +} + +/// #3115: a row drawn in a hidden layer, at 30 % opacity or at 80 % horizontal scale keeps that +/// state when another row of the same paragraph is edited; the edited row doesn't take it on. +#[test] +fn editing_a_row_keeps_the_layer_opacity_and_scale_of_the_rows_around_it() { + let row1 = "BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj ET\n"; + let row2 = "BT /F1 12 Tf 72 686 Td (and the second) Tj ET"; + let pages = [ + (format!("{row1}/OC /L1 BDC {row2} EMC"), (true, false, 100.0)), + (format!("{row1}q /GS0 gs {row2} Q"), (false, true, 100.0)), + ("BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj 80 Tz 0 -14 Td (and the second) Tj ET".to_string(), (false, false, 80.0)), + (format!("{row1}{row2}"), (false, false, 100.0)), + ]; + for (n, (content, (oc, gs, tz))) in pages.iter().enumerate() { + let mut doc = row_state_page(content); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let block = blocks.iter().position(|b| b.text.starts_with("Hello")).unwrap(); + let text = blocks[block].text.replacen("Hello", "Jello", 1); + text::replace_block(&mut doc, 0, block, &text).unwrap(); + let shown = shown_states(&reopen(&doc)); + let find = |needle: &str| shown.iter().filter(|s| s.0.contains(needle)).cloned().collect::>(); + let first = find("Jello"); + assert_eq!(first.len(), 1, "page {}: {shown:?}", n + 1); + assert_eq!((first[0].1, first[0].2, first[0].3), (false, false, 100.0), "page {}: the edited row keeps its own state: {shown:?}", n + 1); + let second = find("and the second"); + assert_eq!(second.len(), 1, "page {}: {shown:?}", n + 1); + assert_eq!((second[0].1, second[0].2, second[0].3), (*oc, *gs, *tz), "page {}: the other row keeps its state: {shown:?}", n + 1); + } + // The control: rows in the same state are still one paragraph. + let blocks = text::text_blocks(&row_state_page(&pages[3].0), 0).unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].text, "Hello world, this is the first line and the second"); +} + +/// What keeps rows apart is the graphics state they are drawn in, not the `gs` operators +/// before them: a repeated `gs` without q/Q, or another name with the same settings, is still one +/// paragraph, and a `gs` that changes the opacity or puts it back is not. +#[test] +fn rows_under_repeated_or_equal_gs_states_stay_one_paragraph() { + let row1 = "BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj ET\n"; + let row2 = "BT /F1 12 Tf 72 686 Td (and the second) Tj ET"; + let paragraphs = |content: String| text::text_blocks(&row_state_page(&content), 0).unwrap().len(); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS0 gs {row2}")), 1, "the same name twice"); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS0 gs /GS0 gs {row2}")), 1, "the same name three times"); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS1 gs {row2}")), 1, "two names, the same settings"); + assert_eq!(paragraphs(format!("/GS2 gs {row1}{row2}")), 1, "settings that are the defaults"); + assert_eq!(paragraphs(format!("{row1}/GS0 gs {row2}")), 2, "another opacity"); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS2 gs {row2}")), 2, "the opacity put back"); +} + +/// A page of three vertical (`Identity-V`) columns at 20 pt: `ABCDEF` at x = 500, the same +/// text 30 pt to its left, and `ABCD` far away. Synthetic font: no font program is needed. +fn vertical_columns_page() -> Document { + vertical_page( + "BT /V1 20 Tf 500 720 Td <004100420043004400450046> Tj ET\nBT /V1 20 Tf 470 720 Td <004100420043004400450046> Tj ET\nBT /V1 20 Tf 300 720 Td <0041004200430044> Tj ET", + ) +} + +/// A page drawing `content` with the vertical font `/V1` (as in [`vertical_columns_page`]) and +/// Helvetica `/F1`. +fn vertical_page(content: &str) -> Document { + let mut doc = text_page(content); + let mut font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type0")), + (b"BaseFont".to_vec(), Object::name("SyntheticCID")), + (b"Encoding".to_vec(), Object::name("Identity-V")), + ]); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <0000> endcodespacerange 6 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar", + ))); + font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); + font.set( + b"DescendantFonts".to_vec(), + Object::Array(vec![Object::Dict(Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("CIDFontType2")), + (b"DW".to_vec(), Object::Int(1000)), + ]))]), + ); + let p = pdfcraft_model::pages(&doc)[0].obj; + let helvetica = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type1")), + (b"BaseFont".to_vec(), Object::name("Helvetica")), + (b"Encoding".to_vec(), Object::name("WinAnsiEncoding")), + ]); + let fonts = Dict::from_iter([(b"V1".to_vec(), Object::Dict(font)), (b"F1".to_vec(), Object::Dict(helvetica))]); + let resources = Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + doc +} + +#[test] +fn vertical_text_lines_are_columns_and_editing_one_keeps_its_neighbour() { + let doc = vertical_columns_page(); + let lines = text::text_lines(&doc, 0).unwrap(); + let first = lines.iter().find(|l| l.baseline()[0] == 500.0).unwrap(); + let r = first.rect; + // Six glyphs, each 20 pt wide and advancing 20 pt down, centred on x = 500. + assert!((r[0] - 490.0).abs() < 0.5 && (r[2] - 510.0).abs() < 0.5, "{r:?}"); + assert!((r[1] - 600.0).abs() < 0.5 && (r[3] - 720.0).abs() < 0.5, "{r:?}"); + for edit in [0, 1] { + let mut doc = vertical_columns_page(); + let lines = text::text_lines(&doc, 0).unwrap(); + if edit == 0 { + let line = lines.iter().position(|l| l.baseline()[0] == 500.0).unwrap(); + text::replace_line(&mut doc, 0, line, "ABCDEA").unwrap(); + } else { + let blocks = text::text_blocks(&doc, 0).unwrap(); + let block = blocks.iter().position(|b| b.lines.iter().any(|&i| lines[i].baseline()[0] == 500.0)).unwrap(); + assert_eq!(blocks[block].lines.len(), 1, "{blocks:?}"); + text::replace_block(&mut doc, 0, block, "ABCDEA").unwrap(); + } + let after = text::text_lines(&doc, 0).unwrap(); + let texts: Vec<(f64, &str)> = after.iter().map(|l| (l.baseline()[0], l.text.as_str())).collect(); + assert!(texts.contains(&(500.0, "ABCDEA")), "{texts:?}"); + assert!(texts.contains(&(470.0, "ABCDEF")), "{texts:?}"); + assert!(texts.contains(&(300.0, "ABCD")), "{texts:?}"); + } +} + +/// Gives `/V1` an embedded CMap that uses itself: which way its glyphs advance can't be known. +fn make_cmap_cyclic(doc: &mut Document) { + let cmap = doc.add(Object::Null); + doc.set( + cmap, + Object::Stream(Stream::flate( + Dict::from_iter([(b"UseCMap".to_vec(), Object::Ref(cmap))]), + b"1 begincodespacerange <0000> endcodespacerange", + )), + ); + let p = pdfcraft_model::pages(doc)[0].obj; + doc.update_dict(p, |d| { + if let Some(Object::Dict(res)) = d.get_mut(b"Resources") + && let Some(Object::Dict(fonts)) = res.get_mut(b"Font") + && let Some(Object::Dict(font)) = fonts.get_mut(b"V1") + { + font.set(b"Encoding".to_vec(), Object::Ref(cmap)); + } + }) + .unwrap(); +} + +#[test] +fn text_whose_cmap_writing_mode_cannot_be_read_is_not_edited() { + let mut doc = vertical_columns_page(); + make_cmap_cyclic(&mut doc); + let error = text::replace_line(&mut doc, 0, 0, "ABCDEA").unwrap_err(); + assert!(error.to_string().contains("cyclic"), "{error}"); +} + +#[test] +fn a_cyclic_cmap_falls_back_to_horizontal_and_only_refuses_its_own_lines() { + let mut doc = vertical_page("BT /V1 20 Tf 100 700 Td <00410042> Tj ET\nBT /F1 12 Tf 100 600 Td (Plain) Tj ET"); + make_cmap_cyclic(&mut doc); + // The page is still listed: the broken font's text as horizontal, the rest untouched. + let lines = text::text_lines(&doc, 0).unwrap(); + let texts: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect(); + assert_eq!(texts, ["AB", "Plain"], "{texts:?}"); + assert!(lines[0].rect[2] - lines[0].rect[0] > 30.0 && lines[0].rect[3] - lines[0].rect[1] < 30.0, "{:?}", lines[0].rect); + assert_eq!(text::reading_blocks(&doc, 0).unwrap().len(), 2); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + // Only the line in the broken font is refused. + let error = text::replace_line(&mut doc.clone(), 0, 0, "BA").unwrap_err(); + assert!(error.to_string().contains("cyclic"), "{error}"); + let block = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "AB").unwrap(); + let error = text::replace_block(&mut doc.clone(), 0, block, "BA").unwrap_err(); + assert!(error.to_string().contains("cyclic"), "{error}"); + text::replace_line(&mut doc, 0, 1, "Changed").unwrap(); + let texts: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(texts, ["AB", "Changed"]); +} + +#[test] +fn vertical_text_drawn_one_glyph_per_tj_is_one_line() { + // One text object, a `Tj` per glyph; and a column with a text object per glyph. + let doc = vertical_page( + "BT /V1 20 Tf 500 720 Td <0041> Tj <0042> Tj <0043> Tj ET\n\ + BT /V1 20 Tf 1 0 0 1 470 720 Tm <0041> Tj ET\nBT /V1 20 Tf 1 0 0 1 470 700 Tm <0042> Tj ET\nBT /V1 20 Tf 1 0 0 1 470 680 Tm <0043> Tj ET", + ); + let lines = text::text_lines(&doc, 0).unwrap(); + let columns: Vec<(f64, &str)> = lines.iter().map(|l| (l.baseline()[0], l.text.as_str())).collect(); + assert_eq!(columns, [(500.0, "ABC"), (470.0, "ABC")], "{columns:?}"); + for line in &lines { + let r = line.rect; + assert!((r[1] - 660.0).abs() < 0.5 && (r[3] - 720.0).abs() < 0.5, "{r:?}"); + } + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); +} + +/// Two genuinely stacked rows of ordinary body text must not join into one row just because the +/// first is narrow enough to put the second's start within the #3252 tolerance band: here the +/// second row starts 1.4pt to the left of where the first ends (so the two overlap a little, +/// as compressed text or a tight column can) and 5pt below its baseline — inside the widened 50% +/// tolerance but outside the original 20% one. They stay two rows (and two paragraphs). +#[test] +fn stacked_rows_that_merely_overlap_do_not_join_into_one_row() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (A compressed line of body text here) Tj 190 -5 Td (Second stacked line of body text) Tj ET"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + let gap = lines[1].rect[0] - lines[0].rect[2]; + assert!((-2.4..0.0).contains(&gap), "the rows overlap a little, within the old tolerance band: {gap}"); + let baseline_shift = lines[0].rect[3] - lines[1].rect[3]; + assert!((2.4..6.0).contains(&baseline_shift), "between the old and new vertical tolerance: {baseline_shift}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2, "two separate rows stay two paragraphs, not one: {blocks:?}"); +} + +/// Two equal-size rows with no horizontal overlap must not join into one row either (#3252): +/// the wider tolerance is for a run distinctly smaller or larger than its neighbour (a +/// super/subscript), not for two ordinary body-text rows that happen to sit close enough +/// vertically — here a 5pt baseline shift, inside the widened 50% tolerance but outside the +/// original 20% one, with the second row starting just past where the first ends. +#[test] +fn equal_size_rows_need_the_tighter_tolerance_even_without_overlap() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (Short line one) Tj 75 -5 Td (Second line two here) Tj ET"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].size, lines[1].size, "same size: no super/subscript here"); + let gap = lines[1].rect[0] - lines[0].rect[2]; + assert!(gap >= 0.0, "no horizontal overlap: {gap}"); + let baseline_shift = lines[0].rect[3] - lines[1].rect[3]; + assert!((2.4..6.0).contains(&baseline_shift), "between the old and new vertical tolerance: {baseline_shift}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2, "two separate rows stay two paragraphs, not one: {blocks:?}"); +} + +/// A `TJ` with a surplus array operand (`[(FIRSTARR)] [(LASTARR)] TJ`) is malformed, but the +/// operator still takes the one immediately before it, as the renderer, MuPDF and pdftotext do +/// (#3569) -- not the first, which this reader took before, showing a word the page never draws. +#[test] +fn a_tj_with_a_surplus_array_reads_the_last_one() { + let doc = text_page("BT /F1 24 Tf 20 100 Td [(FIRSTARR)] [(LASTARR)] TJ ET"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, "LASTARR", "the operand immediately before TJ, as the page draws: {lines:?}"); +} + +/// A producer wrote `0.0001 Tc` on one word of a line (Word through macOS Quartz). That spacing +/// can't be seen, so the word stays on its line; spacing that can be seen still keeps runs apart. +#[test] +fn a_word_with_invisible_extra_spacing_stays_on_its_line() { + let page = |spacing: &str| { + text_page(&format!( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (for company ) Tj ET \ + BT /F1 12 Tf 1 0 0 1 141.36 700 Tm (A) Tj ET \ + BT {spacing} Tc /F1 12 Tf 1 0 0 1 149.364 700 Tm (EROSOL) Tj ET" + )) + }; + let blocks = text::text_blocks(&page("0.0001"), 0).unwrap(); + assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::>(), ["for company AEROSOL"]); + + let mut doc = page("0.0001"); + text::rewrite_block(&mut doc, 0, 0, Some("for company AEROSOL LLC"), &BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["for company AEROSOL LLC"]); + assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{:?}", lines[0].rect); + + let blocks = text::text_blocks(&page("0.5"), 0).unwrap(); + assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::>(), ["for company A", "EROSOL"]); +} + +/// #3573: past the 256-level marked-content cap every `EMC` must still close its own sequence, +/// so `/ActualText` nested that deep is neither dropped nor applied to too little text. +#[test] +fn actual_text_pairs_past_the_marked_content_cap() { + let span = "/Span <> BDC BT /F1 24 Tf 72 700 Td (abc) Tj ET EMC"; + let outer = "/Span <> BDC BT /F1 24 Tf 72 700 Td /Y BMC (abc) Tj EMC (def) Tj ET EMC"; + for (content, expected) in [ + (span.to_string(), "REPLACED"), + (format!("{}{span}{}", "/X BMC ".repeat(256), " EMC".repeat(256)), "REPLACED"), + (format!("{}{span}{}", "/X BMC ".repeat(1000), " EMC".repeat(1000)), "REPLACED"), + (outer.to_string(), "OUTER"), + (format!("{}{outer}{}", "/X BMC ".repeat(255), " EMC".repeat(255)), "OUTER"), + ] { + let doc = text_page(&content); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [expected]); + let reading: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(reading, [expected]); + } +} + +fn page_background_from_content(content: &str, resources: &str) -> Option<[f64; 3]> { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes()); + } + let xref_offset = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); + page_background(&Document::open(Arc::new(bytes)).unwrap(), 0).unwrap() +} + +fn assert_page_background_fill_cases(gs: &str) { + assert_eq!(page_background_from_content("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); + assert_eq!(page_background_from_content("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); + assert_eq!(page_background_from_content("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); + assert_eq!(page_background_from_content("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), + Some([1.0, 0.0, 0.0]), + "one of the rectangles covers it" + ); + // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the + // other way under nonzero. + assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), + Some([1.0, 0.0, 0.0]), + "nonzero, same winding: no hole" + ); + assert_eq!( + page_background_from_content("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), + None, + "reversed by a mirroring cm" + ); + assert_eq!(page_background_from_content("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); + assert_eq!(page_background_from_content("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); + assert_eq!(page_background_from_content("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); + assert_eq!(page_background_from_content("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); + assert_eq!(page_background_from_content("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); + assert_eq!(page_background_from_content("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); + assert_eq!(page_background_from_content("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), + None, + "a page fill with a hole cut in it" + ); +} + +fn assert_page_background_transform_and_clip_cases() { + // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop + // box's corners are inside it; its bounding box covering the crop box isn't enough. This one + // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but + // 354 along the diagonals, short of the corners. One reaching 707 covers them. + let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; + assert_eq!( + page_background_from_content(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), + None, + "a turned fill short of the corners" + ); + assert_eq!( + page_background_from_content(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned fill over them" + ); + // The same for a clip. + assert_eq!( + page_background_from_content(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + None, + "a turned clip" + ); + assert_eq!( + page_background_from_content(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned clip over the corners" + ); + // Clips add up: two that each cover the page leave it covered, one that doesn't among them + // doesn't. + assert_eq!( + page_background_from_content("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), + Some([1.0, 0.0, 0.0]), + "two covering clips" + ); + assert_eq!( + page_background_from_content("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), + None, + "the second clip doesn't cover" + ); + // Past 64 clipping rectangles the clip counts as unknown. + let clips = "0 0 600 800 re W n ".repeat(65); + assert_eq!(page_background_from_content(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); + // A rectangle drawn the other way round (negative width) covers the page all the same. + assert_eq!(page_background_from_content("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); + // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the + // edge of that included. + assert_eq!(page_background_from_content("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); + assert_eq!(page_background_from_content("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); + assert_eq!(page_background_from_content("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); +} + +/// #2992: page background detection follows the first full-page fill under text and ignores +/// partial, translucent, clipped, or unsupported fills. +#[test] +fn a_page_painted_first_with_a_full_page_fill_has_that_background() { + let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; + let mut doc = fixture(); + assert_eq!(page_background(&doc, 0).unwrap(), None, "text first"); + add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap(); + let doc = reopen(&doc); + let teal = page_background(&doc, 0).unwrap().expect("Background's fill"); + assert!(teal.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6), "{teal:?}"); + assert!(page_background(&doc, 1).unwrap().is_some(), "on a turned page too"); + assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content"); + assert!(page_background(&doc, 9).is_err()); + + assert_page_background_fill_cases(gs); + assert_page_background_transform_and_clip_cases(); +} + +#[test] +fn reading_keeps_dotted_leader_entries_whole_and_in_order() { + let mut content = String::new(); + for (title, page, y) in [("Art.1. Scope", "3", 700), ("Art.2. Eligibility", "7", 680), ("Art.3. Application", "12", 660)] { + content.push_str(&format!( + "BT /F1 12 Tf 72 {y} Td ({title}) Tj ET \ + BT /F1 12 Tf 250 {y} Td (....................) Tj ET \ + BT /F1 12 Tf 500 {y} Td ({page}) Tj ET\n" + )); + } + content.push_str("BT /F1 12 Tf 72 640 Td (Appendix) Tj ET BT /F1 12 Tf 250 640 Td (\\205\\205\\205) Tj ET BT /F1 12 Tf 500 640 Td (iv) Tj ET\n"); + content.push_str("BT /F1 12 Tf 72 600 Td (Ordinary body text.) Tj ET BT /F1 12 Tf 72 582 Td (Still the same paragraph.) Tj ET"); + let doc = text_page(&content); + let blocks: Vec<_> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!( + blocks, + [ + "Art.1. Scope .................... 3", + "Art.2. Eligibility .................... 7", + "Art.3. Application .................... 12", + "Appendix ……… iv", + "Ordinary body text. Still the same paragraph.", + ] + ); +} + +/// Text set larger than the widest page PDF allows (14 400 pt) panicked when its paragraph was given +/// a width: the width is clamped between the font size and 14 400 pt, and `f64::clamp` panics when +/// its minimum is the larger. The width is now capped at 14 400 pt whatever the font size: from the +/// file (`Tf`, or a small `Tf` scaled up by `Tm`) or asked for (the automation tools pass it through). +#[test] +fn width_on_a_paragraph_set_larger_than_a_page_is_capped_not_a_panic() { + let wide = text::BlockStyle { width: Some(200.0), ..Default::default() }; + let huge = text::BlockStyle { size: Some(20_000.0), width: Some(200.0), ..Default::default() }; + for (content, style) in [ + ("BT /F2 20000 Tf 72 700 Td (ab) Tj ET", &wide), + ("BT /F2 1 Tf 20000 0 0 20000 72 700 Tm (ab) Tj ET", &wide), + ("BT /F2 12 Tf 72 700 Td (ab) Tj ET", &huge), + ] { + let mut doc = text_page(content); + text::rewrite_block(&mut doc, 0, 0, None, style).unwrap(); + } +} + +#[test] +fn added_text_is_wrapped_once_per_write() { + let mut doc = fixture(); + let lines: Vec = (0..40).map(|i| format!("line {i}")).collect(); + let text = AddedText { rect: [72.0, 100.0, 300.0, 700.0], text: lines.join("\n"), size: 10.0, align: Align::Justify, ..AddedText::default() }; + added::WRAP_CALLS.with(|c| c.set(0)); + add_content(&mut doc, 0, &Content::Text(text)).unwrap(); + let calls = added::WRAP_CALLS.with(std::cell::Cell::get); + assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines"); +} + +/// Character spacing on added text (#4245) is the PDF's `Tc`, kept in `/PCAdded` so the item +/// reopens with it; an item written without it reads 0. +#[test] +fn added_text_character_spacing_is_drawn_and_reopens() { + let mut doc = fixture(); + let plain = AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "Approved".into(), size: 10.0, ..AddedText::default() }; + add_content(&mut doc, 0, &Content::Text(plain.clone())).unwrap(); + add_content(&mut doc, 0, &Content::Text(AddedText { char_spacing: 2.5, ..plain })).unwrap(); + let doc = reopen(&doc); + let spacings: Vec = list_added(&doc).iter().map(|a| if let Content::Text(t) = &a.content { t.char_spacing } else { f64::NAN }).collect(); + assert_eq!(spacings, [0.0, 2.5]); + let page = streams(&doc, 0); + let tc: Vec = page.iter().filter(|s| s.contains("(Approved) Tj")).map(|s| s.contains("2.5 Tc")).collect(); + assert_eq!(tc, [false, true], "{page:?}"); +} + +/// Spacing widens every measure the layout takes: the widest line and wrapping. +#[test] +fn added_text_character_spacing_widens_its_layout() { + let plain = AddedText { rect: [0.0, 0.0, 1000.0, 100.0], text: "Approved".into(), size: 10.0, ..AddedText::default() }; + let spaced = AddedText { char_spacing: 3.0, ..plain.clone() }; + // After each of the 8 letters, like `Tc`. + assert!((added::text_width(&spaced) - added::text_width(&plain) - 24.0).abs() < 1e-9); + let w = added::text_width(&plain); + let narrow = |t: &AddedText| AddedText { text: "Approved Approved".into(), rect: [0.0, 0.0, 2.0 * w + 5.0, 100.0], ..t.clone() }; + assert_eq!(added::lines(&narrow(&plain)).len(), 1); + assert_eq!(added::lines(&narrow(&spaced)).len(), 2, "the spaced words no longer fit one line"); +} + +/// A field tree whose every node lists its child twice is pruned in work linear in its nodes, not +/// in its paths, and a node listing itself is visited once. +#[test] +fn flattening_prunes_a_field_tree_with_repeated_kids_once_per_node() { + let levels = 16; + let page = stream("", " "); + // 5: the flattened widget; 6…: the chain; then the hidden widget at its end, on the page. + let leaf = 6 + levels; + let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R {} 0 R] >> >>", leaf + 2); + let annots = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R {leaf} 0 R] >>"); + let mut objs: Vec = vec![ + fields, + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + annots, + page, + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N {} 0 R >> >>", leaf + 1), + ]; + for i in 0..levels { + let next = 7 + i; + objs.push(format!("<< /T (n{i}) /Kids [{next} 0 R {next} 0 R] >>")); + } + objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 >>".into()); + objs.push("<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into()); + objs.push(format!("<< /T (loop) /Kids [{} 0 R] >>", leaf + 2)); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let mut doc = build(&objs); + flatten::KEEP_CALLS.with(|c| c.set(0)); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1); + let calls = flatten::KEEP_CALLS.with(std::cell::Cell::get); + assert!(calls <= 2 * (levels + 4), "{calls} field-tree visits"); + let root = doc.root().unwrap(); + let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap()); + let left = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().len(); + assert_eq!(left, 2, "the chain to the hidden widget and the self-listing node stay"); +} + +/// A field reached first at the end of a long chain has its kids cut off by the depth limit, so +/// they survive there. A shorter path to the same field still looks at its kids: an off-page widget +/// under it is taken out when every field is flattened. +#[test] +fn a_field_reached_again_by_a_shorter_path_is_pruned_there() { + let chain = 64; + let page = stream("", " "); + // 7…: the chain; then the shared field, its off-page widget, and the short path to it. + let shared = 7 + chain; + let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 7 0 R {} 0 R] >> >>", shared + 2); + let mut objs: Vec = vec![ + fields, + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>".into(), + page, + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into(), + ]; + for i in 0..chain { + objs.push(format!("<< /T (c{i}) /Kids [{} 0 R] >>", 8 + i)); + } + objs.push(format!("<< /T (shared) /Kids [{} 0 R] >>", shared + 1)); + objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 >>".into()); + objs.push(format!("<< /T (short) /Kids [{shared} 0 R] >>")); + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1); + let shared_ref = pdfcraft_cos::ObjRef::new(u32::try_from(shared).unwrap(), 0); + let kids = doc.resolve(doc.get(shared_ref).as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().len(); + assert_eq!(kids, 0, "the off-page widget is pruned along the short path"); +} + +/// A ToUnicode destination is repeated for every glyph that shows its code: a page whose text +/// would run past the extraction budget is refused, not built in memory. +#[test] +fn page_text_past_the_budget_is_refused() { + let cmap = stream("", &format!("1 beginbfchar <41> <{}> endbfchar", "0041".repeat(256))); + let page = |shown: &str| { + let content = stream("", &format!("BT /F1 12 Tf 10 10 Td ({shown}) Tj ET")); + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + &content, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>", + &cmap, + ]) + }; + let doc = page(&"A".repeat(100_000)); + assert!(matches!(text_lines(&doc, 0), Err(EditError::Invalid(_))), "25 MB of text from a 100 kB page"); + let doc = page("AA"); + assert_eq!(text_lines(&doc, 0).unwrap()[0].text.len(), 512, "a page within the budget still reads"); +} + +/// A one-page document from the page's /Contents and /Resources, built in code. +fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.add(Object::Null); + let (contents, resources) = build(&mut doc); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), contents), + (b"Resources".to_vec(), Object::Dict(resources)), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + // One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents. + let doc = page_with(|doc| { + let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + (Object::Array(vec![Object::Ref(spaces); 300]), Dict::new()) + }); + match text_lines(&doc, 0) { + Ok(lines) => panic!("read {} lines from more content than a page may decode", lines.len()), + Err(e) => assert!(matches!(e, EditError::Invalid(_)), "{e}"), + } +} + +#[test] +fn reading_images_stops_at_the_page_record_limit() { + // 20 draws of a form drawing a form 20 times that draws an image 20 times: 8,000 images + // per level-two form, 160,000 in all, from a few hundred bytes. + let doc = page_with(|doc| { + let image: Dict = [ + (b"Subtype".to_vec(), Object::Name(b"Image".to_vec())), + (b"Width".to_vec(), Object::Int(1)), + (b"Height".to_vec(), Object::Int(1)), + (b"ColorSpace".to_vec(), Object::Name(b"DeviceGray".to_vec())), + (b"BitsPerComponent".to_vec(), Object::Int(8)), + ] + .into_iter() + .collect(); + let mut inner = doc.add(Stream::from_raw(image, vec![0])); + let draws = "/X Do ".repeat(20); + let resources = |inner: pdfcraft_cos::ObjRef| -> Dict { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() + }; + for _ in 0..3 { + let mut d = resources(inner); + d = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())), (b"Resources".to_vec(), Object::Dict(d))].into_iter().collect(); + inner = doc.add(Stream::from_raw(d, draws.clone().into_bytes())); + } + let page = doc.add(Stream::from_raw(Dict::new(), draws.into_bytes())); + (Object::Ref(page), resources(inner)) + }); + if let Ok(images) = crate::reading_images(&doc, 0) { + assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len()); + } +} + +#[test] +fn reading_spends_one_decode_budget_on_content_and_forms() { + // 130 MiB of content and a 130 MiB form (a kilobyte each on disk): each fits a page's budget, + // together they don't, so reading leaves the form out and says so. + let doc = page_with(|doc| { + let mut body = b"%".to_vec(); + body.resize(130 << 20, b'x'); + let form: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + let form = doc.add(Stream::flate(form, &body)); + let mut content = b"/X Do\n%".to_vec(); + content.resize(130 << 20, b'x'); + let content = doc.add(Stream::flate(Dict::new(), &content)); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + (Object::Ref(content), [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()) + }); + assert!(crate::text::reading_blocks_checked(&doc, 0).unwrap().truncated); +} + +/// A page that can't take flattened content on top (marked content nested past the depth budget) +/// refuses the whole flatten: the pages before it keep their comments and content. +#[test] +fn a_flatten_refused_on_a_later_page_changes_nothing() { + let deep = stream("", &"/Artifact BMC\n".repeat(10_001)); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>", + &stream("", " "), + "<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /F 4 /AP << /N 9 0 R >> >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 7 0 R /Annots [8 0 R] >>", + &deep, + "<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /F 4 /AP << /N 9 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 0 >>\nstream\n\nendstream", + ]); + let first = pdfcraft_cos::ObjRef::new(3, 0); + let before = (*doc.get(first)).clone(); + assert!(matches!(flatten(&mut doc, &[0, 1], true, false), Err(EditError::Invalid(_)))); + assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content"); +} + +/// Existing-row alignment is explicitly refused without mutating the paragraph. +#[test] +fn one_row_of_a_paragraph_takes_its_own_alignment_is_refused_without_changing_the_document() { + for align in [added::Align::Left, added::Align::Center, added::Align::Right, added::Align::Justify] { + let mut doc = text_page("BT /F1 10 Tf 12 TL 72 700 Td (First row) Tj T* (Second row) Tj ET"); + let before = page_content_bytes(&doc, 0); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let style = text::BlockStyle { aligns: vec![None, Some(align)], ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 0, Some(&blocks[0].editor_text), &style).unwrap_err(); + assert!(error.to_string().contains("selected-row alignment")); + assert_eq!(page_content_bytes(&doc, 0), before); + assert_eq!(page_content_bytes(&reopen(&doc), 0), before); + } +} + +/// Refusal preserves all existing rows and their saved content stream. +#[test] +fn rows_without_their_own_alignment_keep_their_operators_is_refused_without_changing_the_document() { + for align in [added::Align::Left, added::Align::Center, added::Align::Right, added::Align::Justify] { + let mut doc = text_page("BT /F1 10 Tf 12 TL 72 700 Td (First row) Tj T* (Second row) Tj ET"); + let before = page_content_bytes(&doc, 0); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let style = text::BlockStyle { aligns: vec![None, Some(align)], ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 0, Some(&blocks[0].editor_text), &style).unwrap_err(); + assert!(error.to_string().contains("selected-row alignment")); + assert_eq!(page_content_bytes(&doc, 0), before); + assert_eq!(page_content_bytes(&reopen(&doc), 0), before); + } +} + +/// Mixed-style rows also refuse unsupported per-row intent before writing. +#[test] +fn one_row_of_a_styled_region_takes_its_own_alignment_is_refused_without_changing_the_document() { + for align in [added::Align::Left, added::Align::Center, added::Align::Right, added::Align::Justify] { + let mut doc = mixed_region_page(); + let before = page_content_bytes(&doc, 0); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let style = text::BlockStyle { aligns: vec![None, Some(align)], ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 1, Some(&blocks[1].editor_text), &style).unwrap_err(); + assert!(error.to_string().contains("selected-row alignment")); + assert_eq!(page_content_bytes(&doc, 0), before); + assert_eq!(page_content_bytes(&reopen(&doc), 0), before); + } +} + +/// Added text: each paragraph is laid out with its own alignment, a justified paragraph's last +/// line isn't stretched, and the alignments survive saving and reopening. +#[test] +fn added_text_paragraphs_keep_their_own_alignments() { + let mut doc = fixture(); + let text = AddedText { + rect: [72.0, 600.0, 372.0, 700.0], + text: "Left line\nRight line\nCentred line".into(), + size: 12.0, + aligns: vec![None, Some(Align::Right), Some(Align::Center)], + ..AddedText::default() + }; + assert_eq!(text.paragraph_align(1), Align::Right); + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + let doc = reopen(&doc); + let Content::Text(t) = &list_added(&doc)[0].content else { panic!() }; + assert_eq!(t.aligns, text.aligns, "saved with the item"); + let page = streams(&doc, 0).join("\n"); + let x_of = |s: &str| -> f64 { + let at = page.find(&format!("({s}) Tj")).unwrap(); + let tm = page[..at].rfind(" Tm").unwrap(); + let nums: Vec = page[..tm].rsplit("1 0 0 1 ").next().unwrap().split_whitespace().filter_map(|v| v.parse().ok()).collect(); + nums[0] + }; + let w = |s: &str| Family::Helvetica.width(s, 12.0, false, false); + assert!((x_of("Left line") - 72.0).abs() < 0.01); + assert!((x_of("Right line") - (372.0 - w("Right line"))).abs() < 0.01); + assert!((x_of("Centred line") - (72.0 + (300.0 - w("Centred line")) / 2.0)).abs() < 0.01); + // A paragraph's own alignment the same as the box's is no entry at all. + let tidy = paragraph_align::tidy(vec![Some(Align::Left), Some(Align::Right)], Align::Left); + assert_eq!(tidy, [None, Some(Align::Right)]); +} + +/// With character spacing, a paragraph aligned on its own is placed by its spaced width: a +/// right-aligned line still ends at the box's right edge, and a centred one sits in the middle. +#[test] +fn added_text_paragraphs_align_by_their_spaced_width() { + let mut doc = fixture(); + let text = AddedText { + rect: [72.0, 600.0, 372.0, 700.0], + text: "Left line\nRight line\nCentred line".into(), + size: 12.0, + aligns: vec![None, Some(Align::Right), Some(Align::Center)], + char_spacing: 2.0, + ..AddedText::default() + }; + add_content(&mut doc, 0, &Content::Text(text)).unwrap(); + let page = streams(&reopen(&doc), 0).join("\n"); + assert!(page.contains("2 Tc"), "{page}"); + let x_of = |s: &str| -> f64 { + let at = page.find(&format!("({s}) Tj")).unwrap(); + let tm = page[..at].rfind(" Tm").unwrap(); + let nums: Vec = page[..tm].rsplit("1 0 0 1 ").next().unwrap().split_whitespace().filter_map(|v| v.parse().ok()).collect(); + nums[0] + }; + let w = |s: &str| Family::Helvetica.width(s, 12.0, false, false) + 2.0 * s.chars().count() as f64; + assert!((x_of("Left line") - 72.0).abs() < 0.01); + assert!((x_of("Right line") - (372.0 - w("Right line"))).abs() < 0.01); + assert!((x_of("Centred line") - (72.0 + (300.0 - w("Centred line")) / 2.0)).abs() < 0.01); +} + +/// A one-row title over a body column: an alignment picked for it is measured across the column. +/// Left puts it on the column's left edge, and centring it again leaves it where it is, rather +/// than aligning it in its own box (a no-op) or toward the page's margin (moving it right each +/// time). It is aligned as the editor aligns a selected row, in whichever paragraph holds it. +#[test] +fn a_lone_row_aligns_across_the_column_it_sits_in() { + let mut doc = text_page( + "BT /F1 24 Tf 1 0 0 1 250 700 Tm (Title) Tj ET BT /F1 10 Tf 12 TL 72 650 Td (The body of the page runs across the whole column here.) Tj T* (It has a second row that is a little shorter.) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks[0].text, "Title"); + let column = [blocks[1].rect[0], blocks[1].rect[2]]; + let title = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect; + let align = |doc: &mut Document, a: added::Align| { + let lines = text::text_lines(doc, 0).unwrap(); + let line = lines.iter().position(|l| l.text == "Title").unwrap(); + let blocks = text::text_blocks(doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.lines.contains(&line)).unwrap(); + // The title is the paragraph's first row. + let style = text::BlockStyle { align: Some(a), ..Default::default() }; + text::rewrite_block(doc, 0, index, Some(&block.editor_text), &style).unwrap(); + }; + align(&mut doc, added::Align::Center); + let centred = title(&doc); + assert!(((centred[0] + centred[2]) / 2.0 - (column[0] + column[1]) / 2.0).abs() < 1.0, "centred in the column: {centred:?} vs {column:?}"); + align(&mut doc, added::Align::Center); + assert!((title(&doc)[0] - centred[0]).abs() < 0.5, "centring again leaves it where it is: {:?} vs {centred:?}", title(&doc)); + align(&mut doc, added::Align::Left); + assert!((title(&doc)[0] - column[0]).abs() < 0.5, "left: back on the column's edge, {:?} vs {column:?}", title(&doc)); + align(&mut doc, added::Align::Right); + assert!((title(&doc)[2] - column[1]).abs() < 1.0, "right: on the column's right edge, {:?} vs {column:?}", title(&doc)); +} + +/// An edited paragraph's text has a line break between its source rows (`editor_text`), as the +/// paragraph editor sends it. Those are the rows of one paragraph, not paragraph ends: justified +/// in a font of its own (the single-style rewrite), every row but the last still fills the width. +#[test] +fn a_justified_paragraph_from_the_editor_fills_its_rows() { + let mut doc = + text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj T* (end) Tj ET"); + let block = text::text_blocks(&doc, 0).unwrap()[0].clone(); + assert_eq!(block.editor_text.matches('\n').count(), 2); + let style = text::BlockStyle { align: Some(added::Align::Justify), family: Some((Family::Helvetica, true, false)), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&block.editor_text), &style).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let row = |t: &str| lines.iter().find(|l| l.text.starts_with(t)).unwrap().rect; + let right = row("One")[2]; + assert!((row("eight")[2] - right).abs() < 1.0, "the second row is stretched: {:?} vs {right}", row("eight")); + assert!(row("end")[2] < right - 20.0, "the last row isn't"); +} + +/// An alignment for a row the draft doesn't have names no row: a one-row title given one for a +/// second row only stays where it is, not moved to its column's edge. Nor does a row of the draft +/// with no alignment of its own move because another row has one (CodeRabbit on 648dd428). +#[test] +fn a_title_moves_to_its_column_only_for_an_alignment_of_its_own() { + let page = "BT /F1 24 Tf 1 0 0 1 250 700 Tm (Title) Tj ET BT /F1 10 Tf 12 TL 72 650 Td (The body of the page runs across the whole column here.) Tj T* (It has a second row that is a little shorter.) Tj ET"; + let title = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect; + let before = title(&text_page(page)); + for draft in ["Title", "Title\nSub"] { + let mut doc = text_page(page); + let style = text::BlockStyle { aligns: vec![None, Some(added::Align::Right)], ..Default::default() }; + assert!(text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).is_err()); + assert!((title(&doc)[0] - before[0]).abs() < 0.5, "{draft:?}: the title stays put, {:?} vs {before:?}", title(&doc)); + } +} + +/// A column whose rows are several runs (a second style on the same baseline) reaches the right +/// edge of its whole rows, not of the run at the left edge: a title wider than that first run is +/// still held by the column and aligns to it (CodeRabbit on b9940d7d). +#[test] +fn a_column_is_measured_by_its_whole_rows_not_its_first_runs() { + let page = "BT /F1 18 Tf 1 0 0 1 100 672 Tm (A longer title) Tj ET BT /F1 10 Tf 12 TL 50 610 Td (The first column runs to here.) Tj ET BT /F1 11 Tf 1 0 0 rg 1 0 0 1 185 610 Tm (and keeps going on.) Tj ET BT /F1 10 Tf 12 TL 50 598 Td (A second row of it.) Tj ET"; + let blocks = text::text_blocks(&text_page(page), 0).unwrap(); + let body = blocks.iter().find(|b| b.text.starts_with("The first")).expect("the body is a block"); + assert!(body.rows.first().is_some_and(|row| row.len() >= 2), "the body's first row is several runs: {:?}", body.rows); + let lines = text::text_lines(&text_page(page), 0).unwrap(); + let column_right = + body.rows.iter().filter_map(|row| row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect[2]).reduce(f64::max)).fold(f64::MIN, f64::max); + let mut doc = text_page(page); + let index = blocks.iter().position(|b| b.text == "A longer title").expect("the title is a block of its own"); + let style = text::BlockStyle { align: Some(added::Align::Right), ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some("A longer title"), &style).unwrap(); + let title = text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "A longer title").unwrap().rect; + assert!((title[2] - column_right).abs() < 1.0, "right: on the whole row's right edge, {title:?} vs {column_right}"); +} + +/// A row with no column of its own is aligned in its own box, but that box is not the width its new +/// text wraps at: changing the text together with an alignment keeps a single row on one row, as +/// it does without the alignment (CodeRabbit on b9940d7d). +#[test] +fn an_isolated_row_edited_with_an_alignment_does_not_wrap_at_its_old_width() { + for align in [added::Align::Center, added::Align::Right] { + let mut doc = text_page("BT /F1 18 Tf 1 0 0 1 100 700 Tm (Title) Tj ET"); + let longer = "A much longer title for the page"; + let style = text::BlockStyle { align: Some(align), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(longer), &style).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [longer], "{align:?}: the title stays on one row"); + } +} + +/// A heading across the whole page sits closer to the title than the title's own column does: the +/// title is still aligned in its own column, not across the heading's page-wide frame (FredApps's +/// review of 43f848d5). Centre and right both go by the column. +#[test] +fn a_lone_row_aligns_in_its_own_column_not_a_closer_full_width_heading() { + let page = "BT /F1 12 Tf 1 0 0 1 50 700 Tm (A heading that runs across both columns of the page, well past the first column, to the far margin.) Tj ET BT /F1 18 Tf 1 0 0 1 120 672 Tm (Title) Tj ET BT /F1 10 Tf 12 TL 50 610 Td (The first column runs to here.) Tj T* (A second row of it.) Tj ET BT /F1 10 Tf 12 TL 320 610 Td (The second column has more rows.) Tj T* (One.) Tj T* (Two.) Tj T* (Three.) Tj ET"; + let blocks = text::text_blocks(&text_page(page), 0).unwrap(); + let column = blocks.iter().find(|b| b.text.starts_with("The first")).unwrap().rect; + let heading = blocks.iter().find(|b| b.text.starts_with("A heading")).unwrap().rect; + let title_block = blocks.iter().find(|b| b.text == "Title").expect("the title is a block of its own").rect; + let gap = |other: [f64; 4]| (other[1] - title_block[3]).max(title_block[1] - other[3]).max(0.0); + assert!(gap(heading) < gap(column), "the heading is the nearer block: {} vs {}", gap(heading), gap(column)); + assert!(heading[2] - heading[0] > 2.0 * (column[2] - column[0]), "the heading is page-wide: {heading:?} vs {column:?}"); + let title_after = |align: added::Align| { + let mut doc = text_page(page); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let index = blocks.iter().position(|b| b.text == "Title").unwrap(); + let style = text::BlockStyle { align: Some(align), ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some("Title"), &style).unwrap(); + text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect + }; + let centred = title_after(added::Align::Center); + assert!(((centred[0] + centred[2]) / 2.0 - (column[0] + column[2]) / 2.0).abs() < 1.0, "centred in its own column: {centred:?} vs {column:?}"); + let right = title_after(added::Align::Right); + assert!((right[2] - column[2]).abs() < 1.0, "right: on its own column's right edge, {right:?} vs {column:?}"); +} + +/// The same on a page of two columns, where the other column has more rows: a title over the +/// first column is aligned across the first column, not its own box. +#[test] +fn a_lone_row_aligns_across_its_own_column_of_two() { + let mut doc = text_page( + "BT /F1 18 Tf 1 0 0 1 120 700 Tm (Title) Tj ET \ + BT /F1 10 Tf 12 TL 50 660 Td (The first column runs to here.) Tj T* (A second row of it.) Tj ET \ + BT /F1 10 Tf 12 TL 320 660 Td (The second column has more rows.) Tj T* (One.) Tj T* (Two.) Tj T* (Three.) Tj T* (Four.) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let column = blocks.iter().find(|b| b.text.starts_with("The first")).unwrap().rect; + let index = blocks.iter().position(|b| b.text == "Title").unwrap(); + let style = text::BlockStyle { align: Some(added::Align::Left), ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some("Title"), &style).unwrap(); + let title = text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect; + assert!((title[0] - column[0]).abs() < 0.5, "left: on the first column's edge, {title:?} vs {column:?}"); +} + +/// A one-row title in two colours is rewritten by the styled-region path, which is given the +/// same column frame: centred twice, it stays centred in the column instead of moving right. +#[test] +fn a_lone_row_of_two_styles_aligns_across_its_column() { + let mut doc = text_page( + "BT /F1 18 Tf 1 0 0 1 250 700 Tm (Big ) Tj 1 0 0 rg (Title) Tj ET \ + BT 0 g /F1 10 Tf 12 TL 72 650 Td (The body of the page runs across the whole column here.) Tj T* (It has a second row that is a little shorter.) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks[0].text, "Big Title"); + let column = [blocks[1].rect[0], blocks[1].rect[2]]; + let title = |doc: &Document| { + let lines = text::text_lines(doc, 0).unwrap(); + let block = text::text_blocks(doc, 0).unwrap().into_iter().find(|b| b.text == "Big Title").unwrap(); + block.lines.iter().map(|i| lines[*i].rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]).unwrap() + }; + let centre = |doc: &mut Document| { + let blocks = text::text_blocks(doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.text == "Big Title").unwrap(); + let style = text::BlockStyle { align: Some(added::Align::Center), ..Default::default() }; + text::rewrite_block(doc, 0, index, Some(&block.editor_text), &style).unwrap(); + }; + centre(&mut doc); + let centred = title(&doc); + assert!(((centred[0] + centred[2]) / 2.0 - (column[0] + column[1]) / 2.0).abs() < 1.0, "centred in the column: {centred:?} vs {column:?}"); + centre(&mut doc); + assert!((title(&doc)[0] - centred[0]).abs() < 0.5, "centring again leaves it where it is: {:?} vs {centred:?}", title(&doc)); +} + +#[test] +fn a_titles_column_ignores_distant_full_width_headers_and_footers() { + for (title_x, column_prefix) in [(95, "The first"), (365, "The second")] { + for mixed in [false, true] { + for title_y in [710, 580] { + let title = if mixed { "(Big ) Tj 1 0 0 rg (Title) Tj" } else { "(Title) Tj" }; + let name = if mixed { "Big Title" } else { "Title" }; + let mut doc = text_page(&format!( + "BT /F1 10 Tf 12 TL 50 780 Td (A page-wide header extending well beyond either body column on this page and continuing all the way to the right margin.) Tj T* (A repeated page-wide heading across both columns and continuing to the right margin of the page.) Tj ET \ + BT /F1 18 Tf 1 0 0 1 {title_x} {title_y} Tm {title} ET \ + BT 0 g /F1 10 Tf 12 TL 50 660 Td (The first column runs to here.) Tj T* (A second row of it.) Tj ET \ + BT /F1 10 Tf 12 TL 320 660 Td (The second column runs to here.) Tj T* (Another row.) Tj ET \ + BT /F1 10 Tf 50 40 Td (A page-wide footer extending well beyond either body column on this page and continuing all the way to the right margin.) Tj ET" + )); + let rect = |doc: &Document, prefix: &str| { + let blocks = text::text_blocks(doc, 0).unwrap(); + blocks.iter().find(|b| b.text.starts_with(prefix)).unwrap_or_else(|| panic!("missing {prefix:?} in {blocks:?}")).rect + }; + let column = rect(&doc, column_prefix); + let header = rect(&doc, "A page-wide header"); + let footer = rect(&doc, "A page-wide footer"); + assert!(header[2] > column[2] + 20.0 && footer[2] > column[2] + 20.0); + for align in [added::Align::Center, added::Align::Center, added::Align::Right, added::Align::Left] { + let blocks = text::text_blocks(&doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.text == name).unwrap(); + let style = text::BlockStyle { align: Some(align), ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some(&block.editor_text), &style).unwrap(); + doc = reopen(&doc); + let title = rect(&doc, name); + let error = match align { + added::Align::Center => ((title[0] + title[2]) - (column[0] + column[2])).abs() / 2.0, + added::Align::Right => (title[2] - column[2]).abs(), + _ => (title[0] - column[0]).abs(), + }; + assert!(error < 1.0, "{align:?}, mixed={mixed}, title_y={title_y}: {title:?} must use {column:?}"); + assert_eq!(rect(&doc, "A page-wide header"), header); + assert_eq!(rect(&doc, "A page-wide footer"), footer); + assert_eq!(rect(&doc, column_prefix), column); + } + } + } + } +} + +/// How each glyph-showing operator on page 0 is painted, read the way a renderer reads the page: +/// its text, `Tr`, stroke colour operators, line width (from `w` or an ExtGState's `/LW`) and +/// the ExtGStates selected before it, following `q`/`Q`. +#[derive(Clone, Debug, PartialEq)] +struct Painted { + text: String, + render_mode: i64, + stroke: Vec, + line_width: f64, + gs: Vec, +} + +fn painted_shows(doc: &Document) -> Vec { + let (_, ops) = joined_ops(doc); + let p = &pdfcraft_model::pages(doc)[0]; + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let ext = res.as_dict().unwrap().get(b"ExtGState").map(|g| doc.resolve(g).as_dict().cloned().unwrap()).unwrap_or_default(); + let mut state = Painted { text: String::new(), render_mode: 0, stroke: Vec::new(), line_width: 1.0, gs: Vec::new() }; + let mut stack = Vec::new(); + let mut out = Vec::new(); + let show = |op: &pdfcraft_content::Op| String::from_utf8_lossy(&pdfcraft_content::serialize_ops(std::slice::from_ref(op))).trim().to_string(); + for op in ops { + match op.op.as_slice() { + b"q" => stack.push(state.clone()), + b"Q" => state = stack.pop().unwrap(), + b"Tr" => state.render_mode = op.operands[0].as_int().unwrap(), + b"G" | b"RG" | b"K" | b"CS" => state.stroke = vec![show(&op)], + b"SC" | b"SCN" => state.stroke.push(show(&op)), + b"w" => state.line_width = op.num(0).unwrap(), + b"gs" => { + let name = op.name(0).unwrap(); + state.gs.push(String::from_utf8_lossy(name).into_owned()); + let lw = ext.get(name).and_then(|g| doc.resolve(g).as_dict().and_then(|g| g.get(b"LW")).and_then(|w| w.as_f64())); + state.line_width = lw.unwrap_or(state.line_width); + } + b"Tj" => { + let Some(Object::String(s)) = op.operands.last() else { continue }; + if !s.bytes.is_empty() { + out.push(Painted { text: String::from_utf8_lossy(&s.bytes).into_owned(), ..state.clone() }); + } + } + _ => {} + } + } + out +} + +/// A page with Helvetica as /F1, /GS1, an ExtGState that paints at 20 % opacity, /GS2, one that +/// sets the line width to 4, /GS3, another at 20 % opacity, and /GS4, at 20 % opacity and dashed. +fn faint_page(content: &str) -> Document { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /ExtGState << /GS1 6 0 R /GS2 7 0 R /GS3 8 0 R /GS4 9 0 R >> >> >>", + &format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /ExtGState /ca 0.2 >>", + "<< /Type /ExtGState /LW 4 >>", + "<< /Type /ExtGState /ca 0.2 >>", + "<< /Type /ExtGState /ca 0.2 /D [[6 3] 0] >>", + ]) +} + +/// Rewriting a paragraph drew it before its text object, restating only the font, spacing and +/// fill: a `1 Tr 1 0 0 RG 2 w` set inside `BT` was lost, and red outline text came back filled +/// black, even with the text unchanged (#3508). Set before `BT` (the control), it already worked. +#[test] +fn a_rewritten_paragraph_keeps_the_rendering_mode_stroke_and_width_set_inside_its_text_object() { + let outline = |text: &str| Painted { text: text.into(), render_mode: 1, stroke: vec!["1 0 0 RG".into()], line_width: 2.0, gs: Vec::new() }; + for content in ["BT /F1 72 Tf 1 Tr 1 0 0 RG 2 w 40 400 Td (OUTLINE) Tj ET\n", "1 Tr 1 0 0 RG 2 w BT /F1 72 Tf 40 400 Td (OUTLINE) Tj ET\n"] { + assert_eq!(painted_shows(&text_page(content)), [outline("OUTLINE")], "the fixture: {content}"); + for new in ["OUTLINE", "OUTLINED"] { + let mut doc = text_page(content); + text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(painted_shows(&doc), [outline(new)], "{content} -> {new}: {}", streams(&doc, 0).join("\n")); + } + } +} + +/// The same for transparency: `/GS1 gs` (`/ca 0.2`) inside `BT` was not in force for the +/// rewritten paragraph, so 20 % blue text came back opaque (#3509). +#[test] +fn a_rewritten_paragraph_keeps_the_extgstate_selected_inside_its_text_object() { + for content in ["BT /GS1 gs /F1 72 Tf 0 0 1 rg 40 400 Td (FAINT) Tj ET\n", "/GS1 gs BT /F1 72 Tf 0 0 1 rg 40 400 Td (FAINT) Tj ET\n"] { + for new in ["FAINT", "FAINTER"] { + let mut doc = faint_page(content); + text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + assert_eq!(shows.len(), 1, "{content} -> {new}: {shows:?}"); + assert_eq!( + (shows[0].text.as_str(), shows[0].gs.as_slice()), + (new, ["GS1".to_string()].as_slice()), + "{content} -> {new}: {}", + streams(&doc, 0).join("\n") + ); + } + } +} + +/// A paragraph of differently coloured runs goes through the styled rewrite, which draws each +/// run in its own text object: each keeps the state its source set inside `BT`. +#[test] +fn a_styled_rewrite_keeps_the_paint_state_set_inside_the_text_object() { + let content = "BT /GS1 gs 1 Tr 1 0 0 RG 2 w /F1 72 Tf 40 400 Td (FAI) Tj 0 0 1 rg (NT) Tj ET\n"; + let mut doc = faint_page(content); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), 2, "two runs on one row"); + text::rewrite_block(&mut doc, 0, 0, Some("FAINTS"), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + let joined = streams(&doc, 0).join("\n"); + assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::(), "FAINTS", "{joined}"); + for s in &shows { + assert_eq!((s.render_mode, s.stroke.as_slice(), s.line_width), (1, ["1 0 0 RG".to_string()].as_slice(), 2.0), "{s:?}: {joined}"); + assert_eq!(s.gs, ["GS1"], "{s:?}: {joined}"); + } +} + +/// A `gs` has no inverse, so one run's ExtGState stayed in force for the runs the styled rewrite +/// drew after it. Two ExtGStates at the same opacity paint the same (see `Paint`), so their runs +/// share a paragraph, but either may set what that doesn't compare (a dash, a line cap): each run +/// is drawn with only the ones its source selected. +#[test] +fn a_styled_rewrite_does_not_carry_one_runs_extgstate_into_the_next() { + // "FAI" ends at 40 + 72 * (0.611 + 0.667 + 0.278) = 152.032; "NT" is blue, so another run. + let content = "q BT /GS3 gs /F1 72 Tf 40 400 Td (FAI) Tj ET Q BT /GS1 gs /F1 72 Tf 0 0 1 rg 152.032 400 Td (NT) Tj ET +"; + for new in ["FAINT", "FAINTS"] { + let mut doc = faint_page(content); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), 2, "two runs on one row"); + text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + let joined = streams(&doc, 0).join( + " +", + ); + assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::(), new, "{joined}"); + let gs: Vec<_> = shows.iter().map(|s| (s.text.as_str(), s.gs.as_slice())).collect(); + assert_eq!(gs.first(), Some(&("FAI", ["GS3".to_string()].as_slice())), "{gs:?}: {joined}"); + assert!(gs.iter().skip(1).all(|(_, gs)| *gs == ["GS1"]), "{gs:?}: {joined}"); + } +} + +/// An ExtGState's `/LW` sets the line width as `w` does: `2 w /GS2 gs` (`/LW 4`) outlines at 4, +/// and the rewrite replayed `/GS2 gs` and then `2 w`. `/GS2 gs 1 w` outlines at 1, and the +/// rewrite replayed only `/GS2 gs`, since 1 is the initial width. +#[test] +fn a_rewritten_paragraph_keeps_the_line_width_an_extgstate_set() { + for (content, width) in [ + ("BT /F1 72 Tf 1 Tr 2 w /GS2 gs 40 400 Td (OUTLINE) Tj ET\n", 4.0), + ("BT /F1 72 Tf 1 Tr /GS2 gs 2 w 40 400 Td (OUTLINE) Tj ET\n", 2.0), + ("BT /F1 72 Tf 1 Tr /GS2 gs 1 w 40 400 Td (OUTLINE) Tj ET\n", 1.0), + ] { + assert_eq!(painted_shows(&faint_page(content))[0].line_width, width, "the fixture: {content}"); + for new in ["OUTLINE", "OUTLINED"] { + let mut doc = faint_page(content); + text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + assert_eq!( + shows.iter().map(|s| (s.text.as_str(), s.render_mode, s.line_width)).collect::>(), + [(new, 1, width)], + "{content} -> {new}: {}", + streams(&doc, 0).join("\n") + ); + } + } +} + +/// A line is rewritten in the state of its first run, so outline text partway through a text +/// object came back filled: "FAI" filled and "NT" a red outline came back all filled. The change +/// of `Tr` now starts a run. (A `gs` partway already starts a paragraph; see `Paint`.) +#[test] +fn a_rendering_mode_partway_through_a_line_starts_a_run_of_its_own() { + let content = "BT /F1 72 Tf 40 400 Td (FAI) Tj 1 Tr 1 0 0 RG 2 w (NT) Tj ET +"; + for new in ["FAINT", "FAINTS"] { + let mut doc = faint_page(content); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), 2, "two runs on one row"); + text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + let joined = streams(&doc, 0).join( + " +", + ); + assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::(), new, "{joined}"); + let modes: Vec<_> = shows.iter().map(|s| (s.text.as_str(), s.render_mode, s.stroke.as_slice(), s.line_width)).collect(); + assert_eq!(modes.first().map(|m| (m.0, m.1)), Some(("FAI", 0)), "{modes:?}: {joined}"); + let outline = ["1 0 0 RG".to_string()]; + assert!(modes.iter().skip(1).all(|m| (m.1, m.2, m.3) == (1, outline.as_slice(), 2.0)), "{modes:?}: {joined}"); + } +} + +/// Two ExtGStates at the same opacity paint text the same (see `Paint`), but one of them dashes +/// its outline: a dashed outline after a solid one in the same text object joined its line and +/// came back solid, drawn with the first run's selections. A different dash now starts a run. +#[test] +fn a_dash_partway_through_an_outlined_line_starts_a_run_of_its_own() { + let content = "BT /GS3 gs /F1 72 Tf 1 Tr 1 0 0 RG 2 w 40 400 Td (FAI) Tj /GS4 gs (NT) Tj ET +"; + for new in ["FAINT", "FAINTS"] { + let mut doc = faint_page(content); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), 2, "two runs on one row"); + text::rewrite_block(&mut doc, 0, 0, Some(new), &BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let shows = painted_shows(&doc); + let joined = streams(&doc, 0).join( + " +", + ); + assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::(), new, "{joined}"); + let gs: Vec<_> = shows.iter().map(|s| (s.text.as_str(), s.gs.as_slice())).collect(); + assert_eq!(gs.first(), Some(&("FAI", ["GS3".to_string()].as_slice())), "{gs:?}: {joined}"); + assert!(gs.iter().skip(1).all(|(_, gs)| gs.last().is_some_and(|g| g == "GS4")), "{gs:?}: {joined}"); + } +} + +/// The dash and cap are compared as the operators set them too: `d` or `J` partway through an +/// outlined line starts a run, and a malformed one (too long, not numbers, out of range) is ignored. +#[test] +fn a_dash_or_cap_operator_partway_through_an_outlined_line_starts_a_run() { + let long = format!("[{}] 0 d", "1 ".repeat(40)); + for (change, runs) in [("[6 3] 0 d", 2), ("1 J", 2), (long.as_str(), 1), ("[(a) 3] 0 d", 1), ("[6 3] /x d", 1), ("7 J", 1), ("J", 1)] { + let content = format!("BT /F1 72 Tf 1 Tr 1 0 0 RG 2 w 40 400 Td (FAI) Tj {change} (NT) Tj ET\n"); + let mut doc = faint_page(&content); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].rows[0].len(), runs, "{change}"); + text::rewrite_block(&mut doc, 0, 0, Some("FAINTS"), &BlockStyle::default()).unwrap(); + let shows = painted_shows(&reopen(&doc)); + assert_eq!(shows.iter().map(|s| s.text.as_str()).collect::(), "FAINTS", "{change}"); + } +} + +/// #4199, #3797: every space typed into a paragraph is saved, not folded into one. +#[test] +fn an_edited_paragraph_keeps_every_space_typed() { + for typed in ["Hello world", "Hello world end", " indented and trailing "] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + assert_eq!(text::replace_block(&mut doc, 0, 0, typed).unwrap().substituted, None); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains(&format!("({typed}) Tj")), "{typed:?}: {content}"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, typed); + } +} + +/// Spaces after a row's last word are kept but take no part in its alignment: a justified row +/// still reaches the margin, and a centred or right-aligned one sits where it would without them. +#[test] +fn spaces_after_a_rows_last_word_do_not_move_its_alignment() { + let shown = |text: &str, align: added::Align| { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + let style = + text::BlockStyle { family: Some((added::Family::Helvetica, false, false)), align: Some(align), width: Some(300.0), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(text), &style).unwrap(); + String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned() + }; + for (plain, trailing, align) in [ + ("aaa bbb ccc\nddd eee", "aaa bbb ccc \nddd eee", added::Align::Justify), + ("Title", "Title ", added::Align::Center), + ("Title", "Title ", added::Align::Right), + ] { + let first = trailing.split('\n').next().unwrap(); + let expected = shown(plain, align); + let actual = shown(trailing, align).replace(&format!("({first})"), &format!("({})", first.trim_end())); + assert_eq!(actual, expected, "{trailing:?} {align:?}"); + } +} + +/// A justified row runs from the paragraph's left edge to the margin. Word spacing reaches only +/// the spaces between its words: the spaces before its first word and after its last are left +/// out of a stretched row (as before #4199), so the reopened rows stay inside the paragraph and +/// can be edited again in place. +#[test] +fn a_justified_row_keeps_its_indent_and_ends_at_the_margin() { + let style = text::BlockStyle { + family: Some((added::Family::Helvetica, false, false)), + align: Some(added::Align::Justify), + width: Some(300.0), + ..Default::default() + }; + for typed in [" Pack my box with five dozen jugs\nof liquor", " Pack my box with five dozen jugs \nof liquor"] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(typed), &style).unwrap(); + let mut doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + let first = lines.iter().find(|l| l.text.contains("Pack")).unwrap(); + assert_eq!(first.text, "Pack my box with five dozen jugs", "{typed:?}"); + assert!((first.rect[0] - 72.0).abs() < 0.5, "{typed:?}: {:?}", first.rect); + assert!(lines.iter().all(|l| l.rect[0] >= 72.0 - 0.5 && l.rect[2] <= 372.5), "{typed:?}: {lines:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "{typed:?}: {blocks:?}"); + text::replace_block(&mut doc, 0, 0, "Edited again").unwrap(); + let again = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(again.iter().all(|l| l.rect[0] >= 0.0 && l.rect[2] <= 600.0), "{typed:?}: {again:?}"); + } +} + +/// A justified paragraph whose first row was typed with an indent is edited again through the +/// styled path (a draft with a line break): its rows keep their scaling and stay one paragraph +/// inside its width. +#[test] +fn a_justified_paragraph_with_an_indent_edits_again_at_its_own_scale() { + let style = text::BlockStyle { + family: Some((added::Family::Helvetica, false, false)), + align: Some(added::Align::Justify), + width: Some(300.0), + ..Default::default() + }; + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(" aaa bbb ccc \nddd"), &style).unwrap(); + let mut doc = reopen(&doc); + let draft = text::text_blocks(&doc, 0).unwrap()[0].editor_text.replacen("ccc", "cc", 1); + assert!(draft.contains('\n'), "{draft:?}"); + text::replace_block(&mut doc, 0, 0, &draft).unwrap(); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let scales: Vec<&str> = content.lines().filter(|l| l.ends_with(" Tz")).collect(); + assert!(scales.iter().all(|l| *l == "100 Tz"), "{scales:?}: {content}"); + let doc = reopen(&doc); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "{blocks:?}"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.iter().all(|l| l.rect[0] >= 72.0 - 0.5 && l.rect[2] <= 372.5), "{lines:?}"); +} + +/// A paragraph drawn at `0 Tz` has no width to justify into: nothing is divided by it. The +/// writer writes a number that isn't finite as 0, so the test looks for the word spacing and +/// the move a division by zero would add, rather than for `NaN`. +#[test] +fn justifying_text_at_zero_horizontal_scaling_divides_by_nothing() { + let mut doc = text_page("BT /F1 12 Tf 0 Tz 72 700 Td (Hello world) Tj ET"); + let style = text::BlockStyle { family: Some((added::Family::Helvetica, false, false)), align: Some(added::Align::Justify), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(" aaa bbb\nccc"), &style).unwrap(); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let block = content.split("ET").next().unwrap(); + assert_eq!(block.matches(" Tw").count(), 1, "only the paragraph's own 0 Tw: {content}"); + let first_row = block.split(" Tm").nth(1).and_then(|rest| rest.split(" Tj").next()).unwrap(); + assert!(!first_row.contains(" Td"), "no move before the first row: {content}"); +} + +/// The preview's box is the box of the rows Apply writes, measured to their last glyphs. +#[test] +fn a_drafts_box_is_the_box_apply_draws() { + let doc = text_page("BT /F1 12 Tf 14 TL 72 700 Td [(First) -2000 (row here )] TJ T* (Second row ) Tj ET"); + let source = text::text_blocks(&doc, 0).unwrap().swap_remove(0); + let draft = source.editor_text.replacen("Second", "Third", 1); + let layout = text::text_edit_layout(&doc, 0, 0, &draft, &text::BlockStyle::default()).unwrap(); + let mut applied = doc.clone(); + text::replace_block(&mut applied, 0, 0, &draft).unwrap(); + let boxed = text::text_lines(&applied, 0) + .unwrap() + .into_iter() + .map(|l| { + let end = l.ink.iter().map(|r| r[2]).fold(f64::NEG_INFINITY, f64::max); + [l.rect[0], l.rect[1], if end.is_finite() { end.min(l.rect[2]) } else { l.rect[2] }, l.rect[3]] + }) + .reduce(|a, r| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])]); + let (d, b) = (layout.draft_rect.unwrap(), boxed.unwrap()); + assert!(d.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01), "{draft:?}: preview {d:?}, applied {b:?}"); +} + +/// The draft's box is measured to its last glyph, not over the spaces typed after it. +#[test] +fn a_drafts_box_leaves_out_spaces_after_the_last_word() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + let layout = |text: &str| text::text_edit_layout(&doc, 0, 0, text, &text::BlockStyle::default()).unwrap().draft_rect; + assert_eq!(layout("Hello there "), layout("Hello there")); +} + +/// A lone carriage return (an old Mac line ending) breaks the row, as `\n` and `\r\n` do. +#[test] +fn a_lone_carriage_return_breaks_an_edited_paragraph() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::replace_block(&mut doc, 0, 0, "one\rtwo").unwrap(); + let lines: Vec = text::text_lines(&reopen(&doc), 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["one", "two"]); +} + +/// On the styled rewrite path too, `\r\n` is one row break and a lone `\r` another, and no +/// carriage return reaches the page: with no other formatting, and with a part set in red. +#[test] +fn carriage_returns_break_rows_on_the_styled_path() { + let draft = "one\r\ntwo\rthree"; + let red = text::RangeStyle { + range: 0..3, + family: None, + resource: None, + size: None, + color: Some([1.0, 0.0, 0.0]), + underline: None, + char_spacing: None, + scale: None, + }; + for style in [text::BlockStyle::default(), text::BlockStyle { ranges: vec![red], ..Default::default() }] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello world) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + let doc = reopen(&doc); + let content = page_content_bytes(&doc, 0); + assert!(!content.contains(&b'\r') && !content.windows(2).any(|w| w == b"\\r"), "{}", String::from_utf8_lossy(&content)); + let lines: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["one", "two", "three"], "{style:?}"); + } +} + +/// A subset with no space glyph shows each space as a positioning gap one space wide, so a run +/// of spaces still takes a run's width (#4199). +#[test] +fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET"); + assert_eq!(text::replace_block(&mut doc, 0, 0, "ab ba ab").unwrap().substituted, None); + let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned(); + assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); +} + +/// Inside a text object `q`/`Q` save and restore the text and text line matrices (ISO 32000-2 +/// §9.4.1, as corrected by the PDF Association's errata, issue 368), as the renderer does: the +/// text after `Q` is found, and stays, where it is drawn. +#[test] +fn q_inside_a_text_object_restores_the_text_matrices() { + for (content, text, baseline) in [ + ("BT /F1 12 Tf 72 700 Td q 0 -100 Td Q (After) Tj ET", "After", [72.0, 700.0]), + ("BT /F1 12 Tf 72 700 Td q 1 0 0 1 300 300 Tm Q (After) Tj ET", "After", [72.0, 700.0]), + // `T*` moves from the restored line, not the one set inside q/Q. + ("BT /F1 12 Tf 14 TL 72 700 Td q 0 -100 Td Q T* (Next) Tj ET", "Next", [72.0, 686.0]), + ] { + let mut doc = text_page(content); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!((lines[0].text.as_str(), lines[0].baseline()), (text, baseline), "{content}"); + text::replace_line(&mut doc, 0, 0, "Edited").unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!((lines[0].text.as_str(), lines[0].baseline()), ("Edited", baseline), "{content} after editing"); + } +} + +/// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen. +/// Selected-row alignment for existing PDF content is withheld until it can persist safely. +#[test] +fn wrapped_rows_keep_whole_paragraph_alignment_and_hard_break_markers() { + let mut doc = text_page("BT /F1 10 Tf 12 TL 72 700 Td (Old) Tj ET"); + let style = text::BlockStyle { width: Some(60.0), align: Some(added::Align::Left), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some("One two three four five six\nTail"), &style).unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.len() > 2, "the first logical row must wrap"); + let tail = lines.iter().find(|line| line.text == "Tail").unwrap(); + assert!((tail.rect[0] - 72.0).abs() < 1.0, "the second logical row keeps the paragraph alignment: {:?}", tail.rect); + for line in lines.iter().filter(|line| line.text != "Tail") { + assert!((line.rect[0] - 72.0).abs() < 1.0, "soft-wrapped rows stay left-aligned: {:?}", line.rect); + } + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCSoft") && content.contains("/PCHard"), "both break kinds survive: {content}"); +} + +#[test] +fn hostile_soft_wrap_counts_fail_before_reflow_allocates() { + for marker in [ + "/PCSoft << /N 9223372036854775807 >> BDC EMC", + "/PCSoft << /N 16777217 >> BDC EMC", + "/PCSoft << /N 8388608 >> BDC EMC /PCSoft << /N 8388609 >> BDC EMC", + ] { + let doc = text_page(&format!("BT /F1 12 Tf 20 700 Td (First) Tj {marker} 0 -14 Td (Second) Tj ET")); + let error = text::text_lines(&doc, 0).unwrap_err(); + assert!(error.to_string().contains("too large"), "{marker}: {error}"); + } +} + +#[test] +fn ordinary_soft_wrap_counts_keep_their_spaces() { + for gap in [0, 1, 2, 8] { + let doc = text_page(&format!("BT /F1 12 Tf 20 700 Td (First) Tj /PCSoft << /N {gap} >> BDC EMC 0 -14 Td (Second) Tj ET")); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].reflow_text, format!("First{}Second", " ".repeat(gap))); + } +} + +#[test] +fn styled_existing_justification_is_refused_instead_of_silently_left_aligning() { + let mut doc = mixed_region_page(); + let before = page_content_bytes(&doc, 0); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let style = text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 1, Some(&blocks[1].editor_text), &style).unwrap_err(); + assert!(error.to_string().contains("justification")); + assert_eq!(page_content_bytes(&doc, 0), before); + assert_eq!(page_content_bytes(&reopen(&doc), 0), before); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 32d63d5c80..98824c7694 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -14,7 +14,7 @@ use std::collections::HashMap; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream}; use pdfcraft_fonts::pdf::Metrics; use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from}; @@ -22,7 +22,44 @@ use crate::EditError; #[path = "text_regions.rs"] mod regions; -use regions::{group_blocks, rewrite_styled_block}; +#[path = "text_session.rs"] +mod session; +use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block}; +pub use session::TextSession; + +mod blocks; +mod editor; +mod fallback; +mod interp; +mod lines; +mod rewrite; + +use blocks::paragraph_separator; +#[cfg(test)] +use blocks::wrap; +pub use blocks::{ + BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked, + replace_block, text_blocks, +}; +use editor::layout_draft; +pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts}; +#[cfg(test)] +pub(crate) use fallback::SOURCE_SPACE_CALLS; +use fallback::{ + MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes, + source_family, source_program, source_show, source_space, source_width, strip_subset_tag, type3_faces, type3_glyph, write_type3_glyphs, +}; +#[cfg(test)] +use fallback::{add_type3_font, is_type3_fallback}; +use interp::{Origin, Paint, union}; +use lines::page_dict; +pub(crate) use lines::{FormBudget, content_streams, content_streams_within, form_call, rewritten_contents, splice}; +pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines}; +pub use rewrite::{LineEdit, replace_line, rewrite_block}; +use rewrite::{coincident_ops, rewrite_block_from}; +#[cfg(test)] +pub(crate) use rewrite::{replace_line_with_limit, rewrite_block_with_limit}; +pub(crate) use rewrite::{reveal_ops, selectable_blocks, translate_blocks}; /// One line of existing text. #[derive(Clone, Debug, PartialEq)] @@ -62,15 +99,41 @@ pub struct TextLine { origin: Origin, /// Explicit marked-content scope (when present); distinct scopes must not be guessed into one paragraph. scope: Option, + /// The layers and graphics state parameters it is drawn in; see [`Paint`]. + paint: Paint, /// Right-to-left `/ActualText` already supplies typing order; glyph text still needs bidi /// recovery. Left-to-right ActualText (e.g. a Latin ligature) joins its line as usual. logical_order: bool, /// Some of the line's text is `/ActualText`, which is already in logical order: visual-order /// Indic recovery would scramble it. actual_text: bool, + /// Whether any of the line's runs draws in a clipping mode (`4 Tr` to `7 Tr`). The join below + /// does not require one rendering mode, so a line that starts as ink can continue as a mask; + /// [`refuse_clipping_text`] reads this rather than the origin's mode alone. + clips: bool, + /// Why the line can't be edited: its font's writing mode (its CMap's `WMode`) couldn't be read, + /// so which way its glyphs advance is unknown. The line is listed as horizontal text. + unreadable_mode: Option, + /// The runs the line joins, in content order: each one's text, the end of its words in + /// `ink`, and whether joining put a space before it. Empty when the line's text was put + /// into another order (see [`TextLine::runs`]). + runs: Vec<(String, usize, bool)>, + /// This line continues the previous one because a rewrite wrapped it (`/PCSoft`). + soft_break: bool, + /// This line starts after a break a rewrite kept on purpose (`/PCHard`). + hard_break: bool, + /// When `soft_break`: the exact number of separator characters the wrap consumed (0 for a + /// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none + /// (#2529). Meaningless otherwise. + soft_gap: usize, } impl TextLine { + /// Refuses editing a line whose font's writing mode couldn't be read (§9.7.4.3). + fn ensure_editable(&self) -> Result<(), EditError> { + self.unreadable_mode.as_ref().map_or(Ok(()), |why| Err(EditError::Invalid(why.clone()))) + } + /// Origin of the source baseline in PDF user space, independent of font ascent/descent. pub fn baseline(&self) -> [f64; 2] { [self.origin.x, self.origin.baseline] @@ -82,2584 +145,130 @@ impl TextLine { } } -/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and -/// are never installed, copied into assets, or used as a font for another document. -#[derive(Clone, Debug)] -pub struct TextEditorFont { - pub resource: String, - pub face: String, - pub bytes: Vec, -} - -pub fn text_editor_fonts(doc: &Document, page: usize) -> Result, EditError> { - let p = page_dict(doc, page)?; - let resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default(); - let fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default(); - let mut budget = 32_usize << 20; - let mut result = Vec::new(); - for (name, font) in fonts.iter().take(128) { - let Some(font) = doc.dict(font) else { continue }; - let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) else { continue }; - if bytes.len() > budget { - continue; - } - budget -= bytes.len(); - result.push(TextEditorFont { - resource: String::from_utf8_lossy(name).into_owned(), - face: Metrics::from_dict(doc, &font).base_font.rsplit('+').next().unwrap_or_default().to_owned(), - bytes, - }); - } - // The approved full face follows all source subsets in the editor's font stack. It can - // display newly inserted glyphs before Apply without changing the source text's face. - let source_faces: Vec<_> = result.iter().map(|p| p.face.clone()).collect(); - for face in source_faces { - for full in pdfcraft_fonts::embedding::matching_faces(&face) { - if full.bytes.len() > budget || result.iter().any(|p| p.resource == format!("PCApproved{}", full.name())) { - continue; - } - budget -= full.bytes.len(); - result.push(TextEditorFont { resource: format!("PCApproved{}", full.name()), face: face.clone(), bytes: full.bytes.to_vec() }); - } - } - Ok(result) -} - -/// Load an explicit full face into this page's resources. Nothing on the page is replaced; -/// the new resource can complete matching source subsets or be chosen in a text style. -pub fn register_text_font(doc: &mut Document, page: usize, bytes: &[u8]) -> Result { - pdfcraft_fonts::embedding::validate_document_input(bytes).map_err(EditError::Invalid)?; - let (_, postscript) = pdfcraft_fonts::embedding::names(bytes).map_err(EditError::Invalid)?; - let p = page_dict(doc, page)?; - let mut resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default(); - let mut fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default(); - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, bytes, &postscript).map_err(EditError::Invalid)?; - resources.set(b"Font".to_vec(), Object::Dict(fonts)); - doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources)))?; - Ok(name) -} - -/// Read-only draft bounds, measured with the same PDF rewrite used by Apply/Save. -#[derive(Clone, Debug, PartialEq)] -pub struct TextEditLayout { - pub source_rect: [f64; 4], - pub draft_rect: Option<[f64; 4]>, - pub overflow: bool, -} - -pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, style: &BlockStyle) -> Result { - // This runs on every keystroke: read the page once for the paragraphs, the untouched runs - // and the rewrite, which starts from the same unchanged page. - let lines = text_lines(doc, page)?; - let original = group_blocks(&lines); - let source = original.get(block).ok_or(EditError::Invalid("text block does not exist".into()))?; - if text == source.editor_text && *style == BlockStyle::default() { - return Ok(TextEditLayout { source_rect: source.rect, draft_rect: Some(source.rect), overflow: false }); - } - // Subtract the untouched runs by value, rather than by operator offsets: a rewrite may - // split BT/ET or add font resources. A multiset also handles coincident repeated text. - let key = - |line: &TextLine| (line.text.clone(), line.font.clone(), line.rect.map(f64::to_bits), line.size.to_bits(), line.color.map(f64::to_bits)); - let mut untouched = std::collections::BTreeMap::new(); - for (index, line) in lines.iter().enumerate() { - if !source.lines.contains(&index) { - *untouched.entry(key(line)).or_insert(0_usize) += 1; - } - } - let mut draft = doc.clone(); - rewrite_block_from(&mut draft, page, lines, block, Some(text), style, MAX_TYPE3_GLYPHS)?; - let mut rect: Option<[f64; 4]> = None; - for line in text_lines(&draft, page)? { - if let Some(count) = untouched.get_mut(&key(&line)) - && *count > 0 - { - *count -= 1; - continue; - } - rect = Some(rect.map_or(line.rect, |r| [r[0].min(line.rect[0]), r[1].min(line.rect[1]), r[2].max(line.rect[2]), r[3].max(line.rect[3])])); - } - let overflow = rect - .is_some_and(|r| r[0] < source.rect[0] - 0.5 || r[1] < source.rect[1] - 0.5 || r[2] > source.rect[2] + 0.5 || r[3] > source.rect[3] + 0.5); - Ok(TextEditLayout { source_rect: source.rect, draft_rect: rect, overflow }) -} - -/// Text and graphics state captured for rewriting. -#[derive(Clone, Debug, PartialEq)] -struct TextState { - font: Option<(String, f64)>, - char_spacing: f64, - word_spacing: f64, - scale: f64, - leading: f64, - rise: f64, - fill: Vec, - /// The text rendering mode (`Tr`); 3 is invisible text, as in an OCR layer over a scan. - render_mode: i64, -} - -impl TextState { - fn of(ts: &Ts) -> Self { - TextState { - font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)), - char_spacing: ts.char_spacing, - word_spacing: ts.word_spacing, - scale: ts.scale, - leading: ts.leading, - rise: ts.rise, - fill: ts.fill.clone(), - render_mode: ts.render_mode, - } - } - - /// Operators that set this state (inside a text object). - fn ops(&self) -> Vec { - let n = pdfcraft_content::num; - let mut v = Vec::new(); - if let Some((f, size)) = &self.font { - v.push(Op::new("Tf", vec![Object::name(f), n(*size)])); - } - v.push(Op::new("Tc", vec![n(self.char_spacing)])); - v.push(Op::new("Tw", vec![n(self.word_spacing)])); - v.push(Op::new("Tz", vec![n(self.scale * 100.0)])); - v.push(Op::new("TL", vec![n(self.leading)])); - v.push(Op::new("Ts", vec![n(self.rise)])); - v.extend(self.fill.iter().cloned()); - v - } -} - -#[derive(Clone, Debug, PartialEq)] -struct Origin { - tm: [f64; 6], - /// The text line matrix there (where the next line's Td/T* starts from). - tlm: [f64; 6], - /// Text-space units → user space (the scale of the text rendering matrix's y axis). - k: f64, - /// The CTM there (current space → page space), for moving the paragraph in page space. - ctm: [f64; 6], - baseline: f64, - x: f64, - state: TextState, - bt_op: usize, - bt_state: TextState, -} - -/// An editable text region: adjacent runs on visual rows, followed by aligned rows. -/// A region may contain several source fonts, sizes, colours and weights. -#[derive(Clone, Debug, PartialEq)] -pub struct TextBlock { - /// The lines' text joined with spaces (lines that ended in a hyphen are joined directly). - pub text: String, - pub rect: [f64; 4], - pub base_font: String, - pub size: f64, - pub bold: bool, - pub italic: bool, - /// The text fill colour as RGB, used by the visual editor and preserved when no colour - /// override is requested. - pub color: [f64; 3], - /// Counterclockwise baseline angle in page user space, in degrees. - pub rotation: f64, - /// Indexes into [`text_lines`]. - pub lines: Vec, - /// Visual rows; each contains indexes into [`text_lines`], including mixed-style runs. - pub rows: Vec>, - /// Text for the inline editor, with a newline only between visual rows. - pub editor_text: String, - /// Byte ranges in `editor_text` and their source line indexes. - pub spans: Vec<(std::ops::Range, usize)>, - /// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose - /// pixels show the words. A rewrite draws the new text visibly (see [`BlockStyle::cover`]). - pub invisible: bool, -} - -impl TextBlock { - /// Preserve styles outside the changed ranges; inserted characters inherit the surrounding - /// source run. The texts are aligned word by word, so words left untouched between two edits - /// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change. - /// Byte ranges are always UTF-8 boundaries. Linear time, including large edits. - pub fn edited_spans(&self, draft: &str) -> Vec<(std::ops::Range, usize)> { - if self.editor_text.len() > 4_000_000 || draft.len() > 4_000_000 { - return vec![(0..draft.len(), self.lines.first().copied().unwrap_or(0))]; - } - let old: Vec<_> = self.editor_text.chars().collect(); - let new: Vec<_> = draft.char_indices().collect(); - let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect(); - let mut original = Vec::with_capacity(old.len()); - let mut span = 0; - for (byte, _) in self.editor_text.char_indices() { - while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) { - span += 1; - } - original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i)); - } - let mut sources = Vec::with_capacity(new.len()); - let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::>(); - let (old_words, new_words) = (slices(&old), slices(&chars)); - match edit_script(&old_words, &new_words, MAX_CHANGED_WORDS) { - Some(script) => { - // `(o, n)`: where the current change starts in the old and new text; `(x, y)`: where it ends. - let ((mut o, mut n), (mut x, mut y)) = ((0, 0), (0, 0)); - let (mut a, mut b) = (old_words.iter(), new_words.iter()); - for step in script { - match step { - Step::Keep => { - replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources); - let len = a.next().map_or(0, Vec::len); - b.next(); - sources.extend(original.get(x..x.saturating_add(len)).unwrap_or_default()); - (x, y) = (x.saturating_add(len), y.saturating_add(len)); - (o, n) = (x, y); - } - Step::Delete => x = x.saturating_add(a.next().map_or(0, Vec::len)), - Step::Insert => y = y.saturating_add(b.next().map_or(0, Vec::len)), - } - } - replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources); - } - None => replaced(&original, 0, &old, &chars, &mut sources), - } - let fallback = original.first().copied().unwrap_or(0); - let mut result: Vec<(std::ops::Range, usize)> = Vec::new(); - for (i, (byte, ch)) in new.iter().enumerate() { - let source = sources.get(i).copied().unwrap_or(fallback); - let end = byte.saturating_add(ch.len_utf8()); - if let Some((range, _)) = result.last_mut().filter(|(_, prev)| *prev == source) { - range.end = end; - } else { - result.push((*byte..end, source)); - } - } - result - } -} - -/// Changed words [`TextBlock::edited_spans`] aligns before it treats the draft as one change. -/// Myers' diff costs O((N + M) · D), so bounding D keeps it linear. -const MAX_CHANGED_WORDS: usize = 64; - -/// `text` split into words, each with the whitespace after it, as char ranges. -fn words(text: &[char]) -> Vec> { - let mut out = Vec::new(); - let mut start = 0; - for (i, pair) in text.windows(2).enumerate() { - if let [a, b] = pair - && a.is_whitespace() - && !b.is_whitespace() - { - out.push(start..i + 1); - start = i + 1; - } - } - if start < text.len() { - out.push(start..text.len()); - } - out -} - -#[derive(Clone, Copy, Debug, PartialEq)] -enum Step { - Keep, - Delete, - Insert, -} - -/// Myers' shortest edit script from `a` to `b`, or `None` when it takes more than `max` steps. -fn edit_script(a: &[T], b: &[T], max: usize) -> Option> { - let (n, m) = (a.len() as isize, b.len() as isize); - let max = max.min(a.len().saturating_add(b.len())) as isize; - // Furthest x reached on each diagonal k = x - y, stored at k + max + 1. - let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0); - let mut v = vec![0isize; (2 * max + 3) as usize]; - let mut trace = Vec::new(); - for d in 0..=max { - trace.push(v.clone()); - for k in (-d..=d).step_by(2) { - let down = k == -d || (k != d && at(&v, k - 1) < at(&v, k + 1)); - let mut x = if down { at(&v, k + 1) } else { at(&v, k - 1) + 1 }; - let mut y = x - k; - while x < n && y < m && a.get(x as usize) == b.get(y as usize) { - x += 1; - y += 1; - } - if let Some(slot) = usize::try_from(k + max + 1).ok().and_then(|i| v.get_mut(i)) { - *slot = x; - } - if x >= n && y >= m { - return Some(backtrack(&trace, n, m, max)); - } - } - } - None -} - -/// Walk Myers' saved frontiers back from the end to the edit script. -fn backtrack(trace: &[Vec], n: isize, m: isize, max: isize) -> Vec { - let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0); - let mut steps = Vec::new(); - let (mut x, mut y) = (n, m); - for (d, v) in trace.iter().enumerate().rev() { - let (d, k) = (d as isize, x - y); - let prev_k = if k == -d || (k != d && at(v, k - 1) < at(v, k + 1)) { k + 1 } else { k - 1 }; - let prev_x = at(v, prev_k); - let prev_y = prev_x - prev_k; - while x > prev_x && y > prev_y { - steps.push(Step::Keep); - x -= 1; - y -= 1; - } - if d > 0 { - steps.push(if x == prev_x { Step::Insert } else { Step::Delete }); - } - (x, y) = (prev_x, prev_y); - } - steps.reverse(); - steps -} - -/// `old`, at char `at` of the source, became `new`: characters kept at either end keep their -/// runs, and the rest inherit the run just before the change (or the first one it replaces). -fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut Vec) { - let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count(); - let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count(); - let start = at.saturating_add(prefix); - let inherited = start.checked_sub(1).and_then(|i| original.get(i)).or_else(|| original.get(start)).copied().unwrap_or(0); - for i in 0..new.len() { - let source = if i < prefix { - original.get(at.saturating_add(i)).copied() - } else if i >= new.len().saturating_sub(suffix) { - original.get(at.saturating_add(old.len()).saturating_sub(new.len() - i)).copied() - } else { - Some(inherited) - }; - out.push(source.unwrap_or(inherited)); - } -} - -impl TextLine { - /// The baseline's height in user space. - pub fn origin_baseline(&self) -> f64 { - self.origin.baseline - } - - /// Paint position of the line's last text-showing operator: `(content-stream index, - /// operator index in the page's joined stream)`. Lines paint in order of this tuple. - /// (Lines from `text_lines` never come from form XObjects, so `stream` is always a - /// page content-stream index and `ops` are indices into the joined operator list.) - pub fn paint_pos(&self) -> (usize, usize) { - (self.stream, self.ops.iter().max().copied().unwrap_or(0)) - } - - fn rotation(&self) -> f64 { - let m = Matrix(self.origin.tm).then(&Matrix(self.origin.ctm)).0; - m[1].atan2(m[0]).to_degrees() - } - - /// The unit direction its baseline runs in, in user space. - fn direction(&self) -> (f64, f64) { - direction(&Matrix(self.origin.tm).then(&Matrix(self.origin.ctm))) - } - - /// Whether it reads left to right along the page's x axis (most text; a page drawn - /// turned a quarter, as landscape pages often are, isn't). - pub(crate) fn upright(&self) -> bool { - let (x, y) = self.direction(); - (x - 1.0).abs() < 1e-3 && y.abs() < 1e-3 - } - - /// Where it starts as `(along, across)` its baseline: user space turned to the text's direction. - fn frame_origin(&self) -> (f64, f64) { - to_frame(self.direction(), (self.origin.x, self.origin.baseline)) - } - - /// Its box in the same frame, `[along0, across0, along1, across1]` (for upright text, `rect`). - fn frame_rect(&self) -> [f64; 4] { - self.frame - } -} - -/// The unit x axis of a text rendering matrix in user space (`(1, 0)` when it is degenerate). -fn direction(m: &Matrix) -> (f64, f64) { - let (x, y) = (m.0[0], m.0[1]); - let length = x.hypot(y); - if length.is_finite() && length > 1e-9 { (x / length, y / length) } else { (1.0, 0.0) } -} - -/// User-space point `p` as `(along, across)` a baseline running in unit direction `dir`. Upright -/// text (`dir` = `(1, 0)`) keeps its coordinates, so lines and rows of turned text are found the -/// same way (#1443). -fn to_frame(dir: (f64, f64), p: (f64, f64)) -> (f64, f64) { - (p.0 * dir.0 + p.1 * dir.1, p.1 * dir.0 - p.0 * dir.1) -} - -/// The box covering both `[x0, y0, x1, y1]` boxes. -fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { - [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] -} - -/// What replacing a line did. -#[derive(Clone, Debug, PartialEq)] -pub struct LineEdit { - /// `Some(font)` when the line's font couldn't show the new text and Helvetica was used. - pub substituted: Option, -} - -#[derive(Clone)] -struct Ts { - ctm: Matrix, - /// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`). - fill: Vec, - font: Option<(Vec, Rc)>, - /// The Japanese or Hebrew fallback face the font subsets, when the font is one (see [`is_type3_fallback`]). - face: Option, - size: f64, - char_spacing: f64, - word_spacing: f64, - scale: f64, - leading: f64, - rise: f64, - render_mode: i64, -} - -fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; - list.into_iter() - .map(|o| { - let data = match &*doc.resolve(&o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }; - (o, data) - }) - .collect() -} - -/// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators -/// to insert before operator `i` of `ops` (parsed from the joined pieces) and whether to keep it. -/// Everything else is copied byte for byte. A page's streams are one stream in pieces, split -/// between any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and -/// its keyword start the next; removing or replacing it rewrites every piece it spans. Returns -/// each piece's new bytes. -pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> (Vec, bool)) -> Vec> { - let data = pieces.data(); - let mut edits = Vec::new(); - for (i, op) in ops.iter().enumerate() { - let (insert, keep) = edit(i); - if insert.is_empty() && keep { - continue; - } - let mut bytes = Vec::new(); - if !insert.is_empty() { - if op.span.start.checked_sub(1).and_then(|p| data.get(p)).is_some_and(|b| !b.is_ascii_whitespace()) { - bytes.push(b'\n'); - } - bytes.extend_from_slice(&serialize_ops(&insert)); - } - let end = if keep { op.span.start } else { op.span.end }; - edits.push((op.span.start..end, bytes)); - } - pieces.splice(edits) -} - -/// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams -/// (keeping their dictionaries), the others stay as they were. -pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec)], new: Vec>) -> Object { - let mut contents: Vec = Vec::with_capacity(streams.len()); - for ((obj, old), data) in streams.iter().zip(new) { - if *old == data { - contents.push(obj.clone()); - continue; - } - let mut dict = match &*doc.resolve(obj) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - contents.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &data))))); - } - match <[Object; 1]>::try_from(contents) { - Ok([one]) => one, - Err(many) => Object::Array(many), - } -} - -fn page_dict(doc: &Document, page: usize) -> Result { - pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page)) -} - -fn fill_color(fill: &[Op]) -> [f64; 3] { - let Some(op) = fill.iter().rev().find(|op| matches!(op.op.as_slice(), b"g" | b"rg" | b"k" | b"sc" | b"scn")) else { - return [0.0, 0.0, 0.0]; - }; - let space = match op.op.as_slice() { - b"sc" | b"scn" => match op.operands.len() { - 1 => b"g".as_slice(), - 3 => b"rg".as_slice(), - 4 => b"k".as_slice(), - _ => return [0.0; 3], - }, - other => other, - }; - match space { - b"g" => { - let v = op.operands.first().and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0); - [v, v, v] - } - b"rg" => [ - op.operands.first().and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0), - op.operands.get(1).and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0), - op.operands.get(2).and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0), - ], - b"k" => { - let c = op.operands.first().and_then(Object::as_f64).unwrap_or(0.0); - let m = op.operands.get(1).and_then(Object::as_f64).unwrap_or(0.0); - let y = op.operands.get(2).and_then(Object::as_f64).unwrap_or(0.0); - let k = op.operands.get(3).and_then(Object::as_f64).unwrap_or(0.0); - [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] - } - _ => [0.0, 0.0, 0.0], - } -} - -/// The text-showing operators of one stream with their text, font, box and baseline. -struct Shown { - op: usize, - extra_ops: Vec, - /// Encoded glyphs, not bytes or Unicode characters (a ligature can represent several). - glyph_count: usize, - /// Advance in text space, including spacing and TJ adjustments. - advance: f64, - /// The text matrix where it starts (text space), and the state there and at its `BT`. - tm: Matrix, - tlm: Matrix, - state: Ts, - bt_op: usize, - bt_state: Ts, - text: String, - rect: [f64; 4], - /// Its box along and across its baseline (see [`TextLine::frame`]). - frame: [f64; 4], - /// The boxes of its words (see [`TextLine::ink`]). - ink: Vec<[f64; 4]>, - baseline: f64, - start_x: f64, - end_x: f64, - end_y: f64, - bt: usize, - font: Vec, - base_font: String, - face: Option, - size: f64, - bold: bool, - italic: bool, - visual_order: bool, - decodable: bool, - scope: Option, - logical_order: bool, - actual_text: bool, -} - -impl Shown { - /// The unit direction of its baseline, where it starts and ends along it, and the baseline's - /// offset across it (for upright text: the user-space x, x and y). - fn span(&self) -> ((f64, f64), f64, f64, f64) { - let dir = direction(&self.tm.then(&self.state.ctm)); - let (start, across) = to_frame(dir, (self.start_x, self.baseline)); - (dir, start, to_frame(dir, (self.end_x, self.end_y)).0, across) - } -} - -/// Where the previous run of a line ended: its text object, baseline direction, offset across -/// the baseline, end along it and size. -#[derive(Clone, Copy)] -struct Pen { - bt: usize, - dir: (f64, f64), - across: f64, - end: f64, - size: f64, -} - -impl Pen { - fn after(s: &Shown) -> Self { - let (dir, _, end, across) = s.span(); - Pen { bt: s.bt, dir, across, end, size: s.size.max(1.0) } - } - - /// Whether `s` could continue the same line on geometry alone: on the same baseline, starting - /// at most a word gap (`reach` sizes) after it. Also the gap along the baseline. - fn continues(&self, s: &Shown, reach: f64) -> (bool, f64) { - let (dir, start, _, across) = s.span(); - let gap = start - self.end; - let same_dir = (dir.0 - self.dir.0).abs() < 1e-3 && (dir.1 - self.dir.1).abs() < 1e-3; - (same_dir && (across - self.across).abs() < self.size * 0.3 && gap > -self.size && gap < self.size * reach, gap) - } -} - -/// The graphics state carried from one of a page's content streams to the next: the streams -/// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first -/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it. -struct Carry { - ts: Ts, - stack: Vec, - marked: Vec>, -} - -impl Carry { - fn new() -> Self { - let ts = Ts { - ctm: Matrix::IDENTITY, - fill: Vec::new(), - font: None, - face: None, - size: 0.0, - char_spacing: 0.0, - word_spacing: 0.0, - scale: 1.0, - leading: 0.0, - rise: 0.0, - render_mode: 0, - }; - Carry { ts, stack: Vec::new(), marked: Vec::new() } - } -} - -/// A `Do` met while interpreting: its operator index, XObject name and the state there. -type Invocation = (usize, Vec, Ts); - -fn interpret( - doc: &Document, - ops: &[Op], - fonts_res: &Dict, - cache: &mut HashMap, (Rc, Option)>, - carry: &mut Carry, - mut invoked: Option<&mut Vec>, - // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`. - logical_text: Option<&Dict>, -) -> Vec { - let mut out: Vec = Vec::new(); - let mut ts = carry.ts.clone(); - let mut at_bt = (0usize, ts.clone()); - let mut stack: Vec = std::mem::take(&mut carry.stack); - let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); - let mut bt = 0usize; - let mut marked = std::mem::take(&mut carry.marked); - let mut actual: Vec> = Vec::new(); - for (i, op) in ops.iter().enumerate() { - match op.op.as_slice() { - b"BMC" | b"BDC" if marked.len() < 256 => { - // Marked text paragraphs/headings are authoritative boundaries. Ignore - // artifacts and inner spans, which inherit their enclosing region. - let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); - marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); - // The property list is inline or named in the resources' /Properties (§14.6.2). - let list = logical_text.and_then(|properties| match op.operands.get(1) { - Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)), - other => other.and_then(Object::as_dict).cloned(), - }); - let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s { - Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), - _ => None, - }); - actual.push(logical.map(|s| (out.len(), i, s))); - } - b"EMC" => { - marked.pop(); - if let Some(Some((start, open, logical))) = actual.pop() { - let tail = out.split_off(start.min(out.len())); - let mut items = tail.into_iter(); - if let Some(mut first) = items.next() { - for shown in items { - first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count); - first.extra_ops.push(shown.op); - first.extra_ops.extend(shown.extra_ops); - first.rect = [ - first.rect[0].min(shown.rect[0]), - first.rect[1].min(shown.rect[1]), - first.rect[2].max(shown.rect[2]), - first.rect[3].max(shown.rect[3]), - ]; - first.frame = union(first.frame, shown.frame); - first.ink.extend(shown.ink); - } - first.extra_ops.extend([open, i]); - // Only right-to-left ActualText is already in typing order and must stay - // out of the line's bidi recovery. Word and Chrome wrap Latin ligatures - // (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line. - first.logical_order = logical.chars().any(crate::bidi::is_rtl); - first.actual_text = true; - first.text = logical; - first.start_x = first.rect[0]; - first.end_x = first.rect[2]; - first.end_y = first.baseline; - first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); - out.push(first); - } - } - } - b"q" => stack.push(ts.clone()), - b"Q" => { - if let Some(s) = stack.pop() { - ts = s; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - ts.ctm = Matrix(m).then(&ts.ctm); - } - } - b"BT" => { - tm = Matrix::IDENTITY; - tlm = Matrix::IDENTITY; - bt += 1; - at_bt = (i, ts.clone()); - } - b"g" | b"rg" | b"k" | b"cs" => ts.fill = vec![op.clone()], - b"sc" | b"scn" => { - ts.fill.retain(|o| o.is("cs")); - ts.fill.push(op.clone()); - } - b"Tf" => { - ts.size = op.num(1).unwrap_or(ts.size); - if let Some(name) = op.name(0) { - let (m, face) = cache.entry(name.to_vec()).or_insert_with(|| { - let dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); - let metrics = dict.as_ref().map_or_else(Metrics::fallback, |d| Metrics::from_dict(doc, d)); - let face = dict.filter(is_type3_fallback).map(|_| metrics.base_font.clone()); - (Rc::new(metrics), face) - }); - ts.font = Some((name.to_vec(), m.clone())); - ts.face = face.clone(); - } - } - b"Tc" => ts.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => ts.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => ts.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => ts.leading = op.num(0).unwrap_or(0.0), - b"Ts" => ts.rise = op.num(0).unwrap_or(0.0), - b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1), - b"Do" => { - if let (Some(list), Some(name)) = (invoked.as_deref_mut(), op.name(0)) { - list.push((i, name.to_vec(), ts.clone())); - } - } - b"Td" | b"TD" => { - if let Some([x, y]) = op.nums::<2>() { - if op.is("TD") { - ts.leading = -y; - } - tlm = Matrix([1.0, 0.0, 0.0, 1.0, x, y]).then(&tlm); - tm = tlm; - } - } - b"Tm" => { - if let Some(m) = op.nums::<6>() { - tlm = Matrix(m); - tm = tlm; - } - } - b"T*" => { - tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm); - tm = tlm; - } - b"Tj" | b"TJ" | b"'" | b"\"" => { - if matches!(op.op.as_slice(), b"'" | b"\"") { - if op.is("\"") { - ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing); - ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing); - } - tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm); - tm = tlm; - } - let Some((name, m)) = ts.font.clone() else { continue }; - // Pieces: strings, and TJ number adjustments. - let pieces: Vec = match op.op.as_slice() { - b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - let trm0 = tm.then(&ts.ctm); - let start = trm0.apply(0.0, ts.rise); - let mut text = String::new(); - let mut decodable = true; - let mut x_text = 0.0; - // Where each word's glyphs start and end along the run (text space), and whether - // the next glyph continues the last word. - let mut words: Vec<(f64, f64)> = Vec::new(); - let mut in_word = false; - let mut glyph_count = 0_usize; - for p in &pieces { - match p { - Object::String(s) => { - for (code, len) in m.codes(&s.bytes) { - glyph_count = glyph_count.saturating_add(1); - let shown = m.text_of(code); - let blank = - m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); - match shown { - Some(t) => text.push_str(&t), - None => decodable = false, - } - if !blank { - let ink_end = x_text + m.width(code) * ts.size * ts.scale; - // A negative font size or `Tz` runs the glyphs backwards: - // the word is the union of its glyphs, whichever way they go. - let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); - match words.last_mut() { - Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), - _ => words.push((lo, hi)), - } - } - in_word = !blank; - let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; - x_text += w * ts.scale; - } - } - other => { - if let Some(n) = other.as_f64() { - let dx = -n / 1000.0 * ts.size * ts.scale; - // A large gap inside TJ reads as a space. - if dx > ts.size * 0.2 && !text.ends_with(' ') { - text.push(' '); - } - // Kerning keeps a word together; a jump either way of more - // than 0.2 em ends it (whatever the sign of the size or `Tz`). - if dx.abs() > (ts.size * ts.scale).abs() * 0.2 { - in_word = false; - } - x_text += dx; - } - } - } - } - tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, 0.0]).then(&tm); - let end = tm.then(&ts.ctm).apply(0.0, ts.rise); - // The box: from descent to ascent along the run. - let corners = [ - trm0.apply(0.0, ts.rise + m.descent * ts.size), - trm0.apply(x_text, ts.rise + m.descent * ts.size), - trm0.apply(0.0, ts.rise + m.ascent * ts.size), - trm0.apply(x_text, ts.rise + m.ascent * ts.size), - ]; - let rect = corners - .iter() - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); - // The same corners along and across the baseline: the run's own box, not its - // upright bounding box turned (which is wider by the box's height on a slant). - let dir = direction(&trm0); - let frame = corners - .iter() - .map(|&p| to_frame(dir, p)) - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); - let ink = words - .iter() - .map(|&(a, b)| { - let (y0, y1) = (ts.rise + m.descent * ts.size, ts.rise + m.ascent * ts.size); - [trm0.apply(a, y0), trm0.apply(b, y0), trm0.apply(a, y1), trm0.apply(b, y1)] - .iter() - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) - }) - .collect(); - let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; - // A numeric-only TJ adjusts the current text matrix but paints no glyphs. - // It must not create a phantom editable line or enlarge its neighbours' boxes. - if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { - continue; - } - out.push(Shown { - op: i, - extra_ops: Vec::new(), - glyph_count, - advance: x_text, - tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]), - state: ts.clone(), - tlm, - bt_op: at_bt.0, - bt_state: at_bt.1.clone(), - text, - rect, - frame, - ink, - baseline: start.1, - start_x: start.0, - end_x: end.0, - end_y: end.1, - bt, - font: name, - base_font: m.base_font.clone(), - face: ts.face.clone(), - size: if size_user > 0.0 { size_user } else { ts.size }, - bold: m.bold, - italic: m.italic, - visual_order: m.visual_order, - decodable, - scope: marked.last().copied().flatten(), - logical_order: false, - actual_text: false, - }); - } - _ => {} - } - } - carry.ts = ts; - carry.stack = stack; - carry.marked = marked; - out -} - -/// The lines of text on a page (0-based), in content order. -pub fn text_lines(doc: &Document, page: usize) -> Result, EditError> { - let p = page_dict(doc, page)?; - let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let mut cache = HashMap::new(); - let mut lines: Vec = Vec::new(); - let mut carry = Carry::new(); - // The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them - // joined, so an operator whose operands end one piece still has them. - let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); - let ops = pieces.parse().ops; - let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); - let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties)); - let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended - for mut s in shown { - // A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a - // separate logical line. Put its letters into visual order before joining the - // surrounding glyphs; the completed line is recovered to typing order below. - // Multi-glyph and mixed-direction replacements keep their authoritative order. - if s.logical_order && s.glyph_count == 1 && s.text.chars().all(crate::bidi::is_rtl) { - s.text = crate::bidi::to_visual(&s.text); - s.logical_order = false; - } - // A line belongs to one stream piece. Within it, PDF producers often put every word - // in its own BT/ET: geometry and text state, not object boundaries, define a line. - let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0); - let joins = last.is_some_and(|(piece, pen)| { - let bt = pen.bt; - let (near, gap) = pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }); - piece == si - && lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order) - && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) - && (s.text.trim().is_empty() - || lines.last().is_some_and(|l| { - (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color - })) - && near - // An OCR layer is one text object over the whole scan: its words continue a line - // only across a word space, not across a table cell or a tab stop. - && (s.state.render_mode != 3 || gap < pen.size * 0.6) - }); - if joins && let Some(l) = lines.last_mut() { - let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1); - if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { - l.text.push(' '); - } - l.text.push_str(&s.text); - l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; - l.frame = union(l.frame, s.frame); - l.ink.extend(s.ink.iter().copied()); - l.ops.push(s.op); - l.ops.extend(s.extra_ops.iter().copied()); - l.decodable &= s.decodable; - l.visual_order |= s.visual_order; - l.actual_text |= s.actual_text; - // Spaces may use a separate subset font; they do not change the visible style. - } else { - lines.push(TextLine { - text: s.text.clone(), - rect: s.rect, - frame: s.frame, - ink: s.ink.clone(), - font: String::from_utf8_lossy(&s.font).into_owned(), - base_font: s.base_font.clone(), - size: s.size, - bold: s.bold, - italic: s.italic, - color: fill_color(&s.state.fill), - decodable: s.decodable, - visual_order: s.visual_order, - face: s.face.clone(), - stream: si, - scope: s.scope, - logical_order: s.logical_order, - actual_text: s.actual_text, - ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), - origin: Origin { - tm: s.tm.0, - tlm: s.tlm.0, - k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(), - ctm: s.state.ctm.0, - baseline: s.baseline, - x: s.start_x, - state: TextState::of(&s.state), - bt_op: s.bt_op, - bt_state: TextState::of(&s.bt_state), - }, - }); - } - last = Some((si, Pen::after(&s))); - } - // Content streams draw right-to-left lines in visual order; the editor works in typing order. - for l in lines.iter_mut().filter(|l| !l.logical_order) { - // A visual-order Indic font draws pre-base vowel signs before their consonant. - if l.visual_order && !l.actual_text { - l.text = crate::indic::to_logical(&l.text); - } - l.text = crate::bidi::to_logical(&l.text); - } - // Lines of only spaces aren't editable text. - lines.retain(|l| !l.text.trim().is_empty()); - Ok(lines) -} - -/// Joining independent text objects is safe only when they use the same text and graphics -/// transforms and spacing. In particular, a nearby rotated label isn't part of this line. -fn compatible_word(line: &TextLine, shown: &Shown) -> bool { - let same = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6); - let (a, b) = (&line.origin.state, TextState::of(&shown.state)); - line.scope == shown.scope - && same(&line.origin.tm[..4], &shown.tm.0[..4]) - && same(&line.origin.ctm, &shown.state.ctm.0) - && same(&[a.char_spacing, a.word_spacing, a.scale, a.rise], &[b.char_spacing, b.word_spacing, b.scale, b.rise]) - && a.fill.len() == b.fill.len() - && a.fill.iter().zip(&b.fill).all(|(a, b)| a.op == b.op && a.operands == b.operands) -} - -/// The line `s` continues, or a new one: `last` is the previous run of the same stream -/// (`bt`, baseline, end x, size). -fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream: usize) { - let joins = last.is_some_and(|pen| { - let bt = pen.bt; - lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order) - && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) - && (s.text.trim().is_empty() - || lines.last().is_some_and(|l| { - (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color - })) - && pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }).0 - }); - if joins && let Some(l) = lines.last_mut() { - let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1); - if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { - l.text.push(' '); - } - l.text.push_str(&s.text); - l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; - l.frame = union(l.frame, s.frame); - l.ink.extend(s.ink.iter().copied()); - l.ops.push(s.op); - l.ops.extend(s.extra_ops.iter().copied()); - l.decodable &= s.decodable; - l.visual_order |= s.visual_order; - l.actual_text |= s.actual_text; - } else { - lines.push(TextLine { - text: s.text.clone(), - rect: s.rect, - frame: s.frame, - ink: s.ink.clone(), - font: String::from_utf8_lossy(&s.font).into_owned(), - base_font: s.base_font.clone(), - size: s.size, - bold: s.bold, - italic: s.italic, - color: fill_color(&s.state.fill), - decodable: s.decodable, - visual_order: s.visual_order, - face: s.face.clone(), - stream, - scope: s.scope, - logical_order: s.logical_order, - actual_text: s.actual_text, - ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), - origin: Origin { - tm: s.tm.0, - tlm: s.tlm.0, - k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(), - ctm: s.state.ctm.0, - baseline: s.baseline, - x: s.start_x, - state: TextState::of(&s.state), - bt_op: s.bt_op, - bt_state: TextState::of(&s.bt_state), - }, - }); - } - *last = Some(Pen::after(&s)); -} - -/// How deep form XObjects nest before reading stops (they may also refer to themselves). -pub(crate) const MAX_FORM_DEPTH: usize = 12; - -/// The most form XObjects one page's walk enters, at any depth. The depth cap alone still lets -/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 12 -/// levels), which would hang export on a hostile PDF. -pub(crate) const MAX_FORM_VISITS: usize = 4096; - -/// A form XObject a page draws: its object, content, resources and its matrix composed with the -/// CTM at the `Do`. -pub(crate) struct FormCall { - pub(crate) obj: Option, - pub(crate) data: Vec, - pub(crate) resources: Dict, - pub(crate) ctm: Matrix, -} - -/// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names -/// and forms already being read (`path`), nested too deep, or past the page's `visits` budget -/// ([`MAX_FORM_VISITS`]); each form entered counts one visit. -pub(crate) fn form_call( - doc: &Document, - resources: &Dict, - name: &[u8], - ctm: Matrix, - path: &[pdfcraft_cos::ObjRef], - visits: &mut usize, -) -> Option { - if path.len() >= MAX_FORM_DEPTH || *visits >= MAX_FORM_VISITS { - return None; - } - let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; - let o = xobjects.get(name)?; - let obj = o.as_ref(); - if obj.is_some_and(|r| path.contains(&r)) { - return None; - } - let Object::Stream(s) = &*doc.resolve(o) else { return None }; - if s.dict.name(b"Subtype") != Some(b"Form") { - return None; - } - let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| { - let a = m.as_array()?; - let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); - <[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite())) - }); - // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). - let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); - *visits = visits.saturating_add(1); - Some(FormCall { - obj, - data: s.decoded().unwrap_or_default(), - resources: own.unwrap_or_else(|| resources.clone()), - ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)), - }) -} - -/// The lines a page shows as it reads, including text drawn by form XObjects (which -/// [`text_lines`] leaves out because it can only rewrite the page's own streams). Lines from a -/// form carry a stream number past the page's streams, one per form drawn. -fn reading_lines(doc: &Document, page: usize) -> Result, EditError> { - struct Walk<'a> { - doc: &'a Document, - lines: Vec, - next_stream: usize, - path: Vec, - visits: usize, - } - /// `stream` gives the stream number of the operator at an index of `ops`. - fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) { - let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let mut cache = HashMap::new(); - let mut invoked = Vec::new(); - let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default(); - let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties)); - let mut last = None; - let mut last_stream = None; - let mut forms = invoked.into_iter().peekable(); - for s in shown { - while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) { - enter(w, resources, &name, ts); - // Text after a form starts a new line. - last = None; - } - let si = stream(s.op); - // Text from another stream starts a new line, as when each stream was read alone. - if last_stream.replace(si).is_some_and(|prev| prev != si) { - last = None; - } - add_shown(&mut w.lines, &mut last, s, si); - } - for (_, name, ts) in forms { - enter(w, resources, &name, ts); - } - } - fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) { - let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return }; - let ops = parse(&form.data).ops; - // The form starts with the graphics state at its `Do`, its own matrix applied. - let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new() }; - let stream = w.next_stream; - w.next_stream = w.next_stream.saturating_add(1); - if let Some(r) = form.obj { - w.path.push(r); - } - walk(w, &ops, &form.resources, &mut carry, &|_| stream); - if form.obj.is_some() { - w.path.pop(); - } - } - let p = page_dict(doc, page)?; - let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); - let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 }; - let mut carry = Carry::new(); - // Read the page's streams as the one stream they are, in one pass, as rendering and editing - // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) - // can run on from one stream into the next. Text still counts as the stream its operator - // starts in, which keeps lines from different streams apart. - let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::>()); - let ops = pieces.parse().ops; - let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0); - walk(&mut w, &ops, &res, &mut carry, &stream_of); - let mut lines = w.lines; - // A visual-order Indic font draws pre-base vowel signs before their consonant. - for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) { - l.text = crate::indic::to_logical(&l.text); - } - lines.retain(|l| !l.text.trim().is_empty()); - Ok(lines) -} - -/// Text → the runs that show it: each run's font resource name and the bytes shown in that font. -type Encoder = Box Option)>>>; - -fn is_win_ansi_char(c: char) -> bool { - c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some() -} - -fn needs_type3(text: &str) -> bool { - text.chars().any(|c| !is_win_ansi_char(c)) -} - -/// The `/Name` of the Type 3 fonts generated for Japanese text. -const JAPANESE_FONT_NAME: &str = "PCJapanese"; -/// The `/Name` of the Type 3 fonts generated for Hebrew text. -const HEBREW_FONT_NAME: &str = "PCHebrew"; - -/// Whether `font` is one of those generated fonts (Japanese or Hebrew): a replacement longer than -/// one of them is split across several, which read back as one line (see [`same_font`]). -fn is_type3_fallback(font: &Dict) -> bool { - font.name(b"Subtype") == Some(b"Type3") - && font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes()) -} - -/// Whether a run shown in `font` (a resource name, and the Japanese or Hebrew fallback face it subsets, if -/// any) continues `line`: the same font resource, or another subset of the same face. -fn same_font(line: &TextLine, font: &[u8], face: Option<&str>) -> bool { - line.font.as_bytes() == font || (line.face.is_some() && line.face.as_deref() == face) -} - -fn source_family(base_font: &str) -> crate::added::Family { - let name = base_font.to_ascii_lowercase(); - if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) { - crate::added::Family::Courier - } else if !name.contains("sans") - && ["times", "serif", "roman", "mincho", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s)) - { - crate::added::Family::Times - } else { - crate::added::Family::Helvetica - } -} - -const SUBSTITUTE_NAME: &str = "PCEdHelv"; -const SUBSTITUTE: &[u8] = SUBSTITUTE_NAME.as_bytes(); -const MAX_TYPE3_GLYPHS: usize = 240; - -/// The generated fonts for one replacement: one per run of at most `max_glyphs` unique characters, -/// in order of first appearance. -#[derive(Clone)] -struct Type3Fallback { - /// The family its glyphs come from ("Shippori Mincho"). - family: &'static str, - /// The fonts' resource names in order; the first is `PCJp` (`PCHe` for Hebrew text). - names: Vec, - /// Every unique character with its advance (em), in order of first appearance. - widths: Vec<(char, f64)>, - /// Where each character is shown: the index of its font in `names`, and its code there. - codes: HashMap, -} - -/// One glyph of a generated font: its code there, its character, its glyph procedure and advance. -struct Type3Glyph { - code: u8, - ch: char, - path: Vec, - width: f64, -} - -fn pdf_num(v: f64) -> String { - if v.fract() == 0.0 { format!("{v:.0}") } else { format!("{v:.4}").trim_end_matches('0').trim_end_matches('.').to_string() } -} - -fn type3_path(face: &CraftFont, ch: char) -> Result<(Vec, f64), EditError> { - let glyph = japanese_glyph_from(face, ch).map_err(|e| match e { - GlyphError::NoFont => no_japanese_font(), - GlyphError::Missing => EditError::Invalid(format!("the {} fallback font has no glyph for U+{:04X}", face.family, ch as u32)), - GlyphError::TooComplex => EditError::Invalid(format!("the {} fallback glyph U+{:04X} is too complex", face.family, ch as u32)), - })?; - let (dx, width) = proportional(ch, &glyph); - let scale = 1000.0; - // d1 is `wx wy llx lly urx ury` (ISO 32000-2 §9.6.4) with a box enclosing the glyph; - // Acrobat draws a bullet in place of a glyph whose d1 is malformed. - let b = if glyph.contours.is_empty() { [0.0; 4] } else { glyph.bbox }; - let mut out = format!( - "{} 0 {} {} {} {} d1\n", - pdf_num(width * scale), - pdf_num(((b[0] + dx) * scale).floor()), - pdf_num((b[1] * scale).floor()), - pdf_num(((b[2] + dx) * scale).ceil()), - pdf_num((b[3] * scale).ceil()) - ) - .into_bytes(); - for contour in glyph.contours { - let Some(first) = contour.first() else { continue }; - out.extend_from_slice(format!("{} {} m\n", pdf_num((first[0] + dx) * scale), pdf_num(first[1] * scale)).as_bytes()); - for p in contour.iter().skip(1) { - out.extend_from_slice(format!("{} {} l\n", pdf_num((p[0] + dx) * scale), pdf_num(p[1] * scale)).as_bytes()); - } - out.extend_from_slice(b"h\n"); - } - out.extend_from_slice(b"f\n"); - Ok((out, width)) -} - -/// Side bearing (em) given to a respaced glyph on each side. -const SIDE_BEARING: f64 = 0.05; - -/// Shift and advance for a fallback glyph. Japanese faces draw Cyrillic and Greek full-width -/// (one em each, as in JIS X 0208) and have no proportional (`palt`) metrics for them, so a -/// word set that way reads as letter-spaced. Those letters get their ink width plus a side -/// bearing instead; everything else keeps the face's own advance. -fn proportional(ch: char, glyph: &pdfcraft_fonts::GlyphOutline) -> (f64, f64) { - let respace = matches!(ch, '\u{0370}'..='\u{03FF}' | '\u{0400}'..='\u{052F}' | '\u{1F00}'..='\u{1FFF}'); - let ink = glyph.bbox[2] - glyph.bbox[0]; - if !respace || glyph.contours.is_empty() || !(ink > 0.0 && ink < glyph.width) { - return (0.0, glyph.width); - } - (SIDE_BEARING - glyph.bbox[0], ink + 2.0 * SIDE_BEARING) -} - -fn unicode_hex(ch: char) -> String { - let mut units = [0u16; 2]; - let encoded = ch.encode_utf16(&mut units); - encoded.iter().map(|u| format!("{u:04X}")).collect() -} - -/// No font this build has can draw `text`. Only Japanese text is about the Japanese face: other -/// characters are missing from the paragraph's font, and choosing a font that has them works too. -fn no_fallback_font(text: &str) -> EditError { - let japanese = |c: char| matches!(u32::from(c), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF); - if text.chars().any(japanese) { - return no_japanese_font(); - } - let Some(ch) = text.chars().find(|c| !c.is_whitespace() && !is_win_ansi_char(*c)) else { return no_japanese_font() }; - EditError::Invalid(format!( - "the paragraph's font has no glyph for {ch} (U+{:04X}) and this build has no fallback font for it; \ - choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \ - PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)", - u32::from(ch) - )) -} - -/// This build has no Japanese face to draw replacement text with. -fn no_japanese_font() -> EditError { - EditError::Invalid( - "this text needs PdfCraft's Japanese fallback font, which this build doesn't include \ - (official releases do; to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout)" - .into(), - ) -} - -/// The first of `faces` (best match first) that has a glyph for every one of `chars`, else the first. -fn fallback_face<'a>(faces: &[&'a CraftFont], chars: &[char], has_glyph: impl Fn(&CraftFont, char) -> bool) -> Option<&'a CraftFont> { - faces.iter().copied().find(|face| chars.iter().all(|ch| has_glyph(face, *ch))).or_else(|| faces.first().copied()) -} - -/// This build has no Hebrew face to draw replacement text with. -fn no_hebrew_font() -> EditError { - EditError::Invalid( - "this text needs PdfCraft's Hebrew fallback font (Noto Sans Hebrew), which this build doesn't include \ - (to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has it)" - .into(), - ) -} - -/// The faces a Type 3 replacement may draw `text` with, best first. Text with Hebrew takes the -/// Hebrew face first (its Latin and digits too, so a line keeps one look), then the Japanese -/// faces for anything it lacks; other text takes the Japanese faces, as before. -fn type3_faces(text: &str, family: crate::added::Family, bold: bool) -> Result<(Vec<&'static CraftFont>, bool), EditError> { - let japanese = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold); - if !text.chars().any(crate::bidi::is_hebrew) { - return Ok((japanese, false)); - } - // Points are drawn on their letter with the font's positioning, which a one-glyph-per- - // character Type 3 font can't do; refuse rather than print them beside the letter. - if text.chars().any(crate::bidi::is_hebrew_mark) { - return Err(EditError::Invalid("Hebrew points (niqqud) can't be written into a PDF yet; type the text without them".into())); - } - let hebrew = pdfcraft_fonts::document_hebrew_font(bold).ok_or_else(no_hebrew_font)?; - Ok((std::iter::once(hebrew).chain(japanese).collect(), true)) -} - -fn type3_font( - doc: &mut Document, - fonts_res: &mut Dict, - text: &str, - family: crate::added::Family, - bold: bool, - max_glyphs: usize, -) -> Result { - let (faces, hebrew) = type3_faces(text, family, bold)?; - // Each unique character once, in order of first appearance. - let mut chars = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for ch in text.chars() { - if seen.insert(ch) { - chars.push(ch); - } - } - if chars.is_empty() { - return Err(EditError::Invalid("replacement text is empty".into())); - } - // The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every - // character. When none has them all, the best face reports the character it lacks. Hebrew - // text keeps the Hebrew face and takes each character it lacks from the next face that has it. - let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) } - .ok_or_else(|| no_fallback_font(text))?; - let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] }; - // Every outline is read before the document is touched, so a character that fails leaves it - // as it was. Each font takes the next `per_font` characters; codes restart at 1 in each. - let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); - let mut glyphs = Vec::with_capacity(chars.len()); - for (i, ch) in chars.into_iter().enumerate() { - // Hebrew text: the first face with the glyph, else the Hebrew face's error. - let (path, width) = others.iter().fold(type3_path(face, ch), |found, f| found.or_else(|e| type3_path(f, ch).map_err(|_| e)))?; - let code = u8::try_from((i % per_font).saturating_add(1)) - .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; - glyphs.push(Type3Glyph { code, ch, path, width }); - } - let mut codes = HashMap::with_capacity(glyphs.len()); - let mut widths = Vec::with_capacity(glyphs.len()); - let mut names = Vec::new(); - let mut glyphs = glyphs.into_iter(); - loop { - let group: Vec = glyphs.by_ref().take(per_font).collect(); - if group.is_empty() { - break; - } - for glyph in &group { - codes.insert(glyph.ch, (names.len(), glyph.code)); - widths.push((glyph.ch, glyph.width)); - } - names.push(add_type3_font(doc, fonts_res, face, hebrew, group)); - } - Ok(Type3Fallback { family: face.family, names, widths, codes }) -} - -/// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource -/// name (`PCJp`, then `PCJp1`, ...; `PCHe`, ... for Hebrew text). Nothing here can fail: the -/// outlines were read already. -fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, hebrew: bool, glyphs: Vec) -> String { - let last_char = glyphs.len(); - let mut charprocs = Dict::new(); - let mut widths = Vec::with_capacity(last_char); - let mut differences = vec![Object::Int(1)]; - let mut cmap = String::from( - "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> \nendcodespacerange\n", - ); - cmap.push_str(&format!("{last_char} beginbfchar\n")); - for glyph in glyphs { - let glyph_name = format!("g{:02X}", glyph.code); - let mut pd = Dict::new(); - pd.set(b"Length".to_vec(), glyph.path.len() as i64); - let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, glyph.path))); - charprocs.set(glyph_name.as_bytes().to_vec(), Object::Ref(proc_ref)); - differences.push(Object::name(&glyph_name)); - widths.push(Object::Real((glyph.width * 1000.0).round())); - cmap.push_str(&format!("<{:02X}> <{}>\n", glyph.code, unicode_hex(glyph.ch))); - } - cmap.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); - let mut cmap_dict = Dict::new(); - cmap_dict.set(b"Length".to_vec(), cmap.len() as i64); - let cmap_ref = doc.add(Object::Stream(Stream::from_raw(cmap_dict, cmap.into_bytes()))); - let mut encoding = Dict::new(); - encoding.set(b"Type".to_vec(), Object::name("Encoding")); - encoding.set(b"Differences".to_vec(), Object::Array(differences)); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type3")); - font.set(b"Name".to_vec(), Object::name(if hebrew { HEBREW_FONT_NAME } else { JAPANESE_FONT_NAME })); - let mut descriptor = Dict::new(); - descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); - descriptor.set(b"FontName".to_vec(), Object::name(&format!("{}-{}", face.family, face.style).replace(' ', ""))); - descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(face.family.as_bytes().to_vec()))); - descriptor.set(b"Flags".to_vec(), Object::Int(if face.family.contains("Mincho") { 6 } else { 4 })); - descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); - // PDF 1.7 tables 5.9 and 5.19: a Type 3 descriptor is indirect; Ascent/Descent may be omitted. - font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor)))); - font.set(b"FontBBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])); - font.set( - b"FontMatrix".to_vec(), - Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), - ); - font.set(b"FirstChar".to_vec(), Object::Int(1)); - font.set(b"LastChar".to_vec(), Object::Int(last_char as i64)); - font.set(b"Widths".to_vec(), Object::Array(widths)); - font.set(b"Encoding".to_vec(), Object::Dict(encoding)); - font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); - font.set(b"ToUnicode".to_vec(), Object::Ref(cmap_ref)); - let prefix = if hebrew { "PCHe" } else { "PCJp" }; - let mut name = String::from(prefix); - let mut suffix = 0usize; - while fonts_res.contains(name.as_bytes()) { - suffix = suffix.saturating_add(1); - name = format!("{prefix}{suffix}"); - } - fonts_res.set(name.as_bytes().to_vec(), Object::Dict(font)); - name -} - -/// The runs that show `text` in the fallback: consecutive characters in one font form one run, so -/// a font is switched only where the text moves to another font. `None` if a character has no code. -fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option)>> { - let mut runs: Vec<(usize, Vec)> = Vec::new(); - for ch in text.chars() { - let (font, code) = *fallback.codes.get(&ch)?; - match runs.last_mut() { - Some((last, bytes)) if *last == font => bytes.push(code), - _ => runs.push((font, vec![code])), - } - } - runs.into_iter().map(|(font, bytes)| Some((fallback.names.get(font)?.clone(), bytes))).collect() -} - -/// The source font's own TrueType/OpenType program, when the document embeds a complete one whose -/// licence allows editable embedding. A subset (an `ABCDEF+` name) is left out: a character can keep -/// its mapping there after the subsetter dropped its outline. -fn source_program(doc: &Document, fonts: &Dict, name: &str) -> Option> { - let font = fonts.get(name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned())?; - let base = Metrics::from_dict(doc, &font).base_font; - if base.split_once('+').is_some_and(|(tag, _)| tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase())) { - return None; - } - let bytes = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font)?; - pdfcraft_fonts::embedding::validate(&bytes).is_ok().then_some(bytes) -} - -/// Whether `text` has characters `line`'s font can't encode that its own program can draw. -fn source_program_completes(doc: &Document, fonts: &Dict, line: &TextLine, text: &str) -> bool { - let Some(metrics) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|f| Metrics::from_dict(doc, &f)) else { - return false; - }; - let missing: String = text.chars().filter(|c| !c.is_whitespace() && !source_encodes(&metrics, &c.to_string())).collect(); - !missing.is_empty() && source_program(doc, fonts, &line.font).is_some_and(|bytes| pdfcraft_fonts::embedding::covers(&bytes, &missing)) -} - -/// A subset may contain all the letters but no space glyph: producers use another subset -/// or a positioning operator for spaces. Do the same instead of substituting the whole font. -fn source_encodes(metrics: &Metrics, text: &str) -> bool { - text.split(' ').all(|word| metrics.encode(word).is_some()) -} - -#[cfg(test)] -thread_local! { - /// How often [`source_space`] ran on this thread, for the test that pins it out of per-character loops. - pub(crate) static SOURCE_SPACE_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -fn source_space(doc: &Document, fonts: &Dict, metrics: &Metrics) -> f64 { - #[cfg(test)] - SOURCE_SPACE_CALLS.with(|c| c.set(c.get().saturating_add(1))); - let width = |m: &Metrics| m.encode(" ").map(|b| m.codes(&b).iter().map(|(c, _)| m.width(*c)).sum::()); - if let Some(w) = width(metrics) { - return w; - } - let family = metrics.base_font.rsplit('+').next().unwrap_or(&metrics.base_font); - for (_, font) in fonts.iter() { - if let Some(dict) = doc.resolve(font).as_dict() { - let m = Metrics::from_dict(doc, dict); - if m.base_font.rsplit('+').next() == Some(family) - && let Some(w) = width(&m) - { - return w; - } - } - } - 0.33 -} - -fn source_width(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> f64 { - if let Some(bytes) = metrics.encode(text) { - // PDF word spacing applies only to single-byte code 32, even for a Unicode space. - return metrics - .codes(&bytes) - .iter() - .map(|(c, len)| metrics.width(*c) * size + char_spacing + if metrics.is_space(*c, *len) { word_spacing } else { 0.0 }) - .sum(); - } - let words: f64 = text - .split(' ') - .filter_map(|w| metrics.encode(w)) - .map(|b| metrics.codes(&b).iter().map(|(c, _)| metrics.width(*c) * size + char_spacing).sum::()) - .sum(); - words + text.matches(' ').count() as f64 * (space * size + char_spacing + word_spacing) -} - -fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> Result, EditError> { - if let Some(bytes) = metrics.encode(text) { - return Ok(vec![Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])]); - } - let adjustment = -1000.0 * (space + (char_spacing + word_spacing) / size); - if !adjustment.is_finite() { - return Err(EditError::Invalid("the source font's space advance is invalid".into())); - } - let mut out = Vec::new(); - for (i, word) in text.split(' ').enumerate() { - if i > 0 { - out.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); - } - if !word.is_empty() { - let bytes = metrics.encode(word).ok_or_else(|| EditError::Invalid("the source font cannot show the replacement text".into()))?; - out.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); - } - } - Ok(out) -} - -/// Replace the text of line `line` (an index into [`text_lines`]) on `page` with `text`. -pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> Result { - replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS) -} - -/// [`replace_line`] with the most unique characters one generated font may hold (tests lower it). -pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result { - let text = text.replace(['\n', '\r'], " "); - let lines = text_lines(doc, page)?; - let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?; - let p = page_dict(doc, page)?; - let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); - let ops = pieces.parse().ops; - let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; - // The line's own font, when it can show every character. - let font = fonts_res.get(target.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); - let visual_indic = font.as_ref().is_some_and(|m| m.visual_order) || target.visual_order; - let indic_text = if visual_indic { crate::indic::to_visual(&text) } else { text.clone() }; - // Typed in logical order; drawn left to right (right-to-left runs reversed). - let shown = crate::bidi::to_visual(&indic_text); - let reused = font.as_ref().filter(|m| source_encodes(m, &shown)); - let mut substituted = None; - let mut replacement: Vec = Vec::new(); - // ' and " also move to the next line; keep that. - match ops[first].op.as_slice() { - b"'" => replacement.push(Op::new("T*", vec![])), - b"\"" => { - replacement.push(Op::new("Tw", vec![ops[first].operands.first().cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("Tc", vec![ops[first].operands.get(1).cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("T*", vec![])); - } - _ => {} - } - match reused { - Some(metrics) => replacement.extend(source_show( - metrics, - &shown, - target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size), - source_space(doc, &fonts_res, metrics), - target.origin.state.char_spacing, - target.origin.state.word_spacing, - )?), - None => { - let fallback_shown = crate::bidi::to_visual(&text); - if needs_type3(&text) { - let fallback = type3_font(doc, &mut fonts_res, &text, source_family(&target.base_font), target.bold, max_glyphs)?; - let runs = type3_encode(&fallback, &fallback_shown) - .ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the {} fallback", fallback.family)))?; - let size = font_size_before(&ops, first).unwrap_or(target.size); - for (name, bytes) in runs { - replacement.push(Op::new("Tf", vec![Object::name(&name), pdfcraft_content::num(size)])); - replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); - } - replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); - substituted = Some(format!("{} Type3", fallback.family)); - } else { - let win = pdfcraft_fonts::win_ansi(&fallback_shown); - // WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing. - let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); - if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { - return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font))); - } - // The size in text space: the current Tf's size. - let size = font_size_before(&ops, first).unwrap_or(target.size); - let family = source_family(&target.base_font); - let base = family.base_font(target.bold, target.italic); - let substitute_name = format!("PCEd{}", base.replace('-', "")); - replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); - replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); - replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); - substituted = Some(base.to_string()); - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts_res.set(substitute_name.into_bytes(), Object::Dict(f)); - } - } - } - // Rebuild: the line's first operator becomes the replacement, its others go. Copies of the - // line drawn on the same baseline go too, so the replacement is all that shows. - let mut drops = coincident_ops(&lines, &[&target]); - drops.extend(target.ops.iter().copied()); - let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); - let contents = rewritten_contents(doc, &streams, new_data); - let page_ref = p.obj; - if substituted.is_some() { - res.set(b"Font".to_vec(), Object::Dict(fonts_res)); - } - doc.update_dict(page_ref, |d| { - d.set(b"Contents".to_vec(), contents); - if substituted.is_some() { - d.set(b"Resources".to_vec(), Object::Dict(res)); - } - })?; - Ok(LineEdit { substituted }) -} - -/// The size of the last `Tf` before operator `at`. -fn font_size_before(ops: &[Op], at: usize) -> Option { - ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) -} - -/// Operators of every line that is another drawing of one of `targets` (fake bold, an invisible -/// text layer). A surviving copy would show the old text under the replacement. -/// -/// A copy overlaps a target by more than half the smaller box and sits on the same baseline -/// (within the tolerance that joins showing operators onto one line). A neighbouring line can -/// share box area when the leading is tighter than the font's ascent plus descent; its baseline -/// is farther away, so it stays. -fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections::HashSet { - let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); - let mut drop = std::collections::HashSet::new(); - for line in lines { - let copy = targets.iter().any(|target| { - let same_text = line.text.trim() == target.text.trim() || (line.origin.state.render_mode == 3) != (target.origin.state.render_mode == 3); - same_text && overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target) - }); - if copy { - drop.extend(line.ops.iter().copied()); - } - } - drop -} - -fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bool { - let ix = (b[2].min(a[2]) - b[0].max(a[0])).max(0.0); - let iy = (b[3].min(a[3]) - b[1].max(a[1])).max(0.0); - let small = area(b).min(area(a)); - small > 0.0 && ix * iy / small > 0.5 -} - -/// Perpendicular distance between baselines, in user space. Horizontal text compares height; -/// turned text compares the distance across the writing direction. -fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { - let (dir, target_dir) = (line.direction(), target.direction()); - let same_dir = (dir.0 - target_dir.0).abs() < 1e-3 && (dir.1 - target_dir.1).abs() < 1e-3; - let same_size = line.size.min(target.size) >= line.size.max(target.size) * 0.8; - let angle = target.rotation().to_radians(); - let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; - let dx = line.origin.x - target.origin.x; - let dy = line.origin.baseline - target.origin.baseline; - let offset = dx.mul_add(-sin, dy * cos).abs(); - let size = line.size.min(target.size).max(1.0); - same_dir && same_size && offset.is_finite() && offset < size * 0.3 -} - -/// The paragraphs on a page (0-based). -pub fn text_blocks(doc: &Document, page: usize) -> Result, EditError> { - let lines = text_lines(doc, page)?; - Ok(group_blocks(&lines)) -} - -/// Remove a paragraph's glyphs for a temporary editor background. Numeric TJ adjustments -/// preserve the text matrix, so unselected text positioned relative to it stays in place. -/// Call this on a clone: it is a preview operation, not a document edit. -pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), EditError> { - let lines = text_lines(doc, page)?; - let blocks = group_blocks(&lines); - let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?; - let targets: Vec<&TextLine> = target.lines.iter().filter_map(|i| lines.get(*i)).collect(); - let mut drops = coincident_ops(&lines, &targets); - drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); - let p = page_dict(doc, page)?; - let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let streams = content_streams(doc, &p.dict); - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); - let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None); - let mut patches = HashMap::new(); - for s in shown.into_iter().filter(|s| drops.contains(&s.op)) { - if s.state.render_mode >= 4 { - return Err(EditError::Invalid("text used as a clipping path cannot be previewed for editing".into())); - } - let denominator = s.state.size * s.state.scale; - let adjustment = if denominator != 0.0 { -1000.0 * s.advance / denominator } else { 0.0 }; - if !adjustment.is_finite() || (denominator == 0.0 && s.advance != 0.0) { - return Err(EditError::Invalid("the paragraph's text advance cannot be previewed".into())); - } - let mut replacement = Vec::new(); - if let Some(op) = ops.get(s.op) { - if op.is("\"") { - replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))])); - replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))])); - } - if op.is("'") || op.is("\"") { - replacement.push(Op::new("T*", vec![])); - } - } - replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); - patches.insert(s.op, replacement); - } - let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false))); - let contents = rewritten_contents(doc, &streams, data); - doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; - Ok(()) -} - -/// The paragraphs on a page as a reader sees them, including text drawn by form XObjects -/// (Export to Word, HTML and RTF). Read-only: indexes into this list are not -/// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`]. -pub fn reading_blocks(doc: &Document, page: usize) -> Result, EditError> { - let lines = reading_lines(doc, page)?; - Ok(regions::group_reading_blocks(&lines)) -} - -// An ideographic space carries intentional Japanese spacing, including paragraph indentation. -// Keep other whitespace normalization unchanged. -fn paragraph_separator(c: char) -> bool { - c.is_whitespace() && c != '\u{3000}' -} - -/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a -/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); -/// those pieces join without a space. -fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { - let mut out = Vec::new(); - for row in text.split('\n') { - let mut line = String::new(); - let start = out.len(); - for word in row.split(paragraph_separator).filter(|word| !word.is_empty()) { - for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() { - let candidate = match (line.is_empty(), i) { - (true, _) => piece.to_string(), - (false, 0) => format!("{line} {piece}"), - (false, _) => format!("{line}{piece}"), - }; - if !line.is_empty() && advance(&candidate) > width { - out.push(std::mem::take(&mut line)); - line = piece.to_string(); - } else { - line = candidate; - } - } - } - if !line.is_empty() || out.len() == start { - out.push(line); - } - } - out -} - -/// Replace paragraph `block` (an index into [`text_blocks`]) with `text`, rewrapped to the -/// paragraph's width with its line spacing. The paragraph keeps its first line's position, font -/// (or Helvetica when the font can't show the text), size and colour. -pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) -> Result { - rewrite_block(doc, page, block, Some(text), &BlockStyle::default()) -} - -/// Formatting for a rewritten paragraph (`None` keeps the paragraph's own). -#[derive(Clone, Debug, Default, PartialEq)] -pub struct BlockStyle { - /// Disjoint UTF-8 ranges in the replacement, each preserving unspecified source style. - pub ranges: Vec, - /// Explicit page font resource (from register_text_font or the page font inventory). - pub resource: Option, - /// A standard font family, bold, italic. - pub family: Option<(crate::added::Family, bool, bool)>, - /// Override weight without choosing a different family (`None` keeps the source weight). - pub bold: Option, - /// Font size in points (user space). - pub size: Option, - /// Fill colour (RGB 0–1). - pub color: Option<[f64; 3]>, - pub align: Option, - /// Draw a line under each line of text. - pub underline: Option, - /// Line spacing as a multiple of the font size (1.2 is ordinary). - pub line_spacing: Option, - /// Character spacing (points) and horizontal scaling (percent). - pub char_spacing: Option, - pub scale: Option, - /// Move the paragraph by `[dx, dy]` in user space (dragging its box). - pub offset: Option<[f64; 2]>, - /// Rewrap to this width in user space (dragging the box's edge). - pub width: Option, - /// For an invisible paragraph ([`TextBlock::invisible`]): fill its old area with this colour - /// (RGB 0–1) before the new text, hiding the scanned word beneath it. - pub cover: Option<[f64; 3]>, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct RangeStyle { - pub range: std::ops::Range, - pub family: Option<(crate::added::Family, bool, bool)>, - pub resource: Option, - pub size: Option, - pub color: Option<[f64; 3]>, - pub underline: Option, - pub char_spacing: Option, - pub scale: Option, -} - -/// Rewrite paragraph `block` with new text (or its own) and formatting, rewrapped to its width. -pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option<&str>, style: &BlockStyle) -> Result { - rewrite_block_with_limit(doc, page, block, text, style, MAX_TYPE3_GLYPHS) -} - -/// [`rewrite_block`] with the most unique characters one generated font may hold (tests lower it). -pub(crate) fn rewrite_block_with_limit( - doc: &mut Document, - page: usize, - block: usize, - text: Option<&str>, - style: &BlockStyle, - max_glyphs: usize, -) -> Result { - let lines = text_lines(doc, page)?; - rewrite_block_from(doc, page, lines, block, text, style, max_glyphs) -} - -/// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now. -fn rewrite_block_from( - doc: &mut Document, - page: usize, - lines: Vec, - block: usize, - text: Option<&str>, - style: &BlockStyle, - max_glyphs: usize, -) -> Result { - let blocks = group_blocks(&lines); - let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; - let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect(); - let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; - let p = page_dict(doc, page)?; - let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let registered_face = fonts_res.iter().any(|(name, _)| name.starts_with(b"PCFull")); - // Hebrew is written back in drawing order (and with the Hebrew fallback face) by the - // single-style path below; the styled region path does not reorder right-to-left runs. - let hebrew = text.unwrap_or(&b.text).chars().any(crate::bidi::is_hebrew); - if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { - return Err(EditError::Invalid( - "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), - )); - } - // Text turned on the page (a landscape page drawn a quarter turn round) is rewritten in its - // own text space below; the styled region path lays rows out along the page's x axis. - let upright = members.iter().all(|line| line.upright()); - if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { - return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); - } - let visual_indic = members.iter().any(|line| line.visual_order); - if upright - && !hebrew - && !visual_indic - && (!style.ranges.is_empty() - || style.resource.is_some() - || (text.is_some_and(|draft| { - draft.contains('\n') - || pdfcraft_fonts::source_shaping::required(draft) - || !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty() - }) || registered_face - || text.is_some_and(|draft| source_program_completes(doc, &fonts_res, first, draft)) - || b.rows.iter().any(|row| row.len() > 1) - || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color)) - && style.family.is_none() - && style.bold.is_none()) - { - return rewrite_styled_block(doc, page, &b, &lines, text, style); - } - let streams = content_streams(doc, &p.dict); - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); - let ops = pieces.parse().ops; - // Explicit line breaks stay (#1443); within a line, runs of white space become one space. - let text = text - .unwrap_or(&b.text) - .split('\n') - .map(|row| row.split(paragraph_separator).filter(|word| !word.is_empty()).collect::>().join(" ")) - .collect::>() - .join("\n"); - // The characters to show, for choosing fonts: the breaks are moves, not glyphs. - let flat = text.replace('\n', " "); - let o = &first.origin; - let last = members.last().copied().unwrap_or(first); - let emit = &last.origin; - let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; - let k = o.k.max(1e-6); - // The size in text space: points ÷ the text-to-user scale. - let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); - // Spacing and scaling (text state), with the chosen values. - let mut ts_state = o.state.clone(); - if let Some(c) = style.char_spacing { - ts_state.char_spacing = c / k; - } - if let Some(sc) = style.scale { - ts_state.scale = (sc / 100.0).clamp(0.1, 10.0); - } - let o_state = ts_state.clone(); - let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); - let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; - let indic_text = if visual_indic { crate::indic::to_visual(&flat) } else { flat.clone() }; - let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); - let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); - // The chosen style also determines the real Japanese fallback outlines and advances. - let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); - let bold = style.bold.unwrap_or(bold); - // The face is chosen after `bold` settles, so a bold request measures with the bold metrics. - let face = family.std14(bold, italic); - let std_width = move |s: &str, size: f64| -> f64 { face.text_width(s, size) }; - let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); - if !(dx.is_finite() && dy.is_finite()) { - return Err(EditError::Invalid("the paragraph can't be moved that far".into())); - } - // Right-to-left text (more Hebrew or Arabic letters than Latin) is right-aligned unless an - // alignment is asked for, and a single right-to-left line keeps its right edge as it grows. - let rtl = crate::bidi::rtl_base(&flat); - let anchored_right = rtl && upright && style.width.is_none() && members.len() == 1; - // The paragraph's width along its lines: in user space unless it is turned. - let frame = if upright { - b.rect - } else { - members.iter().map(|l| l.frame_rect()).reduce(|a, f| [a[0].min(f[0]), a[1].min(f[1]), a[2].max(f[2]), a[3].max(f[3])]).unwrap_or(b.rect) - }; - // A paragraph keeps its width (or takes the one asked for); a single line grows to the right - // (to the left when right-to-left), up to the page's margin. - let width = match style.width { - // Capped at the largest page PDF allows (14 400 pt). - Some(w) if w.is_finite() => w.clamp(size * k, 14_400.0), - Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), - // Turned text: the page's margins are along another axis. A single line grows (below). - None if !upright => (frame[2] - frame[0]).max(size * k), - None if anchored_right => (b.rect[2] - b.rect[0]).max(size * k).max(b.rect[2] + dx - (p.crop(doc)[0] + 36.0)), - None if members.len() == 1 => (b.rect[2] - b.rect[0]).max(size * k).max(p.crop(doc)[2] - 36.0 - (b.rect[0] + dx)), - None => (b.rect[2] - b.rect[0]).max(size * k), - }; - if !width.is_finite() { - return Err(EditError::Invalid("the paragraph's width must be a number".into())); - } - let align = style.align.or(rtl.then_some(crate::added::Align::Right)); - // Where a right-aligned line ends, from the paragraph's left edge (user space). - let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width }; - // The move and the width are checked here, before the fallback fonts are written: a refused - // paragraph leaves the document as it was. The move is in page space, so it is taken back through - // the CTM's linear part into the space the paragraph is drawn in. - let movement = if dx != 0.0 || dy != 0.0 { - let c = emit.ctm; - let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0]) - .invert() - .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; - Some(back.apply(dx, dy)) - } else { - None - }; - let tm = if o.ctm == emit.ctm { - o.tm - } else { - let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; - Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0 - }; - let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; - let advance = |s: &str| -> f64 { - let t = match (&metrics, reuse) { - (Some(m), true) => { - let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; - source_width(m, &visual_s, size, space, o_state.char_spacing, o_state.word_spacing) - } - _ if let Some(fallback) = &type3 => { - // Each character's count in `s`, from one pass over `s`. - let mut counts: HashMap = HashMap::new(); - for c in s.chars() { - let count = counts.entry(c).or_insert(0); - *count = count.saturating_add(1); - } - fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * size).sum::() - + s.chars().count() as f64 * o_state.char_spacing - } - _ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing, - }; - t * o_state.scale * k - }; - // A turned single line grows to fit, up to where its baseline reaches the page's margin. - let width = if !upright && style.width.is_none() && members.len() == 1 { - let crop = p.crop(doc); - let (x0, y0) = (first.origin.x + dx, first.origin.baseline + dy); - let (ux, uy) = first.direction(); - let tx = if ux > 1e-6 { - (crop[2] - 36.0 - x0) / ux - } else if ux < -1e-6 { - (crop[0] + 36.0 - x0) / ux - } else { - f64::INFINITY - }; - let ty = if uy > 1e-6 { - (crop[3] - 36.0 - y0) / uy - } else if uy < -1e-6 { - (crop[1] + 36.0 - y0) / uy - } else { - f64::INFINITY - }; - let cap = width.max(tx.min(ty)).min(14_400.0); - text.split('\n').map(&advance).fold(width, f64::max).min(cap) - } else { - width - }; - let right_edge = if anchored_right { right_edge } else { width }; - let wrapped = wrap(&text, width + 0.5, advance); - let mut substituted = None; - let new_font = !reuse; - let (show_font, encode): (String, Encoder) = if let Some(m) = metrics.clone().filter(|_| reuse) { - let name = font_name.clone(); - (font_name.clone(), Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)]))) - } else if let Some(fallback) = type3.clone() { - substituted = Some(format!("{} Type3", fallback.family)); - let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; - (first, Box::new(move |s: &str| type3_encode(&fallback, s))) - } else { - let win = pdfcraft_fonts::win_ansi(&flat); - let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); - let base = family.base_font(bold, italic); - if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { - return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", b.base_font))); - } - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let name = if style.family.is_none() { String::from_utf8_lossy(SUBSTITUTE).into_owned() } else { format!("PCEd{}", base.replace('-', "")) }; - fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); - if style.family.is_none() { - substituted = Some(base.to_string()); - } - let shown = name.clone(); - (name, Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))]))) - }; - // Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. - let lead = match style.line_spacing { - Some(m) => size * m.clamp(0.5, 5.0), - // From the first two rows (a row may hold several runs). - None if let Some((upper, lower)) = b - .rows - .first() - .zip(b.rows.get(1)) - .and_then(|(upper, lower)| upper.first().zip(lower.first())) - .and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) => - { - (upper.frame_origin().1 - lower.frame_origin().1) / k * size / old_size - } - None if o_state.leading > 0.0 => o_state.leading, - None => size * 1.2, - }; - let n = pdfcraft_content::num; - // In its own graphics state, so a new colour (or anything else) stops at the paragraph. - let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); - // Moved: a translation inside that state. - if let Some((ax, ay)) = movement { - block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); - } - block_ops.push(Op::new("BT", vec![])); - let mut state = o_state.clone(); - state.word_spacing = 0.0; - // The font the next run is shown in: the one the BT sets, until a run of another fallback subset. - let mut shown_font = show_font.clone(); - state.font = Some((show_font, size)); - if let Some([r, g, bl]) = style.color { - state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; - } - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); - // Alignment: each line's offset from the left edge, in text space. - let offset = |line: &str| -> f64 { - let free = (width - advance(line)).max(0.0) / k; - match align { - Some(crate::added::Align::Center) => free / 2.0, - // A right-to-left line anchored at its right edge may start left of the old box. - Some(crate::added::Align::Right) if anchored_right => (right_edge - advance(line)) / k, - Some(crate::added::Align::Right) => free, - // A justified right-to-left paragraph ends its last line at the right edge. - Some(crate::added::Align::Justify) if rtl => free, - _ => 0.0, - } - }; - // Justify: each line but the last fills the width. Word spacing reaches only single-byte code 32 - // (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a - // generated Type 3 font, a subset without a space) the extra space follows each space as a - // TJ adjustment instead, and code 32, which may then be a letter, gets none. - let justify = align == Some(crate::added::Align::Justify); - let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) { - Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))), - None => type3.is_none(), - }; - let mut x = 0.0; - let mut tw_set = 0.0; - let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space - for (i, line) in wrapped.iter().enumerate() { - let spaces = line.matches(' ').count(); - let tw = - if justify && i + 1 < wrapped.len() && spaces > 0 { (width - advance(line)).max(0.0) / k / o_state.scale / spaces as f64 } else { 0.0 }; - // A stretched line fills the width from the left edge. - let dx = if tw > 0.0 { 0.0 } else { offset(line) }; - if i > 0 || dx != 0.0 { - block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); - } - x = dx; - let stretch = if word_spacing_reaches_spaces { 0.0 } else { tw }; - let tw = tw - stretch; - if tw != tw_set { - block_ops.push(Op::new("Tw", vec![n(tw)])); - tw_set = tw; - } - // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). - let visual = crate::bidi::to_visual(line); - // Stretched without word spacing: shown up to and including each space, then moved on. - let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; - for piece in pieces { - if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { - let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; - block_ops.extend(source_show(metrics, &indic_piece, size, space, o_state.char_spacing, tw)?); - } else { - for (font, bytes) in encode(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != shown_font { - block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); - shown_font = font; - } - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); - } - } - if stretch > 0.0 && piece.ends_with(' ') { - block_ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / size)])])); - } - } - let tw = tw + stretch; - let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k; - underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); - } - block_ops.push(Op::new("ET", vec![])); - if style.underline == Some(true) { - // In text space (the paragraph's text matrix), in its fill colour. - let colour: Vec = state - .fill - .iter() - .map(|f| match f.op.as_slice() { - b"g" => Op::new("G", f.operands.clone()), - b"rg" => Op::new("RG", f.operands.clone()), - b"k" => Op::new("K", f.operands.clone()), - b"cs" => Op::new("CS", f.operands.clone()), - b"sc" => Op::new("SC", f.operands.clone()), - _ => Op::new("SCN", f.operands.clone()), - }) - .collect(); - block_ops.push(Op::new("q", vec![])); - block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect())); - block_ops.extend(colour); - block_ops.push(Op::new("w", vec![n(size * 0.06)])); - for (x0, x1, y) in underlines { - block_ops.push(Op::new("m", vec![n(x0), n(y)])); - block_ops.push(Op::new("l", vec![n(x1), n(y)])); - } - block_ops.push(Op::new("S", vec![])); - block_ops.push(Op::new("Q", vec![])); - } - block_ops.push(Op::new("Q", vec![])); - // The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold - // second copy, an invisible text layer): the new text is all that may show. - let mut drop = coincident_ops(&lines, &members); - drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - // Text shown earlier in the same text object stays first: the paragraph then goes where its - // last row was, after any backgrounds between the rows, or between two halves of the text object. - let start = last.ops.first().copied().unwrap_or(emit.bt_op); - let split = ops - .get(emit.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); - // What the text after the paragraph expects: the state at its BT (or where it was split). - let after = if split { &emit.state } else { &emit.bt_state }; - block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); - if split { - block_ops.insert(0, Op::new("ET", vec![])); - block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); - } - let at = if split { start } else { emit.bt_op }; - let new_data = splice(&pieces, &ops, |i| { - let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; - (insert, !drop.contains(&i)) - }); - let contents = rewritten_contents(doc, &streams, new_data); - if new_font { - res.set(b"Font".to_vec(), Object::Dict(fonts_res)); - } - let page_ref = p.obj; - doc.update_dict(page_ref, |d| { - d.set(b"Contents".to_vec(), contents); - if new_font { - d.set(b"Resources".to_vec(), Object::Dict(res)); - } - })?; - Ok(LineEdit { substituted }) -} - -/// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a -/// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text. -/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it. -pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result, EditError> { - if !block.invisible { - return Ok(Vec::new()); - } - let n = pdfcraft_content::num; - let mut ops = Vec::new(); - if let Some(colour) = style.cover { - if colour.iter().any(|c| !c.is_finite() || !(0.0..=1.0).contains(c)) { - return Err(EditError::Invalid("the cover colour must be RGB values from 0 to 1".into())); - } - let back = Matrix(ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be covered in".into()))?; - ops.push(Op::new("q", vec![])); - ops.push(Op::new("rg", colour.map(n).to_vec())); - for rect in block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect) { - // Scanned ink reaches a little past the recognised box. - let pad = ((rect[3] - rect[1]).abs() * 0.1).clamp(0.5, 2.0); - let corners = - [[rect[0] - pad, rect[1] - pad], [rect[2] + pad, rect[1] - pad], [rect[2] + pad, rect[3] + pad], [rect[0] - pad, rect[3] + pad]]; - for (i, [x, y]) in corners.into_iter().enumerate() { - let (x, y) = back.apply(x, y); - if !(x.is_finite() && y.is_finite()) { - return Err(EditError::Invalid("the paragraph's area can't be covered".into())); - } - ops.push(Op::new(if i == 0 { "m" } else { "l" }, vec![n(x), n(y)])); - } - ops.push(Op::new("h", vec![])); - } - ops.push(Op::new("f", vec![])); - ops.push(Op::new("Q", vec![])); - } - ops.push(Op::new("Tr", vec![Object::Int(0)])); - Ok(ops) -} - -/// Existing paragraph indexes, excluding streams owned by an added-content item. -pub(crate) fn selectable_blocks( - doc: &Document, - page: usize, - excluded: &std::collections::BTreeSet, -) -> Result, EditError> { - let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); - let lines = text_lines(doc, page)?; - Ok(group_blocks(&lines) - .into_iter() - .enumerate() - .filter(|(_, b)| { - !b.lines - .iter() - .filter_map(|i| lines.get(*i)) - .any(|l| streams.get(l.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r))) - }) - .collect()) -} - -/// Detect vertical CMaps before moving horizontal text. A broken/cyclic embedded CMap is -/// refused rather than guessing which direction TJ advances its text matrix. -fn vertical_encoding(doc: &Document, font: &Dict) -> Result { - if font.name(b"Subtype") != Some(b"Type0") { - return Ok(false); - } - let mut next = font.get(b"Encoding").cloned(); - let mut seen = std::collections::BTreeSet::new(); - for _ in 0..8 { - let Some(o) = next.take() else { return Ok(false) }; - if let Some(r) = o.as_ref() - && !seen.insert(r) - { - return Err(EditError::Invalid("the text encoding is cyclic".into())); - } - match &*doc.resolve(&o) { - Object::Name(n) => return Ok(n.ends_with(b"-V")), - Object::Stream(stream) => { - if let Some(mode) = stream.dict.get(b"WMode") { - match mode.as_int() { - Some(1) => return Ok(true), - Some(0) => {} - _ => return Err(EditError::Invalid("the text writing mode is invalid".into())), - } - } - let bytes = stream - .decoded_within(8 * 1024 * 1024) - .map_err(|_| EditError::Invalid("the text encoding cannot be decoded within its size limit".into()))?; - let ops = parse(&bytes).ops; - if let Some(mode) = ops.iter().rev().find(|op| op.is("def") && op.name(0) == Some(b"WMode")) { - return match mode.num(1) { - Some(0.0) => Ok(false), - Some(1.0) => Ok(true), - _ => Err(EditError::Invalid("the text writing mode is invalid".into())), - }; - } - next = stream - .dict - .get(b"UseCMap") - .cloned() - .or_else(|| ops.iter().rev().find(|op| op.is("usecmap")).and_then(|op| op.operands.first().cloned())); - } - _ => return Err(EditError::Invalid("the text encoding is invalid".into())), - } - } - Err(EditError::Invalid("the text encoding is nested too deeply".into())) -} - -/// Translate paragraphs together without re-encoding glyphs, changing fonts or reflowing. -/// Numeric TJ offsets and text rise temporarily move each selected show operation, then -/// restore both advances and rise. The PDF reader advances the original glyph bytes itself: -/// no approximate font metrics are used to position following, unselected text (§9.4.4). -pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { - if indexes.is_empty() { - return Ok(()); - } - let p = page_dict(doc, page)?; - let streams = content_streams(doc, &p.dict); - let lines = text_lines(doc, page)?; - let blocks = group_blocks(&lines); - let mut selected = std::collections::BTreeSet::new(); - for index in indexes { - let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?; - for line in &b.lines { - let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?; - selected.extend(l.ops.iter().copied()); - } - } - let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); - let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None); - let mut patches = HashMap::new(); - // Keep full precision: a tiny rounded text-space change can be magnified by a CTM, - // and restoring rise/spacing must not alter following unselected text. - let n = Object::Real; - for s in shown.into_iter().filter(|s| selected.contains(&s.op)) { - if !(0..=3).contains(&s.state.render_mode) { - return Err(EditError::Invalid("text used as a clipping path cannot be moved in a group".into())); - } - let font = fonts - .get(&s.font) - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_dict().cloned()) - .ok_or_else(|| EditError::Invalid("a selected paragraph has no font".into()))?; - if vertical_encoding(doc, &font)? { - return Err(EditError::Invalid("vertical text cannot be moved in a group".into())); - } - let inverse = s.tm.then(&s.state.ctm).invert().ok_or_else(|| EditError::Invalid("a selected paragraph has no area".into()))?; - let [a, b, c, d, _, _] = inverse.0; - let (dx, dy) = (a * offset[0] + c * offset[1], b * offset[0] + d * offset[1]); - let denominator = s.state.size * s.state.scale; - let shift = if dx == 0.0 { - 0.0 - } else if denominator.abs() > 1e-12 { - -1000.0 * dx / denominator - } else { - return Err(EditError::Invalid("a selected paragraph has no horizontal advance".into())); - }; - let rise = s.state.rise + dy; - if ![shift, rise, dx, dy].iter().all(|v| v.is_finite() && v.abs() <= 1e12) { - return Err(EditError::Invalid("the paragraph move is too large".into())); - } - let original = ops.get(s.op).ok_or_else(|| EditError::Invalid("a selected text operation is missing".into()))?; - let mut replacement = Vec::new(); - let show = if original.is("'") || original.is("\"") { - if original.is("\"") { - let spacing = original - .operands - .get(..2) - .filter(|a| a.iter().all(|o| o.as_f64().is_some())) - .ok_or_else(|| EditError::Invalid("a text operation has invalid spacing".into()))?; - for (operator, operand) in ["Tw", "Tc"].into_iter().zip(spacing) { - replacement.push(Op::new(operator, vec![operand.clone()])); - } - } - replacement.push(Op::new("T*", vec![])); - Op::new("Tj", vec![original.operands.last().cloned().ok_or_else(|| EditError::Invalid("a text operation has no string".into()))?]) - } else { - original.clone() - }; - if dy != 0.0 { - replacement.push(Op::new("Ts", vec![n(rise)])); - } - if shift != 0.0 { - replacement.push(Op::new("TJ", vec![Object::Array(vec![n(shift)])])); - } - replacement.push(show); - if shift != 0.0 { - replacement.push(Op::new("TJ", vec![Object::Array(vec![n(-shift)])])); - } - if dy != 0.0 { - replacement.push(Op::new("Ts", vec![n(s.state.rise)])); - } - patches.insert(s.op, replacement); - } - if patches.len() != selected.len() { - return Err(EditError::Invalid("a selected text operation cannot be moved".into())); - } - let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false))); - let contents = rewritten_contents(doc, &streams, data); - doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; - Ok(()) -} - #[cfg(test)] mod tests { use super::*; + fn block(text: &str, rect: [f64; 4]) -> TextBlock { + TextBlock { + text: text.into(), + rect, + base_font: String::new(), + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + lines: Vec::new(), + rows: Vec::new(), + editor_text: text.into(), + reflow_text: text.into(), + spans: Vec::new(), + invisible: false, + } + } + + #[test] + fn a_block_key_finds_its_paragraph_after_others_move_and_not_a_neighbour_in_its_place() { + let (a, b, c) = (block("Alpha", [0.0, 80.0, 50.0, 100.0]), block("Bravo", [0.0, 50.0, 50.0, 60.0]), block("Charlie", [0.0, 0.0, 50.0, 20.0])); + let key = BlockKey::of(&b); + assert_eq!(key.find(&[a.clone(), b.clone(), c.clone()], 1), Some(1)); + // Alpha and Bravo became one paragraph: Bravo's number now belongs to Charlie. + let merged = block("Alpha Bravo", [0.0, 50.0, 50.0, 100.0]); + assert_eq!(key.find(&[merged, c.clone()], 1), None); + // A paragraph that split in two shifts the others down. + assert_eq!(key.find(&[a.clone(), block("x", [0.0; 4]), b.clone(), c], 1), Some(2)); + // The same words somewhere else, or other words in the same place, are not the paragraph. + assert_eq!(key.find(&[block("Bravo", [0.0, 40.0, 50.0, 50.0])], 0), None); + assert_eq!(key.find(&[block("Bravo!", b.rect)], 0), None); + assert_eq!(key.find(&[], 0), None); + // Two paragraphs drawn on top of one another are told apart by the hint. + assert_eq!(key.find(&[b.clone(), b], 1), Some(1)); + // A box that is not a number matches nothing, and cannot panic. + let nan = BlockKey::of(&block("Bravo", [f64::NAN; 4])); + assert_eq!(nan.find(&[block("Bravo", [f64::NAN; 4])], 0), None); + } + + #[test] + fn the_block_that_overlaps_most_is_the_edited_paragraph() { + let blocks = [block("a", [0.0, 80.0, 50.0, 100.0]), block("b", [0.0, 40.0, 50.0, 70.0])]; + assert_eq!(block_overlapping(&blocks, [0.0, 60.0, 50.0, 95.0]), Some(0)); + assert_eq!(block_overlapping(&blocks, [0.0, 35.0, 50.0, 75.0]), Some(1)); + assert_eq!(block_overlapping(&blocks, [200.0, 0.0, 250.0, 10.0]), None, "a box that overlaps nothing"); + assert_eq!(block_overlapping(&[], [0.0, 0.0, 1.0, 1.0]), None); + assert_eq!(block_overlapping(&blocks, [f64::NAN; 4]), None); + } + + #[test] + fn a_subset_tag_is_the_only_difference_between_subsets_of_one_font() { + assert!(same_base_font("ABCDEF+Arial", "GHIJKL+Arial")); + assert!(same_base_font("Arial", "ABCDEF+Arial")); + assert!(same_base_font("Arial", "Arial")); + assert!(!same_base_font("ABCDEF+Arial", "GHIJKL+Helvetica")); + // Five letters are part of the name, not a subset tag. + assert!(!same_base_font("ABCDE+Helvetica-Bold", "Helvetica-Bold")); + // A tag with no name after it, or no name at all, names no font. + assert!(!same_base_font("ABCDEF+", "GHIJKL+")); + assert!(!same_base_font("", "")); + assert!(!same_base_font("Arial", "")); + } + + fn wrapped(text: &str, width: f64) -> Vec { + wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _, _)| line).collect() + } + #[test] fn paragraph_wrapping_keeps_ideographic_spaces() { + assert_eq!(wrapped("A  B C", 4.0), ["A  B", "C"]); + assert_eq!(wrapped("A\u{a0}B C", 3.0), ["A B", "C"]); + assert_eq!(wrapped("A\u{2028}B C", 3.0), ["A B", "C"]); + // `wrap` treats every whitespace character, including `\r`, as a separator to keep + // (#4199); the caller turns `\r\n` and `\r` into `\n` before calling it. + assert_eq!(wrapped(" \tA B\r\nC ", 3.0), [" A", "B ", "C "]); + } + + #[test] + fn a_wrap_is_marked_soft_and_a_newline_is_not() { let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("A  B C", 4.0, width), ["A  B", "C"]); - assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]); - assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]); - assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]); + assert_eq!(wrap("Page 1", 5.0, width), [("Page".into(), false, 0), ("1".into(), true, 1)]); + assert_eq!(wrap("One\nTwo", 10.0, width), [("One".into(), false, 0), ("Two".into(), false, 0)]); + } + + /// #2529: a run of spaces that happens to fall at a wrap point is recorded exactly, not + /// assumed to be one space; a Chinese/Japanese word split, which has no space of its own, + /// is recorded as none. + #[test] + fn a_wrap_records_how_many_spaces_it_consumed() { + let width = |s: &str| s.chars().count() as f64; + assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 2)]); + assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 3)]); + assert_eq!(wrap("日本語の文章です", 3.0, width), [("日本語".into(), false, 0), ("の文章".into(), true, 0), ("です".into(), true, 0)]); + } + + /// #4199: a run of spaces stays a run, before, between and after words; the spaces where a + /// row wraps go, and each other white-space character is one space. + #[test] + fn paragraph_wrapping_keeps_every_space_typed() { + assert_eq!(wrapped("a b c", 20.0), ["a b c"]); + assert_eq!(wrapped(" a b ", 20.0), [" a b "]); + assert_eq!(wrapped("a\t\tb", 20.0), ["a b"]); + assert_eq!(wrapped("aa bb cc", 6.0), ["aa bb", "cc"]); + // An indent alone on a row doesn't wrap before its first word. + assert_eq!(wrapped(" abc", 3.0), [" abc"]); + // The rewrite turns `\r\n` and `\r` into `\n` before wrapping. + assert_eq!(wrapped(" \tA B\nC ", 3.0), [" A", "B", "C "]); + assert_eq!(wrapped(" ", 3.0), [" "]); + assert_eq!(wrapped("", 3.0), [""]); } #[test] fn japanese_text_wraps_between_characters_but_not_before_closing_marks() { - let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]); + assert_eq!(wrapped("日本語の文章です。", 3.0), ["日本語", "の文章", "です。"]); // The comma can't begin a row, so the character before it moves down with it. - assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]); + assert_eq!(wrapped("あいう、えお", 3.0), ["あい", "う、え", "お"]); // Pieces of one word join without a space; separate words keep theirs. - assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]); - assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]); + assert_eq!(wrapped("Edit PDFを編集", 6.0), ["Edit", "PDFを編集"]); + assert_eq!(wrapped("一行目 二行目", 4.0), ["一行目 ", "二行目"]); } #[test] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs new file mode 100644 index 0000000000..9ddf4db4b1 --- /dev/null +++ b/crates/edit/src/text/blocks.rs @@ -0,0 +1,442 @@ +use super::interp::{Carry, interpret}; +use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines}; +use super::rewrite::{LineEdit, coincident_ops, rewrite_block}; +use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions}; + +/// An editable text region: adjacent runs on visual rows, followed by aligned rows. +/// A region may contain several source fonts, sizes, colours and weights. +#[derive(Clone, Debug, PartialEq)] +pub struct TextBlock { + /// The lines' text joined with spaces (lines that ended in a hyphen are joined directly). + pub text: String, + pub rect: [f64; 4], + pub base_font: String, + pub size: f64, + pub bold: bool, + pub italic: bool, + /// The text fill colour as RGB, used by the visual editor and preserved when no colour + /// override is requested. + pub color: [f64; 3], + /// Counterclockwise baseline angle in page user space, in degrees. + pub rotation: f64, + /// Indexes into [`text_lines`]. + pub lines: Vec, + /// Visual rows; each contains indexes into [`text_lines`], including mixed-style runs. + pub rows: Vec>, + /// Text for the inline editor, with a newline only between visual rows. + pub editor_text: String, + /// What a width change reflows. A wrap rejoins with a space; a break that was typed, or a + /// short line that still had room for the next word, stays a newline. + pub reflow_text: String, + /// Byte ranges in `editor_text` and their source line indexes. + pub spans: Vec<(std::ops::Range, usize)>, + /// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose + /// pixels show the words. A rewrite draws the new text visibly (see [`BlockStyle::cover`]). + pub invisible: bool, +} + +impl TextBlock { + /// Preserve styles outside the changed ranges; inserted characters inherit the surrounding + /// source run. The texts are aligned word by word, so words left untouched between two edits + /// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change. + /// Byte ranges are always UTF-8 boundaries. Linear time, including large edits. + pub fn edited_spans(&self, draft: &str) -> Vec<(std::ops::Range, usize)> { + if self.editor_text.len() > 4_000_000 || draft.len() > 4_000_000 { + return vec![(0..draft.len(), self.lines.first().copied().unwrap_or(0))]; + } + let old: Vec<_> = self.editor_text.chars().collect(); + let new: Vec<_> = draft.char_indices().collect(); + let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect(); + let mut original = Vec::with_capacity(old.len()); + let mut span = 0; + for (byte, _) in self.editor_text.char_indices() { + while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) { + span += 1; + } + original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i)); + } + let mut sources = Vec::with_capacity(new.len()); + let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::>(); + let (old_words, new_words) = (slices(&old), slices(&chars)); + match edit_script(&old_words, &new_words, MAX_CHANGED_WORDS) { + Some(script) => { + // `(o, n)`: where the current change starts in the old and new text; `(x, y)`: where it ends. + let ((mut o, mut n), (mut x, mut y)) = ((0, 0), (0, 0)); + let (mut a, mut b) = (old_words.iter(), new_words.iter()); + for step in script { + match step { + Step::Keep => { + replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources); + let len = a.next().map_or(0, Vec::len); + b.next(); + sources.extend(original.get(x..x.saturating_add(len)).unwrap_or_default()); + (x, y) = (x.saturating_add(len), y.saturating_add(len)); + (o, n) = (x, y); + } + Step::Delete => x = x.saturating_add(a.next().map_or(0, Vec::len)), + Step::Insert => y = y.saturating_add(b.next().map_or(0, Vec::len)), + } + } + replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources); + } + None => replaced(&original, 0, &old, &chars, &mut sources), + } + let fallback = original.first().copied().unwrap_or(0); + let mut result: Vec<(std::ops::Range, usize)> = Vec::new(); + for (i, (byte, ch)) in new.iter().enumerate() { + let source = sources.get(i).copied().unwrap_or(fallback); + let end = byte.saturating_add(ch.len_utf8()); + if let Some((range, _)) = result.last_mut().filter(|(_, prev)| *prev == source) { + range.end = end; + } else { + result.push((*byte..end, source)); + } + } + result + } +} + +/// Changed words [`TextBlock::edited_spans`] aligns before it treats the draft as one change. +/// Myers' diff costs O((N + M) · D), so bounding D keeps it linear. +const MAX_CHANGED_WORDS: usize = 64; + +/// `text` split into words, each with the whitespace after it, as char ranges. +fn words(text: &[char]) -> Vec> { + let mut out = Vec::new(); + let mut start = 0; + for (i, pair) in text.windows(2).enumerate() { + if let [a, b] = pair + && a.is_whitespace() + && !b.is_whitespace() + { + out.push(start..i + 1); + start = i + 1; + } + } + if start < text.len() { + out.push(start..text.len()); + } + out +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Step { + Keep, + Delete, + Insert, +} + +/// Myers' shortest edit script from `a` to `b`, or `None` when it takes more than `max` steps. +fn edit_script(a: &[T], b: &[T], max: usize) -> Option> { + let (n, m) = (a.len() as isize, b.len() as isize); + let max = max.min(a.len().saturating_add(b.len())) as isize; + // Furthest x reached on each diagonal k = x - y, stored at k + max + 1. + let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0); + let mut v = vec![0isize; (2 * max + 3) as usize]; + let mut trace = Vec::new(); + for d in 0..=max { + trace.push(v.clone()); + for k in (-d..=d).step_by(2) { + let down = k == -d || (k != d && at(&v, k - 1) < at(&v, k + 1)); + let mut x = if down { at(&v, k + 1) } else { at(&v, k - 1) + 1 }; + let mut y = x - k; + while x < n && y < m && a.get(x as usize) == b.get(y as usize) { + x += 1; + y += 1; + } + if let Some(slot) = usize::try_from(k + max + 1).ok().and_then(|i| v.get_mut(i)) { + *slot = x; + } + if x >= n && y >= m { + return Some(backtrack(&trace, n, m, max)); + } + } + } + None +} + +/// Walk Myers' saved frontiers back from the end to the edit script. +fn backtrack(trace: &[Vec], n: isize, m: isize, max: isize) -> Vec { + let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0); + let mut steps = Vec::new(); + let (mut x, mut y) = (n, m); + for (d, v) in trace.iter().enumerate().rev() { + let (d, k) = (d as isize, x - y); + let prev_k = if k == -d || (k != d && at(v, k - 1) < at(v, k + 1)) { k + 1 } else { k - 1 }; + let prev_x = at(v, prev_k); + let prev_y = prev_x - prev_k; + while x > prev_x && y > prev_y { + steps.push(Step::Keep); + x -= 1; + y -= 1; + } + if d > 0 { + steps.push(if x == prev_x { Step::Insert } else { Step::Delete }); + } + (x, y) = (prev_x, prev_y); + } + steps.reverse(); + steps +} + +/// `old`, at char `at` of the source, became `new`: characters kept at either end keep their +/// runs, and the rest inherit the run just before the change (or the first one it replaces). +fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut Vec) { + let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count(); + let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count(); + let start = at.saturating_add(prefix); + let inherited = start.checked_sub(1).and_then(|i| original.get(i)).or_else(|| original.get(start)).copied().unwrap_or(0); + for i in 0..new.len() { + let source = if i < prefix { + original.get(at.saturating_add(i)).copied() + } else if i >= new.len().saturating_sub(suffix) { + original.get(at.saturating_add(old.len()).saturating_sub(new.len() - i)).copied() + } else { + Some(inherited) + }; + out.push(source.unwrap_or(inherited)); + } +} + +/// How a listing of [`text_blocks`] records a paragraph so that it can be found again. +/// +/// The index of a paragraph is its position in a list regrouped from geometry on every read, so +/// an edit that merges or splits paragraphs makes the same index name another one (#2516). A key +/// names the paragraph by what it says and where it is; a paragraph that no longer exists as +/// such has no match, instead of a neighbour taking its place. +#[derive(Clone, Debug, PartialEq)] +pub struct BlockKey { + text: String, + rect: [f64; 4], +} + +impl BlockKey { + /// Two boxes are the same box within this many points. + const TOLERANCE: f64 = 0.01; + + pub fn of(block: &TextBlock) -> Self { + Self { text: block.text.clone(), rect: block.rect } + } + + /// The paragraph's box when it was recorded (user space). + pub fn rect(&self) -> [f64; 4] { + self.rect + } + + /// The paragraph in `blocks` with this text and box. `hint` is tried first, so two + /// paragraphs drawn on top of one another are told apart by their order. + pub fn find(&self, blocks: &[TextBlock], hint: usize) -> Option { + let matches = |b: &TextBlock| b.text == self.text && b.rect.iter().zip(&self.rect).all(|(a, b)| (a - b).abs() <= Self::TOLERANCE); + match blocks.get(hint) { + Some(b) if matches(b) => Some(hint), + _ => blocks.iter().position(matches), + } + } +} + +/// The paragraph in `blocks` whose box overlaps `rect` the most, if any overlaps it at all. An +/// edit rewrites a paragraph in place, so this is the paragraph it became. +pub fn block_overlapping(blocks: &[TextBlock], rect: [f64; 4]) -> Option { + // `f64::min` and `max` skip a NaN, which would make a box that isn't a number overlap anything. + let area = |b: &TextBlock| { + if !rect.iter().chain(&b.rect).all(|v| v.is_finite()) { + return 0.0; + } + let w = b.rect[2].min(rect[2]) - b.rect[0].max(rect[0]); + let h = b.rect[3].min(rect[3]) - b.rect[1].max(rect[1]); + if w > 0.0 && h > 0.0 { w * h } else { 0.0 } + }; + blocks.iter().enumerate().map(|(i, b)| (i, area(b))).filter(|(_, a)| *a > 0.0).max_by(|(_, a), (_, b)| a.total_cmp(b)).map(|(i, _)| i) +} + +/// The paragraphs on a page (0-based). +pub fn text_blocks(doc: &Document, page: usize) -> Result, EditError> { + let lines = text_lines(doc, page)?; + Ok(group_blocks(&lines)) +} + +/// Remove a paragraph's glyphs for a temporary editor background. Numeric TJ adjustments +/// preserve the text matrix, so unselected text positioned relative to it stays in place. +/// Call this on a clone: it is a preview operation, not a document edit. +pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), EditError> { + let lines = text_lines(doc, page)?; + let blocks = group_blocks(&lines); + let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?; + let targets: Vec<&TextLine> = target.lines.iter().filter_map(|i| lines.get(*i)).collect(); + targets.iter().try_for_each(|line| line.ensure_editable())?; + let mut drops = coincident_ops(&lines, &targets); + drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); + let p = page_dict(doc, page)?; + let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let streams = content_streams(doc, &p.dict)?; + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; + let mut patches = HashMap::new(); + for s in shown.into_iter().filter(|s| drops.contains(&s.op)) { + if s.state.render_mode >= 4 { + return Err(EditError::Invalid("text used as a clipping path cannot be previewed for editing".into())); + } + let denominator = s.state.size * s.state.scale; + let adjustment = if denominator != 0.0 { -1000.0 * s.advance / denominator } else { 0.0 }; + if !adjustment.is_finite() || (denominator == 0.0 && s.advance != 0.0) { + return Err(EditError::Invalid("the paragraph's text advance cannot be previewed".into())); + } + let mut replacement = Vec::new(); + if let Some(op) = ops.get(s.op) { + if op.is("\"") { + replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))])); + replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))])); + } + if op.is("'") || op.is("\"") { + replacement.push(Op::new("T*", vec![])); + } + } + replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); + patches.insert(s.op, replacement); + } + let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false))); + let contents = rewritten_contents(doc, &streams, data); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + +/// The paragraphs on a page as a reader sees them, including text drawn by form XObjects +/// (Export to Word, HTML and RTF). Read-only: indexes into this list are not +/// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`]. +/// [`reading_blocks_checked`] also says whether text was left out. +pub fn reading_blocks(doc: &Document, page: usize) -> Result, EditError> { + reading_blocks_checked(doc, page).map(|r| r.blocks) +} + +/// A page's paragraphs as a reader sees them ([`reading_blocks_checked`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ReadingBlocks { + /// The paragraphs, as [`reading_blocks`] returns them. + pub blocks: Vec, + /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], more of them than + /// [`MAX_FORM_VISITS`], or forms that run past what the page's content left of its decode + /// budget. Reading leaves those forms out to stay bounded on hostile files, so their text + /// is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses + /// their images too. + pub truncated: bool, +} + +/// [`reading_blocks`], saying whether the limits that bound reading left text out. A page +/// whose `/Contents` alone runs past its decode budget is an [`EditError::Invalid`], not a +/// truncated result. +pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result { + let (lines, truncated) = reading_lines(doc, page)?; + Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated }) +} + +// An ideographic space carries intentional Japanese spacing, including paragraph indentation. +// Keep other whitespace normalization unchanged. +pub(crate) fn paragraph_separator(c: char) -> bool { + c.is_whitespace() && c != '\u{3000}' +} + +/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a +/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); +/// those pieces join without a space. Every white-space character is written as one space, so +/// a run of spaces typed between, before or after words stays (#4199); only the spaces where a +/// row wraps are dropped (recorded as the third value, so a later rejoin can restore the exact +/// count instead of guessing one, #2529). The bool is true when the line continues the previous +/// one because the next word did not fit, rather than because of a newline; the number is how +/// many separator characters stood where that wrap fell (0 for a Chinese/Japanese word split, +/// which has no space of its own; meaningless when the bool is false). +pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool, usize)> { + let mut out = Vec::new(); + for row in text.split('\n') { + let mut line = String::new(); + let mut soft = false; + // The gap consumed right before `line` started: 0 until a wrap sets it. + let mut wrap_gap = 0usize; + let start = out.len(); + // The separators read since the last word: each one boundary between `split` pieces. + let mut gap = 0usize; + for (k, word) in row.split(paragraph_separator).enumerate() { + if k > 0 { + gap = gap.saturating_add(1); + } + if word.is_empty() { + continue; + } + let gap_here = std::mem::take(&mut gap); + let spaces = " ".repeat(gap_here); + for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() { + let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") }; + // A row holding only the spaces that indent it doesn't wrap before its first word. + if line.chars().any(|c| c != ' ') && advance(&candidate) > width { + out.push((std::mem::take(&mut line), soft, wrap_gap)); + soft = true; + // A mid-word piece (i > 0) had no separator of its own; the first piece of a + // word had exactly the separators just taken. + wrap_gap = if i == 0 { gap_here } else { 0 }; + line = piece.to_string(); + } else { + line = candidate; + } + } + } + line.push_str(&" ".repeat(gap)); + if !line.is_empty() || out.len() == start { + out.push((line, soft, wrap_gap)); + } + } + out +} + +/// Replace paragraph `block` (an index into [`text_blocks`]) with `text`, rewrapped to the +/// paragraph's width with its line spacing. The paragraph keeps its first line's position, font +/// (or Helvetica when the font can't show the text), size and colour. +pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) -> Result { + rewrite_block(doc, page, block, Some(text), &BlockStyle::default()) +} + +/// Formatting for a rewritten paragraph (`None` keeps the paragraph's own). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BlockStyle { + /// Disjoint UTF-8 ranges in the replacement, each preserving unspecified source style. + pub ranges: Vec, + /// Explicit page font resource (from register_text_font or the page font inventory). + pub resource: Option, + /// A standard font family, bold, italic. + pub family: Option<(crate::added::Family, bool, bool)>, + /// Override weight without choosing a different family (`None` keeps the source weight). + pub bold: Option, + /// Font size in points (user space). + pub size: Option, + /// Fill colour (RGB 0–1). + pub color: Option<[f64; 3]>, + pub align: Option, + /// Rows (the text's lines between `\n`s) aligned other than the paragraph, by index; `None` + /// and rows past the end follow `align` ([`crate::paragraph_align`]). + pub aligns: Vec>, + /// Draw a line under each line of text. + pub underline: Option, + /// Line spacing as a multiple of the font size (1.2 is ordinary). + pub line_spacing: Option, + /// Character spacing (points) and horizontal scaling (percent). + pub char_spacing: Option, + pub scale: Option, + /// Move the paragraph by `[dx, dy]` in user space (dragging its box). + pub offset: Option<[f64; 2]>, + /// Rewrap to this width in user space (dragging the box's edge). + pub width: Option, + /// For an invisible paragraph ([`TextBlock::invisible`]): fill its old area with this colour + /// (RGB 0–1) before the new text, hiding the scanned word beneath it. + pub cover: Option<[f64; 3]>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RangeStyle { + pub range: std::ops::Range, + pub family: Option<(crate::added::Family, bool, bool)>, + pub resource: Option, + pub size: Option, + pub color: Option<[f64; 3]>, + pub underline: Option, + pub char_spacing: Option, + pub scale: Option, +} diff --git a/crates/edit/src/text/editor.rs b/crates/edit/src/text/editor.rs new file mode 100644 index 0000000000..f7aa1f47bd --- /dev/null +++ b/crates/edit/src/text/editor.rs @@ -0,0 +1,127 @@ +use super::blocks::{BlockStyle, TextBlock}; +use super::fallback::MAX_TYPE3_GLYPHS; +use super::lines::{page_dict, text_lines}; +use super::rewrite::rewrite_block_from; +use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession}; + +/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and +/// are never installed, copied into assets, or used as a font for another document. +#[derive(Clone, Debug)] +pub struct TextEditorFont { + pub resource: String, + pub face: String, + pub bytes: Vec, +} + +pub fn text_editor_fonts(doc: &Document, page: usize) -> Result, EditError> { + let p = page_dict(doc, page)?; + let resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default(); + let fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default(); + let mut budget = 32_usize << 20; + // Every program decoded counts, usable or not, so aliases of one program can't repeat the work. + let mut decode = DecodeBudget::new(2 * budget, 0); + let mut result = Vec::new(); + for (name, font) in fonts.iter().take(128) { + let Some(font) = doc.dict(font) else { continue }; + let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt_within(doc, &font, &mut decode) else { continue }; + if bytes.len() > budget { + continue; + } + budget -= bytes.len(); + result.push(TextEditorFont { + resource: String::from_utf8_lossy(name).into_owned(), + face: Metrics::from_dict(doc, &font).base_font.rsplit('+').next().unwrap_or_default().to_owned(), + bytes, + }); + } + // The approved full face follows all source subsets in the editor's font stack. It can + // display newly inserted glyphs before Apply without changing the source text's face. + let source_faces: Vec<_> = result.iter().map(|p| p.face.clone()).collect(); + for face in source_faces { + for full in pdfcraft_fonts::embedding::matching_faces(&face) { + if full.bytes.len() > budget || result.iter().any(|p| p.resource == format!("PCApproved{}", full.name())) { + continue; + } + budget -= full.bytes.len(); + result.push(TextEditorFont { resource: format!("PCApproved{}", full.name()), face: face.clone(), bytes: full.bytes.to_vec() }); + } + } + Ok(result) +} + +/// Load an explicit full face into this page's resources. Nothing on the page is replaced; +/// the new resource can complete matching source subsets or be chosen in a text style. +pub fn register_text_font(doc: &mut Document, page: usize, bytes: &[u8]) -> Result { + pdfcraft_fonts::embedding::validate_document_input(bytes).map_err(EditError::Invalid)?; + let (_, postscript) = pdfcraft_fonts::embedding::names(bytes).map_err(EditError::Invalid)?; + let p = page_dict(doc, page)?; + let mut resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default(); + let mut fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default(); + let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, bytes, &postscript).map_err(EditError::Invalid)?; + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources)))?; + Ok(name) +} + +/// Read-only draft bounds, measured with the same PDF rewrite used by Apply/Save. +#[derive(Clone, Debug, PartialEq)] +pub struct TextEditLayout { + pub source_rect: [f64; 4], + pub draft_rect: Option<[f64; 4]>, + pub overflow: bool, +} + +pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, style: &BlockStyle) -> Result { + TextSession::open(doc, page, block)?.layout(text, style) +} + +/// A draft of paragraph `source` (number `block` among the page's `lines`) laid out by the rewrite +/// Apply runs, on a copy of `doc`. +pub(crate) fn layout_draft( + doc: &Document, + page: usize, + lines: &[TextLine], + source: &TextBlock, + block: usize, + text: &str, + style: &BlockStyle, +) -> Result { + if text == source.editor_text && *style == BlockStyle::default() { + return Ok(TextEditLayout { source_rect: source.rect, draft_rect: Some(source.rect), overflow: false }); + } + // Subtract the untouched runs by value, rather than by operator offsets: a rewrite may + // split BT/ET or add font resources. A multiset also handles coincident repeated text. + let key = + |line: &TextLine| (line.text.clone(), line.font.clone(), line.rect.map(f64::to_bits), line.size.to_bits(), line.color.map(f64::to_bits)); + let mut untouched = std::collections::BTreeMap::new(); + for (index, line) in lines.iter().enumerate() { + if !source.lines.contains(&index) { + *untouched.entry(key(line)).or_insert(0_usize) += 1; + } + } + let mut draft = doc.clone(); + rewrite_block_from(&mut draft, page, lines.to_vec(), block, Some(text), style, MAX_TYPE3_GLYPHS)?; + let mut rect: Option<[f64; 4]> = None; + for line in text_lines(&draft, page)? { + if let Some(count) = untouched.get_mut(&key(&line)) + && *count > 0 + { + *count -= 1; + continue; + } + // Spaces typed after a row's last word are drawn but show nothing, so an upright row is + // measured to the end of its last word: they don't make a draft overflow (#4199). + let mut r = line.rect; + if line.upright() && !line.ink.is_empty() { + if crate::bidi::rtl_base(&line.text) { + r[0] = r[0].max(line.ink.iter().map(|i| i[0]).fold(f64::INFINITY, f64::min)); + } else { + r[2] = r[2].min(line.ink.iter().map(|i| i[2]).fold(f64::NEG_INFINITY, f64::max)); + } + } + rect = Some(rect.map_or(r, |a| [a[0].min(r[0]), a[1].min(r[1]), a[2].max(r[2]), a[3].max(r[3])])); + } + let overflow = rect + .is_some_and(|r| r[0] < source.rect[0] - 0.5 || r[1] < source.rect[1] - 0.5 || r[2] > source.rect[2] + 0.5 || r[3] > source.rect[3] + 0.5); + Ok(TextEditLayout { source_rect: source.rect, draft_rect: rect, overflow }) +} diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs new file mode 100644 index 0000000000..b8f45a221f --- /dev/null +++ b/crates/edit/src/text/fallback.rs @@ -0,0 +1,601 @@ +use super::interp::Ts; +use super::lines::page_dict; +use super::{ + CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse, +}; + +/// Text → the runs that show it: each run's font resource name and the bytes shown in that font. +pub(super) type Encoder = Box Option)>>>; + +fn is_win_ansi_char(c: char) -> bool { + c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some() +} + +pub(crate) fn needs_type3(text: &str) -> bool { + text.chars().any(|c| !is_win_ansi_char(c)) +} + +/// The `/Name` of the Type 3 fonts generated for Japanese text. +const JAPANESE_FONT_NAME: &str = "PCJapanese"; + +/// The `/Name` of the Type 3 fonts generated for Hebrew text. +const HEBREW_FONT_NAME: &str = "PCHebrew"; + +/// The `/Name` of the Type 3 fonts generated from the bundled Latin (Inter) face. +const LATIN_FONT_NAME: &str = "PCLatin"; + +/// Whether `font` is one of those generated fonts (Latin, Japanese or Hebrew): a replacement longer +/// than one of them is split across several, which read back as one line (see [`same_font`]). +pub(super) fn is_type3_fallback(font: &Dict) -> bool { + font.name(b"Subtype") == Some(b"Type3") + && font + .name(b"Name") + .is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes()) +} + +/// The generated font's `/Name` and its resource prefix: the bundled Latin face, Hebrew, or the +/// Japanese ones. +fn fallback_names(face: &CraftFont, hebrew: bool) -> (&'static str, &'static str) { + if hebrew { + (HEBREW_FONT_NAME, "PCHe") + } else if face.family == "Inter" { + (LATIN_FONT_NAME, "PCLat") + } else { + (JAPANESE_FONT_NAME, "PCJp") + } +} + +/// Drops the generated fallback fonts ([`is_type3_fallback`]) that nothing on `page` shows any more. +/// Each rewrite generates its own, with the outlines of every character it draws, so without this +/// an earlier rewrite's fonts stay in the page's resources and the file. Only a font dictionary the +/// page owns is changed (a rewrite gives the page its own), and nothing is dropped unless every +/// content stream that may name the page's fonts can be read: the page's own, and those of form +/// XObjects and Type 3 glyphs that have no resources of their own and so use the page's. +pub(super) fn drop_unused_fallback_fonts(doc: &mut Document, page: usize) { + let Ok(p) = page_dict(doc, page) else { return }; + let Some(Object::Dict(mut resources)) = p.dict.get(b"Resources").cloned() else { return }; + let Some(mut fonts) = resources.get(b"Font").and_then(|f| doc.dict(f)) else { return }; + let generated: Vec> = + fonts.iter().filter(|(_, f)| doc.dict(f).is_some_and(|d| is_type3_fallback(&d))).map(|(name, _)| name.clone()).collect(); + if generated.is_empty() { + return; + } + let Some(shown) = fonts_shown(doc, &p.dict, &resources) else { return }; + let unused: Vec<&Vec> = generated.iter().filter(|name| !shown.contains(*name)).collect(); + if unused.is_empty() { + return; + } + for name in unused { + fonts.remove(name); + } + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + // Nothing is lost if this fails: the fonts just stay. + let _ = doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))); +} + +/// The font resource names `Tf` selects in the page's content and in the content that uses the +/// page's resources (see [`drop_unused_fallback_fonts`]), or `None` if any of it can't be read. +fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option>> { + let mut streams: Vec = match page.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + // Form XObjects without their own resources use the page's, however deeply they are nested + // (ISO 32000-1 §7.8.3), so every form the page can reach is visited. + let mut pending: Vec = vec![resources.clone()]; + let mut seen = std::collections::HashSet::new(); + while let Some(res) = pending.pop() { + for (_, x) in res.get(b"XObject").and_then(|x| doc.dict(x)).unwrap_or_default().iter() { + let Some(r) = x.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + if seen.len() > 4096 { + return None; + } + let Object::Stream(s) = &*doc.get(r) else { continue }; + if s.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + match s.dict.get(b"Resources").and_then(|r| doc.dict(r)) { + Some(own) => pending.push(own), + None => streams.push(x.clone()), + } + } + } + // And so do Type 3 glyph procedures without their own (the generated fonts' are outlines only). + for (_, f) in resources.get(b"Font").and_then(|f| doc.dict(f)).unwrap_or_default().iter() { + if let Some(font) = doc.dict(f).filter(|d| d.name(b"Subtype") == Some(b"Type3") && !d.contains(b"Resources") && !is_type3_fallback(d)) { + streams.extend(font.get(b"CharProcs").and_then(|c| doc.dict(c)).unwrap_or_default().iter().map(|(_, proc)| proc.clone())); + } + } + let mut shown = std::collections::HashSet::new(); + for stream in streams { + let Object::Stream(s) = &*doc.resolve(&stream) else { return None }; + // Strict: a stream only partly read might name a font in the part that wasn't. + let data = s.decoded_strict().ok()?; + for op in parse(&data).ops { + if op.is("Tf") + && let Some(name) = op.operands.first().and_then(|n| n.as_name()) + { + shown.insert(name.to_vec()); + } + } + } + Some(shown) +} + +/// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese +/// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another +/// resource for the same font (a writer may split one font into several subsets, #3661), or +/// another subset of the same face. +pub(crate) fn same_font(line: &TextLine, font: &[u8], base_font: &str, face: Option<&str>) -> bool { + line.font.as_bytes() == font || same_base_font(&line.base_font, base_font) || (line.face.is_some() && line.face.as_deref() == face) +} + +/// Whether a run painted with `ts` continues `line` as it is painted: a line is rewritten in the +/// state of its first run, so a `Tr`, stroke colour or line width that changes partway starts +/// another run (see [`TextState::paint_ops`]; the `gs` parameters are compared as [`Paint`]). +/// The stroke colour, width, dash and cap only count for text that is stroked, so a rule drawn +/// between two words doesn't split their line. They are compared as values, wherever they were +/// set: two ExtGStates with the same settings under other names still continue a line. +/// +/// Only the visible modes (`Tr` 0–2) that `paint_ops` restates are compared: a line that goes on +/// as invisible or clipping text stays one line, which the clipping refusal reads whole (#3447). +pub(super) fn same_paint(line: &TextLine, ts: &Ts) -> bool { + let a = &line.origin.state; + let visible = |m: i64| (0..=2).contains(&m); + let same_mode = a.render_mode == ts.render_mode || !visible(a.render_mode) || !visible(ts.render_mode); + let stroked = matches!((a.render_mode, ts.render_mode), (1 | 2, 1 | 2)); + let outline = a.stroke == ts.stroke && a.line_width == ts.line_width && a.dash == ts.dash && a.line_cap == ts.line_cap; + same_mode && (!stroked || outline) +} + +/// Whether two `/BaseFont` names name the same font: a subset's six-letter tag and `+` prefix do +/// not change the font (ISO 32000-2 §9.6.4), so `ABCDEF+Arial` and `GHIJKL+Arial` are one font. +/// A name that is missing, or only a tag (`ABCDEF+`), names no font and matches nothing. +pub(crate) fn same_base_font(a: &str, b: &str) -> bool { + let (a, b) = (strip_subset_tag(a), strip_subset_tag(b)); + !a.is_empty() && a == b +} + +/// `ABCDEF+Arial` → `Arial`. A name without a subset tag is returned unchanged. +pub(crate) fn strip_subset_tag(name: &str) -> &str { + let bytes = name.as_bytes(); + let is_tag = bytes.get(6) == Some(&b'+') && bytes.get(..6).is_some_and(|tag| tag.iter().all(u8::is_ascii_uppercase)); + if is_tag { name.get(7..).unwrap_or(name) } else { name } +} + +pub(crate) fn source_family(base_font: &str) -> crate::added::Family { + let name = base_font.to_ascii_lowercase(); + if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) { + crate::added::Family::Courier + } else if !name.contains("sans") + && ["times", "serif", "roman", "mincho", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s)) + { + crate::added::Family::Times + } else { + crate::added::Family::Helvetica + } +} + +pub(crate) const MAX_TYPE3_GLYPHS: usize = 240; + +/// The generated fonts for one replacement: one per run of at most `max_glyphs` unique characters, +/// in order of first appearance. +#[derive(Clone)] +pub(crate) struct Type3Fallback { + /// The family its glyphs come from ("Shippori Mincho"). + pub(crate) family: &'static str, + /// The fonts' resource names in order; the first is `PCJp` (`PCLat` for the bundled Latin + /// face, `PCHe` for Hebrew text). + pub(crate) names: Vec, + /// Every unique character with its advance (em), in order of first appearance. + pub(super) widths: Vec<(char, f64)>, + /// Where each character is shown: the index of its font in `names`, and its code there. + pub(crate) codes: HashMap, +} + +/// One glyph of a generated font: its code there, its character, its glyph procedure and advance. +pub(crate) struct Type3Glyph { + pub(crate) code: u8, + pub(crate) ch: char, + pub(crate) path: Vec, + pub(crate) width: f64, +} + +fn pdf_num(v: f64) -> String { + if v.fract() == 0.0 { format!("{v:.0}") } else { format!("{v:.4}").trim_end_matches('0').trim_end_matches('.').to_string() } +} + +fn type3_path(face: &CraftFont, ch: char) -> Result<(Vec, f64), EditError> { + let glyph = japanese_glyph_from(face, ch).map_err(|e| match e { + GlyphError::NoFont => no_japanese_font(), + GlyphError::Missing => EditError::Invalid(format!("the {} fallback font has no glyph for U+{:04X}", face.family, ch as u32)), + GlyphError::TooComplex => EditError::Invalid(format!("the {} fallback glyph U+{:04X} is too complex", face.family, ch as u32)), + })?; + let (dx, width) = proportional(ch, &glyph); + let scale = 1000.0; + // d1 is `wx wy llx lly urx ury` (ISO 32000-2 §9.6.4) with a box enclosing the glyph; + // Acrobat draws a bullet in place of a glyph whose d1 is malformed. + let b = if glyph.contours.is_empty() { [0.0; 4] } else { glyph.bbox }; + let mut out = format!( + "{} 0 {} {} {} {} d1\n", + pdf_num(width * scale), + pdf_num(((b[0] + dx) * scale).floor()), + pdf_num((b[1] * scale).floor()), + pdf_num(((b[2] + dx) * scale).ceil()), + pdf_num((b[3] * scale).ceil()) + ) + .into_bytes(); + for contour in glyph.contours { + let Some(first) = contour.first() else { continue }; + out.extend_from_slice(format!("{} {} m\n", pdf_num((first[0] + dx) * scale), pdf_num(first[1] * scale)).as_bytes()); + for p in contour.iter().skip(1) { + out.extend_from_slice(format!("{} {} l\n", pdf_num((p[0] + dx) * scale), pdf_num(p[1] * scale)).as_bytes()); + } + out.extend_from_slice(b"h\n"); + } + out.extend_from_slice(b"f\n"); + Ok((out, width)) +} + +/// Side bearing (em) given to a respaced glyph on each side. +const SIDE_BEARING: f64 = 0.05; + +/// Shift and advance for a fallback glyph. Japanese faces draw Cyrillic and Greek full-width +/// (one em each, as in JIS X 0208) and have no proportional (`palt`) metrics for them, so a +/// word set that way reads as letter-spaced. Those letters get their ink width plus a side +/// bearing instead; everything else keeps the face's own advance. +fn proportional(ch: char, glyph: &pdfcraft_fonts::GlyphOutline) -> (f64, f64) { + let respace = matches!(ch, '\u{0370}'..='\u{03FF}' | '\u{0400}'..='\u{052F}' | '\u{1F00}'..='\u{1FFF}'); + let ink = glyph.bbox[2] - glyph.bbox[0]; + if !respace || glyph.contours.is_empty() || !(ink > 0.0 && ink < glyph.width) { + return (0.0, glyph.width); + } + (SIDE_BEARING - glyph.bbox[0], ink + 2.0 * SIDE_BEARING) +} + +fn unicode_hex(ch: char) -> String { + let mut units = [0u16; 2]; + let encoded = ch.encode_utf16(&mut units); + encoded.iter().map(|u| format!("{u:04X}")).collect() +} + +/// The same outline for a face that may simply lack the glyph: `None` then, so the caller can try +/// the next face (and report the character once none of them has it). +pub(crate) fn type3_glyph(face: &CraftFont, ch: char) -> Result, f64)>, EditError> { + match japanese_glyph_from(face, ch) { + Err(GlyphError::Missing) => Ok(None), + _ => type3_path(face, ch).map(Some), + } +} + +/// No face this build has can draw `text`. Only Japanese text is about the Japanese face: other +/// characters are missing from the paragraph's font, and choosing a font that has them works too. +pub(crate) fn no_fallback_font(text: &str) -> EditError { + if text.chars().any(needs_japanese) { + return no_japanese_font(); + } + let Some(ch) = text.chars().find(|c| !c.is_whitespace() && !is_win_ansi_char(*c)) else { return no_japanese_font() }; + EditError::Invalid(format!( + "the paragraph's font has no glyph for {ch} (U+{:04X}) and this build has no fallback font for it; \ + choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \ + PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)", + u32::from(ch) + )) +} + +/// The character the craft-fonts Japanese faces are for: kana, CJK ideographs and their compatibility +/// and full-width forms. +fn needs_japanese(ch: char) -> bool { + matches!(u32::from(ch), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF) +} + +/// The chosen face has no outline for `ch`, and neither has any other: say which character it is and +/// what the writer can do about it. A CJK character without a Japanese face in the build is about +/// that build input, which is the one answer that changes it. +pub(crate) fn no_glyph_for(face: &CraftFont, ch: char) -> EditError { + if needs_japanese(ch) && pdfcraft_fonts::document_japanese_font().is_none() { + return no_japanese_font(); + } + EditError::Invalid(format!( + "the {} fallback font has no glyph for {ch} (U+{:04X}), and no fallback face covers the text; \ + choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \ + PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)", + face.family, + u32::from(ch) + )) +} + +/// This build has no Japanese face to draw replacement text with. +pub(super) fn no_japanese_font() -> EditError { + EditError::Invalid( + "this text needs PdfCraft's Japanese fallback font, which this build doesn't include \ + (official releases do; to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout)" + .into(), + ) +} + +/// The first of `faces` (best match first) that has a glyph for every one of `chars`, else the first. +pub(crate) fn fallback_face<'a>(faces: &[&'a CraftFont], chars: &[char], has_glyph: impl Fn(&CraftFont, char) -> bool) -> Option<&'a CraftFont> { + faces.iter().copied().find(|face| chars.iter().all(|ch| has_glyph(face, *ch))).or_else(|| faces.first().copied()) +} + +/// This build has no Hebrew face to draw replacement text with. +fn no_hebrew_font() -> EditError { + EditError::Invalid( + "this text needs PdfCraft's Hebrew fallback font (Noto Sans Hebrew), which this build doesn't include \ + (to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has it)" + .into(), + ) +} + +/// The faces a Type 3 replacement may draw `text` with, best first. Text with Hebrew takes the +/// Hebrew face first (its Latin and digits too, so a line keeps one look), then the Japanese faces +/// and the bundled Latin one for anything it lacks; other text takes the Japanese faces, and the +/// bundled Latin face behind them, which covers the Latin, Greek and Cyrillic letters they lack +/// (#2593: the Japanese faces have no қ, ғ, ҳ, ә, ө, ү, ұ or һ). +pub(crate) fn type3_faces(text: &str, family: crate::added::Family, bold: bool) -> Result<(Vec<&'static CraftFont>, bool), EditError> { + let japanese = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold); + let latin = pdfcraft_fonts::document_latin_fonts(bold); + if !text.chars().any(crate::bidi::is_hebrew) { + return Ok((japanese.into_iter().chain(latin).collect(), false)); + } + // Points are drawn on their letter with the font's positioning, which a one-glyph-per- + // character Type 3 font can't do; refuse rather than print them beside the letter. + if text.chars().any(crate::bidi::is_hebrew_mark) { + return Err(EditError::Invalid("Hebrew points (niqqud) can't be written into a PDF yet; type the text without them".into())); + } + let hebrew = pdfcraft_fonts::document_hebrew_font(bold).ok_or_else(no_hebrew_font)?; + Ok((std::iter::once(hebrew).chain(japanese).chain(latin).collect(), true)) +} + +pub(super) fn type3_font( + doc: &mut Document, + fonts_res: &mut Dict, + text: &str, + family: crate::added::Family, + bold: bool, + max_glyphs: usize, +) -> Result { + let (faces, hebrew) = type3_faces(text, family, bold)?; + // Each unique character once, in order of first appearance. + let mut chars = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for ch in text.chars() { + if seen.insert(ch) { + chars.push(ch); + } + } + if chars.is_empty() { + return Err(EditError::Invalid("replacement text is empty".into())); + } + // The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every + // character. When none has them all, the best face reports the character it lacks. Hebrew + // text keeps the Hebrew face and takes each character it lacks from the next face that has it. + let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) } + .ok_or_else(|| no_fallback_font(text))?; + let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] }; + // Every outline is read before the document is touched, so a character that fails leaves it + // as it was. Each font takes the next `per_font` characters; codes restart at 1 in each. + let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); + let mut glyphs = Vec::with_capacity(chars.len()); + for (i, ch) in chars.into_iter().enumerate() { + // The chosen face first, then the faces after it (Hebrew text takes a character the Hebrew + // face lacks from the next face that has it); a character no face draws is reported once. + let mut drawn = type3_glyph(face, ch)?; + for f in others.iter().copied() { + if drawn.is_some() { + break; + } + drawn = type3_glyph(f, ch)?; + } + let Some((path, width)) = drawn else { return Err(no_glyph_for(face, ch)) }; + let code = u8::try_from((i % per_font).saturating_add(1)) + .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; + glyphs.push(Type3Glyph { code, ch, path, width }); + } + Ok(write_type3_glyphs(doc, fonts_res, face, hebrew, glyphs, per_font)) +} + +/// Write already validated outlines; callers can prepare every face before adding any objects. +pub(crate) fn write_type3_glyphs( + doc: &mut Document, + fonts_res: &mut Dict, + face: &CraftFont, + hebrew: bool, + glyphs: Vec, + per_font: usize, +) -> Type3Fallback { + let per_font = per_font.clamp(1, MAX_TYPE3_GLYPHS); + let mut codes = HashMap::with_capacity(glyphs.len()); + let mut widths = Vec::with_capacity(glyphs.len()); + let mut names = Vec::new(); + let mut glyphs = glyphs.into_iter(); + loop { + let group: Vec = glyphs.by_ref().take(per_font).collect(); + if group.is_empty() { + break; + } + for glyph in &group { + codes.insert(glyph.ch, (names.len(), glyph.code)); + widths.push((glyph.ch, glyph.width)); + } + names.push(add_type3_font(doc, fonts_res, face, hebrew, group)); + } + Type3Fallback { family: face.family, names, widths, codes } +} + +/// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource +/// name (`PCLat`, `PCJp`, then `PCJp1`, ...; `PCHe`, ... for Hebrew text). Nothing here can fail: the +/// outlines were read already. +pub(super) fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, hebrew: bool, glyphs: Vec) -> String { + let last_char = glyphs.len(); + let mut charprocs = Dict::new(); + let mut widths = Vec::with_capacity(last_char); + let mut differences = vec![Object::Int(1)]; + let mut cmap = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> \nendcodespacerange\n", + ); + cmap.push_str(&format!("{last_char} beginbfchar\n")); + for glyph in glyphs { + let glyph_name = format!("g{:02X}", glyph.code); + let mut pd = Dict::new(); + pd.set(b"Length".to_vec(), glyph.path.len() as i64); + let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, glyph.path))); + charprocs.set(glyph_name.as_bytes().to_vec(), Object::Ref(proc_ref)); + differences.push(Object::name(&glyph_name)); + widths.push(Object::Real((glyph.width * 1000.0).round())); + cmap.push_str(&format!("<{:02X}> <{}>\n", glyph.code, unicode_hex(glyph.ch))); + } + cmap.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + let mut cmap_dict = Dict::new(); + cmap_dict.set(b"Length".to_vec(), cmap.len() as i64); + let cmap_ref = doc.add(Object::Stream(Stream::from_raw(cmap_dict, cmap.into_bytes()))); + let mut encoding = Dict::new(); + encoding.set(b"Type".to_vec(), Object::name("Encoding")); + encoding.set(b"Differences".to_vec(), Object::Array(differences)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type3")); + let (font_name, prefix) = fallback_names(face, hebrew); + font.set(b"Name".to_vec(), Object::name(font_name)); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(&format!("{}-{}", face.family, face.style).replace(' ', ""))); + descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(face.family.as_bytes().to_vec()))); + descriptor.set(b"Flags".to_vec(), Object::Int(if face.family.contains("Mincho") { 6 } else { 4 })); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + // PDF 1.7 tables 5.9 and 5.19: a Type 3 descriptor is indirect; Ascent/Descent may be omitted. + font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor)))); + font.set(b"FontBBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])); + font.set( + b"FontMatrix".to_vec(), + Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), + ); + font.set(b"FirstChar".to_vec(), Object::Int(1)); + font.set(b"LastChar".to_vec(), Object::Int(last_char as i64)); + font.set(b"Widths".to_vec(), Object::Array(widths)); + font.set(b"Encoding".to_vec(), Object::Dict(encoding)); + font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); + font.set(b"ToUnicode".to_vec(), Object::Ref(cmap_ref)); + let mut name = String::from(prefix); + let mut suffix = 0usize; + while fonts_res.contains(name.as_bytes()) { + suffix = suffix.saturating_add(1); + name = format!("{prefix}{suffix}"); + } + fonts_res.set(name.as_bytes().to_vec(), Object::Dict(font)); + name +} + +/// The runs that show `text` in the fallback: consecutive characters in one font form one run, so +/// a font is switched only where the text moves to another font. `None` if a character has no code. +pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option)>> { + let mut runs: Vec<(usize, Vec)> = Vec::new(); + for ch in text.chars() { + let (font, code) = *fallback.codes.get(&ch)?; + match runs.last_mut() { + Some((last, bytes)) if *last == font => bytes.push(code), + _ => runs.push((font, vec![code])), + } + } + runs.into_iter().map(|(font, bytes)| Some((fallback.names.get(font)?.clone(), bytes))).collect() +} + +/// The source font's own TrueType/OpenType program, when the document embeds a complete one whose +/// licence allows editable embedding. A subset (an `ABCDEF+` name) is left out: a character can keep +/// its mapping there after the subsetter dropped its outline. +pub(crate) fn source_program(doc: &Document, fonts: &Dict, name: &str) -> Option> { + let font = fonts.get(name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned())?; + let base = Metrics::from_dict(doc, &font).base_font; + if base.split_once('+').is_some_and(|(tag, _)| tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase())) { + return None; + } + let bytes = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font)?; + pdfcraft_fonts::embedding::validate(&bytes).is_ok().then_some(bytes) +} + +/// Whether `text` has characters `line`'s font can't encode that its own program can draw. +pub(super) fn source_program_completes(doc: &Document, fonts: &Dict, line: &TextLine, text: &str) -> bool { + let Some(metrics) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|f| Metrics::from_dict(doc, &f)) else { + return false; + }; + let missing: String = text.chars().filter(|c| !c.is_whitespace() && !source_encodes(&metrics, &c.to_string())).collect(); + !missing.is_empty() && source_program(doc, fonts, &line.font).is_some_and(|bytes| pdfcraft_fonts::embedding::covers(&bytes, &missing)) +} + +/// A subset may contain all the letters but no space glyph: producers use another subset +/// or a positioning operator for spaces. Do the same instead of substituting the whole font. +pub(crate) fn source_encodes(metrics: &Metrics, text: &str) -> bool { + text.split(' ').all(|word| metrics.encode(word).is_some()) +} + +#[cfg(test)] +thread_local! { + /// How often [`source_space`] ran on this thread, for the test that pins it out of per-character loops. + pub(crate) static SOURCE_SPACE_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub(crate) fn source_space(doc: &Document, fonts: &Dict, metrics: &Metrics) -> f64 { + #[cfg(test)] + SOURCE_SPACE_CALLS.with(|c| c.set(c.get().saturating_add(1))); + let width = |m: &Metrics| m.encode(" ").map(|b| m.codes(&b).iter().map(|(c, _)| m.width(*c)).sum::()); + if let Some(w) = width(metrics) { + return w; + } + let family = metrics.base_font.rsplit('+').next().unwrap_or(&metrics.base_font); + for (_, font) in fonts.iter() { + if let Some(dict) = doc.resolve(font).as_dict() { + let m = Metrics::from_dict(doc, dict); + if m.base_font.rsplit('+').next() == Some(family) + && let Some(w) = width(&m) + { + return w; + } + } + } + 0.33 +} + +pub(crate) fn source_width(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> f64 { + if let Some(bytes) = metrics.encode(text) { + // PDF word spacing applies only to single-byte code 32, even for a Unicode space. + return metrics + .codes(&bytes) + .iter() + .map(|(c, len)| metrics.width(*c) * size + char_spacing + if metrics.is_space(*c, *len) { word_spacing } else { 0.0 }) + .sum(); + } + let words: f64 = text + .split(' ') + .filter_map(|w| metrics.encode(w)) + .map(|b| metrics.codes(&b).iter().map(|(c, _)| metrics.width(*c) * size + char_spacing).sum::()) + .sum(); + words + text.matches(' ').count() as f64 * (space * size + char_spacing + word_spacing) +} + +pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacing: f64, word_spacing: f64) -> Result, EditError> { + if let Some(bytes) = metrics.encode(text) { + return Ok(vec![Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])]); + } + let adjustment = -1000.0 * (space + (char_spacing + word_spacing) / size); + if !adjustment.is_finite() { + return Err(EditError::Invalid("the source font's space advance is invalid".into())); + } + let mut out = Vec::new(); + for (i, word) in text.split(' ').enumerate() { + if i > 0 { + out.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])])); + } + if !word.is_empty() { + let bytes = metrics.encode(word).ok_or_else(|| EditError::Invalid("the source font cannot show the replacement text".into()))?; + out.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + } + Ok(out) +} diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs new file mode 100644 index 0000000000..2b55e51ef8 --- /dev/null +++ b/crates/edit/src/text/interp.rs @@ -0,0 +1,955 @@ +use super::fallback::is_type3_fallback; +use super::rewrite::vertical_advance; +use super::{Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, Rc}; + +/// Text and graphics state captured for rewriting. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct TextState { + pub(crate) font: Option<(String, f64)>, + pub(crate) char_spacing: f64, + pub(crate) word_spacing: f64, + pub(crate) scale: f64, + pub(super) leading: f64, + pub(crate) rise: f64, + pub(crate) fill: Vec, + /// The text rendering mode (`Tr`); 3 is invisible text, as in an OCR layer over a scan. + pub(crate) render_mode: i64, + /// The stroke colour operators, the line width and the ExtGStates selected (see [`Ts`]): + /// graphics state the text was painted with that [`TextState::ops`] doesn't restate. + pub(super) stroke: Vec, + pub(super) line_width: Option, + ext_gstate: Vec>, + /// The dash pattern and line cap in force (see [`Ts`]): compared, not restated. + pub(super) dash: Dash, + pub(super) line_cap: i64, +} + +impl TextState { + pub(super) fn of(ts: &Ts) -> Self { + TextState { + font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)), + char_spacing: ts.char_spacing, + word_spacing: ts.word_spacing, + scale: ts.scale, + leading: ts.leading, + rise: ts.rise, + fill: ts.fill.clone(), + render_mode: ts.render_mode, + stroke: ts.stroke.clone(), + line_width: ts.line_width, + ext_gstate: ts.ext_gstate.clone(), + dash: ts.dash.clone(), + line_cap: ts.line_cap, + } + } + + /// Operators that change how text is painted from `current` (the state where they run) to + /// this one: the ExtGStates, stroke colour, line width and a visible rendering mode. A + /// rewritten paragraph is drawn before its text object, so whatever that object set before + /// its first show (`/GS1 gs`, `1 Tr 1 0 0 RG 2 w` inside `BT`, #3508/#3509) must be stated + /// again. Empty when nothing differs. + /// + /// Only fill, stroke and fill-then-stroke (`Tr` 0–2) are restated: invisible text is revealed + /// by [`reveal_ops`] and clipping text is refused, so neither is ever drawn this way. + /// + /// A `gs` has no inverse, so a selection `current` has and this state doesn't stays in force: + /// run these inside a `q`/`Q` that starts from `current`, one per state drawn. + pub(crate) fn paint_ops(&self, current: &TextState) -> Vec { + let n = pdfcraft_content::num; + let mut v = Vec::new(); + // The selections both share stay in force; the ones after them are selected again in order. + let shared = self.ext_gstate.iter().zip(¤t.ext_gstate).take_while(|(a, b)| a == b).count(); + for name in self.ext_gstate.iter().skip(shared) { + v.push(Op::new("gs", vec![Object::Name(name.clone())])); + } + // A reselected ExtGState may set `/LW`, so the width (already the effective one) follows it. + let reselected = !v.is_empty(); + if self.stroke != current.stroke { + if self.stroke.is_empty() { + // The initial stroke colour: black in DeviceGray. + v.push(Op::new("G", vec![n(0.0)])); + } else { + v.extend(self.stroke.iter().cloned()); + } + } + let width = self.line_width.unwrap_or(1.0); + if reselected || width != current.line_width.unwrap_or(1.0) { + v.push(Op::new("w", vec![n(width)])); + } + if (0..=2).contains(&self.render_mode) && self.render_mode != current.render_mode { + v.push(Op::new("Tr", vec![Object::Int(self.render_mode)])); + } + v + } + + /// Operators that set this state (inside a text object). + pub(crate) fn ops(&self) -> Vec { + let n = pdfcraft_content::num; + let mut v = Vec::new(); + if let Some((f, size)) = &self.font { + v.push(Op::new("Tf", vec![Object::name(f), n(*size)])); + } + v.push(Op::new("Tc", vec![n(self.char_spacing)])); + v.push(Op::new("Tw", vec![n(self.word_spacing)])); + v.push(Op::new("Tz", vec![n(self.scale * 100.0)])); + v.push(Op::new("TL", vec![n(self.leading)])); + v.push(Op::new("Ts", vec![n(self.rise)])); + v.extend(self.fill.iter().cloned()); + v + } +} + +/// What a run is drawn in beyond its text state: the optional content groups of the `/OC` +/// marked-content sequences around it, and the effective graphics state parameters (`gs`) in +/// force. A rewrite writes a paragraph in its first run's place, so runs that differ here are +/// never joined: one in a hidden layer or at another opacity would lose it. +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) struct Paint { + layers: Vec>, + gs: Gs, +} + +/// The transparency parameters of the graphics state: what the `gs` operators have set, resolved, +/// so a repeated `gs` or two names with the same settings leave it as it was. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Gs { + /// `ca`, the fill opacity. + fill_alpha: f64, + /// `CA`, the stroke opacity. + stroke_alpha: f64, + /// `BM`, the blend mode (`Compatible` is `Normal`). + blend: Vec, + /// `SMask`, when it is not `None`: the reference of an indirect soft mask, else the dictionary. + soft_mask: Option, +} + +impl Default for Gs { + fn default() -> Self { + Gs { fill_alpha: 1.0, stroke_alpha: 1.0, blend: b"Normal".to_vec(), soft_mask: None } + } +} + +impl Gs { + /// Applies the parameters a graphics state parameter dictionary sets; the others stay. + fn apply(&mut self, doc: &Document, params: &Dict) { + let num = |key: &[u8]| params.get(key).and_then(|o| doc.resolve(o).as_f64()).filter(|v| v.is_finite()); + if let Some(v) = num(b"ca") { + self.fill_alpha = v; + } + if let Some(v) = num(b"CA") { + self.stroke_alpha = v; + } + if let Some(bm) = params.get(b"BM").map(|o| doc.resolve(o)) { + let first = bm + .as_name() + .map(<[u8]>::to_vec) + .or_else(|| bm.as_array().and_then(|a| a.iter().find_map(|o| doc.resolve(o).as_name().map(<[u8]>::to_vec)))); + if let Some(name) = first { + self.blend = if name == b"Compatible" { b"Normal".to_vec() } else { name }; + } + } + if let Some(mask) = params.get(b"SMask") { + self.soft_mask = match (mask.as_ref(), &*doc.resolve(mask)) { + (_, Object::Name(_)) => None, + (Some(r), _) => Some(Object::Ref(r)), + (None, Object::Dict(d)) => Some(Object::Dict(d.clone())), + (None, _) => None, + }; + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Origin { + pub(crate) tm: [f64; 6], + /// The text line matrix there (where the next line's Td/T* starts from). + pub(crate) tlm: [f64; 6], + /// Text-space units → user space (the scale of the text rendering matrix's y axis). + pub(crate) k: f64, + /// The CTM there (current space → page space), for moving the paragraph in page space. + pub(crate) ctm: [f64; 6], + pub(crate) baseline: f64, + pub(crate) x: f64, + pub(crate) state: TextState, + pub(crate) bt_op: usize, + pub(crate) bt_state: TextState, +} + +/// The unit x axis of a text rendering matrix in user space (`(1, 0)` when it is degenerate). +pub(super) fn direction(m: &Matrix) -> (f64, f64) { + let (x, y) = (m.0[0], m.0[1]); + let length = x.hypot(y); + if length.is_finite() && length > 1e-9 { (x / length, y / length) } else { (1.0, 0.0) } +} + +/// User-space point `p` as `(along, across)` a baseline running in unit direction `dir`. Upright +/// text (`dir` = `(1, 0)`) keeps its coordinates, so lines and rows of turned text are found the +/// same way (#1443). +pub(super) fn to_frame(dir: (f64, f64), p: (f64, f64)) -> (f64, f64) { + (p.0 * dir.0 + p.1 * dir.1, p.1 * dir.0 - p.0 * dir.1) +} + +/// The box covering both `[x0, y0, x1, y1]` boxes. +pub(crate) fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] +} + +#[derive(Clone)] +pub(super) struct Ts { + pub(super) ctm: Matrix, + /// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`). + pub(super) fill: Vec, + pub(super) font: Option<(Vec, Rc)>, + /// The Japanese or Hebrew fallback face the font subsets, when the font is one (see [`is_type3_fallback`]). + pub(super) face: Option, + pub(super) size: f64, + pub(super) char_spacing: f64, + pub(super) word_spacing: f64, + pub(super) scale: f64, + pub(super) leading: f64, + pub(super) rise: f64, + pub(super) render_mode: i64, + /// The transparency parameters the `gs` operators have set so far (see [`Paint`]). + pub(super) gs: Gs, + /// The font's vertical advance per unit of font size (`/DW2`'s w1, normally −1) when its + /// CMap writes vertically (`WMode 1`, §9.7.4.3): each glyph then moves the pen down, not right. + pub(super) vertical: Option, + /// Why the font's writing mode couldn't be read (a broken or cyclic CMap). Its text is then + /// listed as horizontal, but the lines in it are refused for editing. + pub(super) unreadable: Option>, + /// The operators that set the current stroke colour (`G`, `RG`, `K`, or `CS` + `SC`/`SCN`). + pub(super) stroke: Vec, + /// The line width a `w` or an ExtGState's `/LW` set (`None`: the initial 1.0). + pub(super) line_width: Option, + /// The ExtGState resources selected with `gs`, oldest first (see [`select_gs`]). + pub(super) ext_gstate: Vec>, + /// The dash pattern a `d` or an ExtGState's `/D` set (initially solid: `[] 0`). + pub(super) dash: Dash, + /// The line cap a `J` or an ExtGState's `/LC` set (initially 0, butt). + pub(super) line_cap: i64, +} + +/// A dash pattern (ISO 32000-2 §8.4.3.6): the dash array and the phase. +type Dash = (Vec, f64); + +/// The most dash array entries a pattern keeps; a longer one is ignored, as is a malformed one. +const MAX_DASH: usize = 32; + +/// The dash pattern `array phase`, as `d` takes it and an ExtGState's `/D` holds it. +fn dash_pattern(doc: &Document, array: Option<&Object>, phase: Option<&Object>) -> Option { + let array = doc.resolve(array?); + let items = array.as_array().filter(|a| a.len() <= MAX_DASH)?; + let finite = |o: &Object| doc.resolve(o).as_f64().filter(|v| v.is_finite()); + Some((items.iter().map(finite).collect::>>()?, finite(phase?)?)) +} + +/// A line cap style, as `J` takes it and an ExtGState's `/LC` holds it: 0, 1 or 2. +fn line_cap(doc: &Document, cap: Option<&Object>) -> Option { + doc.resolve(cap?).as_int().filter(|c| (0..=2).contains(c)) +} + +/// The most `gs` selections a state remembers. Repeating a selection moves it to the end, so +/// this bounds the distinct ExtGStates in force, not the operators a hostile stream repeats. +const MAX_GS: usize = 32; + +/// Record `gs /name` in `list`. A `gs` sets every parameter its dictionary names to an absolute +/// value, so a later selection of the same dictionary overrides the earlier one completely: the +/// earlier entry goes, which keeps replaying the list equivalent to replaying the stream. +fn select_gs(list: &mut Vec>, name: &[u8]) { + list.retain(|n| n != name); + if list.len() >= MAX_GS { + list.remove(0); + } + list.push(name.to_vec()); +} + +/// The text-showing operators of one stream with their text, font, box and baseline. +pub(super) struct Shown { + pub(super) op: usize, + pub(super) extra_ops: Vec, + /// Encoded glyphs, not bytes or Unicode characters (a ligature can represent several). + pub(super) glyph_count: usize, + /// Advance in text space, including spacing and TJ adjustments. + pub(super) advance: f64, + pub(super) tracking: f64, + /// The text matrix where it starts (text space), and the state there and at its `BT`. + pub(super) tm: Matrix, + pub(super) tlm: Matrix, + pub(super) state: Ts, + pub(super) bt_op: usize, + pub(super) bt_state: Ts, + pub(super) text: String, + pub(super) rect: [f64; 4], + /// Its box along and across its baseline (see [`TextLine::frame`]). + pub(super) frame: [f64; 4], + /// The boxes of its words (see [`TextLine::ink`]). + pub(super) ink: Vec<[f64; 4]>, + pub(super) baseline: f64, + pub(super) start_x: f64, + end_x: f64, + end_y: f64, + pub(super) bt: usize, + pub(super) font: Vec, + pub(super) base_font: String, + pub(super) face: Option, + pub(super) size: f64, + pub(super) bold: bool, + pub(super) italic: bool, + pub(super) visual_order: bool, + pub(super) decodable: bool, + pub(super) scope: Option, + pub(super) paint: Paint, + pub(super) logical_order: bool, + pub(super) actual_text: bool, + /// Set when a `/PCSoft` or `/PCHard` marker was seen since the previous text line. + pub(super) soft_break: bool, + pub(super) hard_break: bool, + /// The `/N` a `/PCSoft` marker carried: how many separator characters the wrap consumed (0 + /// for a Chinese/Japanese word split). Meaningless unless `soft_break` is set; 1 for a + /// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer), + /// matching the single space a rejoin always used before #2529. + pub(super) soft_gap: usize, +} + +impl Shown { + /// The unit direction of its baseline, where it starts and ends along it, and the baseline's + /// offset across it (for upright text: the user-space x, x and y). A vertical run goes along + /// its column instead, the way the pen moves: for upright text down, with the column's x + /// across. + fn span(&self) -> ((f64, f64), f64, f64, f64) { + let m = self.tm.then(&self.state.ctm); + let dir = match self.state.vertical { + Some(w1) => direction(&Matrix([m.0[2] * w1.signum(), m.0[3] * w1.signum(), 0.0, 0.0, 0.0, 0.0])), + None => direction(&m), + }; + let (start, across) = to_frame(dir, (self.start_x, self.baseline)); + (dir, start, to_frame(dir, (self.end_x, self.end_y)).0, across) + } +} + +/// Where the previous run of a line ended: its text object, baseline direction, offset across +/// the baseline, end along it and size. +#[derive(Clone, Copy)] +pub(super) struct Pen { + pub(super) bt: usize, + dir: (f64, f64), + across: f64, + end: f64, + pub(super) size: f64, + vertical: bool, +} + +impl Pen { + pub(super) fn after(s: &Shown) -> Self { + let (dir, _, end, across) = s.span(); + Pen { bt: s.bt, dir, across, end, size: s.size.max(1.0), vertical: s.state.vertical.is_some() } + } + + /// Whether `s` could continue the same line on geometry alone: on the same baseline, starting + /// at most a word gap (`reach` sizes) after it. Also the gap along the baseline. + pub(super) fn continues(&self, s: &Shown, reach: f64) -> (bool, f64) { + let (dir, start, _, across) = s.span(); + let gap = start - self.end; + let same_dir = (dir.0 - self.dir.0).abs() < 1e-3 && (dir.1 - self.dir.1).abs() < 1e-3; + // A column continues a column (along which the pen moves), a baseline a baseline. + let same_dir = same_dir && self.vertical == s.state.vertical.is_some(); + (same_dir && (across - self.across).abs() < self.size * 0.3 && gap > -self.size && gap < self.size * reach, gap) + } +} + +/// Use the lower third of letter gaps, not word gaps. A pair of isolated labels is not +/// evidence of tracking; unadjusted neighbours inside a string contribute zero too. +/// The tracking a run of glyphs was set with: the lower third of its gaps, when that is at +/// most half an em (`limit`). A wider "tracking" is word or cell spacing between one-glyph +/// words (`a b c`, a column of digits or Y/N ticks), which must keep its spaces. +fn typical_tracking(gaps: &mut [f64], limit: f64) -> f64 { + if gaps.len() < 3 { + return 0.0; + } + let at = gaps.len() / 3; + gaps.select_nth_unstable_by(at, f64::total_cmp); + gaps.get(at).copied().filter(|n| n.is_finite() && *n <= limit).unwrap_or(0.0).max(0.0) +} + +fn tj_tracking(pieces: &[Object], metrics: &Metrics, size: f64, scale: f64) -> f64 { + let mut gaps = Vec::new(); + let mut previous = false; + let mut gap = 0.0; + for piece in pieces { + if let Object::String(s) = piece { + for (code, len) in metrics.codes(&s.bytes) { + let blank = metrics.is_space(code, len) || metrics.text_of(code).is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + if previous && !blank { + gaps.push(gap); + } + previous = !blank; + gap = 0.0; + } + } else if let Some(n) = piece.as_f64() { + gap += -n / 1000.0 * size * scale; + } + } + typical_tracking(&mut gaps, size * scale.abs() * 0.5) +} + +fn positioned_tracking(shown: &mut [Shown]) { + let mut start = 0; + while start < shown.len() { + let mut end = start.saturating_add(1); + while let (Some(prev), Some(next)) = (shown.get(end.saturating_sub(1)), shown.get(end)) { + if prev.glyph_count != 1 + || next.glyph_count != 1 + || prev.font != next.font + || prev.scope != next.scope + || (prev.size - next.size).abs() > 0.01 + || prev.state.char_spacing != next.state.char_spacing + || prev.state.scale != next.state.scale + || !Pen::after(prev).continues(next, 1.5).0 + { + break; + } + end = end.saturating_add(1); + } + if let Some(run) = shown.get_mut(start..end) { + let mut gaps: Vec = run + .windows(2) + .filter_map(|pair| { + let [prev, next] = pair else { return None }; + (!prev.text.trim().is_empty() && !next.text.trim().is_empty()).then(|| Pen::after(prev).continues(next, 0.0).1) + }) + .collect(); + let limit = run.first().map_or(0.0, |s| s.size * s.state.scale.abs() * 0.5); + let tracking = typical_tracking(&mut gaps, limit); + for s in run.iter_mut().skip(1) { + s.tracking = tracking; + } + } + start = end; + } +} + +/// The graphics state carried from one of a page's content streams to the next: the streams +/// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first +/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it. +pub(super) struct Carry { + pub(super) ts: Ts, + /// Each saved state with the text and text line matrices: inside a text object `q`/`Q` + /// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's + /// errata, issue 368), as the renderer does. + pub(super) stack: Vec<(Ts, Matrix, Matrix)>, + pub(super) marked: Vec>, + /// For each open marked-content sequence, its optional content group when it is `/OC`. + pub(super) layers: Vec>>, + /// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]). + pub(super) text_left: usize, +} + +/// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is +/// copied for every glyph showing its code, so a small page can otherwise expand into gigabytes; +/// real pages hold far less (a whole novel is about 1 MB). +const MAX_PAGE_TEXT: usize = 16 << 20; + +impl Carry { + pub(super) fn new() -> Self { + let ts = Ts { + ctm: Matrix::IDENTITY, + fill: Vec::new(), + font: None, + face: None, + size: 0.0, + char_spacing: 0.0, + word_spacing: 0.0, + scale: 1.0, + leading: 0.0, + rise: 0.0, + render_mode: 0, + gs: Gs::default(), + vertical: None, + unreadable: None, + stroke: Vec::new(), + line_width: None, + ext_gstate: Vec::new(), + dash: (Vec::new(), 0.0), + line_cap: 0, + }; + Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT } + } +} + +/// A font resource's metrics, fallback face and vertical advance (see [`Ts`]), read once per page. +/// The advance is an error message when the font's writing mode can't be read. +type CachedFont = (Rc, Option, Result, String>); + +/// A `Do` met while interpreting: its operator index, XObject name and the state there. +type Invocation = (usize, Vec, Ts); + +#[derive(Default)] +struct MarkedContent { + regions: Vec>, + layers: Vec>>, + actual_text: Vec>, +} + +impl MarkedContent { + fn begin(&mut self, doc: &Document, op: &Op, index: usize, logical_text: Option<&Dict>, shown_count: usize) { + // Marked text paragraphs/headings are authoritative boundaries. Ignore artifacts and + // inner spans, which inherit their enclosing region. + let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); + self.regions.push(if region { Some(index) } else { self.regions.last().copied().flatten() }); + // A layer named in the resources is told apart by its name, an inline one by where it opens. + let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); + self.layers + .push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec))); + // The property list is inline or named in the resources' /Properties (§14.6.2). + let list = logical_text.and_then(|properties| match op.operands.get(1) { + Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)), + other => other.and_then(Object::as_dict).cloned(), + }); + let logical = list.as_ref().and_then(|dict| dict.get(b"ActualText")).and_then(|value| match value { + Object::String(value) if value.bytes.len() <= 4_000_000 => Some(value.to_text()), + _ => None, + }); + self.actual_text.push(logical.map(|text| (shown_count, index, text))); + } + + fn end(&mut self, index: usize, shown: &mut Vec) { + self.regions.pop(); + self.layers.pop(); + if let Some(Some((start, open, logical))) = self.actual_text.pop() { + let tail = shown.split_off(start.min(shown.len())); + let mut items = tail.into_iter(); + if let Some(mut first) = items.next() { + for item in items { + first.glyph_count = first.glyph_count.saturating_add(item.glyph_count); + first.extra_ops.push(item.op); + first.extra_ops.extend(item.extra_ops); + first.rect = [ + first.rect[0].min(item.rect[0]), + first.rect[1].min(item.rect[1]), + first.rect[2].max(item.rect[2]), + first.rect[3].max(item.rect[3]), + ]; + first.frame = union(first.frame, item.frame); + first.ink.extend(item.ink); + } + first.extra_ops.extend([open, index]); + // Only right-to-left ActualText is already in typing order and must stay out of + // bidi recovery. Word and Chrome also wrap Latin ligatures mid-word. + first.logical_order = logical.chars().any(crate::bidi::is_rtl); + first.actual_text = true; + first.text = logical; + first.start_x = first.rect[0]; + first.end_x = first.rect[2]; + first.end_y = first.baseline; + first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); + shown.push(first); + } + } + } +} + +struct TextRunState<'a> { + ts: &'a mut Ts, + tm: &'a mut Matrix, + tlm: &'a mut Matrix, + at_bt: &'a (usize, Ts), + bt: usize, + marked: &'a MarkedContent, + carry: &'a mut Carry, + out: &'a mut Vec, + soft_break: bool, + hard_break: bool, + soft_gap: usize, +} + +fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> { + let ts = &mut *state.ts; + let tm = &mut *state.tm; + let tlm = &mut *state.tlm; + let at_bt = state.at_bt; + let bt = state.bt; + let marked = state.marked; + let carry = &mut *state.carry; + let out = &mut *state.out; + if matches!(op.op.as_slice(), b"'" | b"\"") { + if op.is("\"") { + ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing); + ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing); + } + *tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(tlm); + *tm = *tlm; + } + let Some((name, m)) = ts.font.clone() else { return Ok(()) }; + // Pieces: strings, and TJ number adjustments. + let pieces: Vec = match op.op.as_slice() { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569) -- not the first, which a reader counting operands + // from the front of a surplus list would otherwise take. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Tc is already included in the pen advance; TJ may encode tracking instead. + let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; + let trm0 = tm.then(&ts.ctm); + let start = trm0.apply(0.0, ts.rise); + let mut text = String::new(); + let mut decodable = true; + let mut x_text = 0.0; + // Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred + // on it horizontally; `half` is half the widest glyph. + let vertical = ts.vertical; + let (mut y_text, mut half) = (0.0, 0.0_f64); + // Where each word's glyphs start and end along the run (text space), and whether + // the next glyph continues the last word. + let mut words: Vec<(f64, f64)> = Vec::new(); + let mut in_word = false; + let mut glyph_count = 0_usize; + for p in &pieces { + match p { + Object::String(s) => { + for (code, len) in m.codes(&s.bytes) { + glyph_count = glyph_count.saturating_add(1); + let shown = m.text_of(code); + let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + match shown { + Some(t) => { + carry.text_left = carry + .text_left + .checked_sub(t.len()) + .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; + text.push_str(&t); + } + None => decodable = false, + } + let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + if let Some(w1) = vertical { + half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0); + if !blank { + let ink_end = y_text + w1 * ts.size; + let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + y_text += w1 * ts.size + spacing; + continue; + } + if !blank { + let ink_end = x_text + m.width(code) * ts.size * ts.scale; + // A negative font size or `Tz` runs the glyphs backwards: + // the word is the union of its glyphs, whichever way they go. + let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + x_text += w * ts.scale; + } + } + other => { + if let Some(n) = other.as_f64() { + if vertical.is_some() { + // Down by n/1000 em, not scaled by `Tz`. + let dy = -n / 1000.0 * ts.size; + if -dy > ts.size * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + if dy.abs() > ts.size.abs() * 0.2 { + in_word = false; + } + y_text += dy; + continue; + } + let dx = -n / 1000.0 * ts.size * ts.scale; + // A large gap inside TJ reads as a space. + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + // Tracking and kerning keep a word together; a larger jump + // ends it (whatever the sign of the size or `Tz`). + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { + in_word = false; + } + x_text += dx; + } + } + } + } + *tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(tm); + let end = tm.then(&ts.ctm).apply(0.0, ts.rise); + // The box from `a` to `b` along the run: from descent to ascent across it, or for + // vertical text, the widest glyph's width centred on the pen. + let span = |a: f64, b: f64| match vertical { + Some(_) => [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)], + None => [ + trm0.apply(a, ts.rise + m.descent * ts.size), + trm0.apply(b, ts.rise + m.descent * ts.size), + trm0.apply(a, ts.rise + m.ascent * ts.size), + trm0.apply(b, ts.rise + m.ascent * ts.size), + ], + }; + let corners = span(0.0, if vertical.is_some() { y_text } else { x_text }); + let rect = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + // The same corners along and across the baseline: the run's own box, not its + // upright bounding box turned (which is wider by the box's height on a slant). + let dir = direction(&trm0); + let frame = corners + .iter() + .map(|&p| to_frame(dir, p)) + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + let ink = words + .iter() + .map(|&(a, b)| { + span(a, b).iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) + }) + .collect(); + let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; + // A numeric-only TJ adjusts the current text matrix but paints no glyphs. + // It must not create a phantom editable line or enlarge its neighbours' boxes. + if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { + return Ok(()); + } + out.push(Shown { + op: i, + extra_ops: Vec::new(), + glyph_count, + // Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the + // preview divides by size × `Tz`, so `Tz` is put back here. + advance: if vertical.is_some() { y_text * ts.scale } else { x_text }, + tracking: 0.0, + tm: Matrix([ + tm.0[0], + tm.0[1], + tm.0[2], + tm.0[3], + tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2], + tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3], + ]), + state: ts.clone(), + tlm: *tlm, + bt_op: at_bt.0, + bt_state: at_bt.1.clone(), + text, + rect, + frame, + ink, + baseline: start.1, + start_x: start.0, + end_x: end.0, + end_y: end.1, + bt, + font: name, + base_font: m.base_font.clone(), + face: ts.face.clone(), + size: if size_user > 0.0 { size_user } else { ts.size }, + bold: m.bold, + italic: m.italic, + visual_order: m.visual_order, + decodable, + scope: marked.regions.last().copied().flatten(), + paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, + logical_order: false, + actual_text: false, + soft_break: state.soft_break, + hard_break: state.hard_break, + soft_gap: state.soft_gap, + }); + Ok(()) +} + +pub(super) fn interpret( + doc: &Document, + ops: &[Op], + resources: &Dict, + cache: &mut HashMap, CachedFont>, + carry: &mut Carry, + mut invoked: Option<&mut Vec>, + // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`. + logical_text: Option<&Dict>, +) -> Result, EditError> { + let fonts_res = resources.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + let states = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + let mut out: Vec = Vec::new(); + let mut ts = carry.ts.clone(); + let mut at_bt = (0usize, ts.clone()); + let mut stack: Vec<(Ts, Matrix, Matrix)> = std::mem::take(&mut carry.stack); + let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); + let mut bt = 0usize; + let mut marked = + MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() }; + // A rewrite names a wrap (`/PCSoft`) or a break it kept (`/PCHard`) in an empty marker + // before the next line. Neither name is a paragraph region, so it does not change scope. + let mut soft_break = false; + let mut hard_break = false; + // `/PCSoft`'s `/N` property: how many separator characters the wrap consumed. A `/PCSoft` + // with no properties dict (a file saved before this was tracked, or by another writer) reads + // as 1, the single space a rejoin always used before #2529. + let mut soft_gap = 1usize; + for (i, op) in ops.iter().enumerate() { + match op.op.as_slice() { + // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing + // sequence (#3573). Both stacks stay bounded by the number of operators. + b"BMC" | b"BDC" => { + if op.name(0) == Some(b"PCSoft") { + soft_break = true; + hard_break = false; + soft_gap = op + .operands + .get(1) + .and_then(Object::as_dict) + .and_then(|d| d.get(b"N")) + .and_then(Object::as_int) + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(1); + // The marker is untrusted and later expands into spaces during reflow. + // Charge it to the same page-wide budget as glyph text, so many individually + // small markers cannot multiply the allocation past the limit either. + carry.text_left = + carry.text_left.checked_sub(soft_gap).ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; + } else if op.name(0) == Some(b"PCHard") { + hard_break = true; + soft_break = false; + } + marked.begin(doc, op, i, logical_text, out.len()); + } + b"EMC" => { + marked.end(i, &mut out); + } + b"q" => stack.push((ts.clone(), tm, tlm)), + b"Q" => { + if let Some(saved) = stack.pop() { + (ts, tm, tlm) = saved; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + ts.ctm = Matrix(m).then(&ts.ctm); + } + } + b"BT" => { + tm = Matrix::IDENTITY; + tlm = Matrix::IDENTITY; + bt += 1; + at_bt = (i, ts.clone()); + } + b"g" | b"rg" | b"k" | b"cs" => ts.fill = vec![op.clone()], + b"sc" | b"scn" => { + ts.fill.retain(|o| o.is("cs")); + ts.fill.push(op.clone()); + } + b"G" | b"RG" | b"K" | b"CS" => ts.stroke = vec![op.clone()], + b"SC" | b"SCN" => { + ts.stroke.retain(|o| o.is("CS")); + ts.stroke.push(op.clone()); + } + b"w" => ts.line_width = op.num(0).filter(|w| w.is_finite()).or(ts.line_width), + b"d" => ts.dash = dash_pattern(doc, op.operands.first(), op.operands.get(1)).unwrap_or_else(|| ts.dash.clone()), + b"J" => ts.line_cap = line_cap(doc, op.operands.first()).unwrap_or(ts.line_cap), + b"Tf" => { + ts.size = op.num(1).unwrap_or(ts.size); + if let Some(name) = op.name(0) { + let (m, face, vertical) = cache.entry(name.to_vec()).or_insert_with(|| { + let dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); + let metrics = dict.as_ref().map_or_else(Metrics::fallback, |d| Metrics::from_dict(doc, d)); + let vertical = dict.as_ref().map_or(Ok(None), |d| vertical_advance(doc, d).map_err(|e| e.to_string())); + let face = dict.filter(is_type3_fallback).map(|_| metrics.base_font.clone()); + (Rc::new(metrics), face, vertical) + }); + ts.font = Some((name.to_vec(), m.clone())); + ts.face = face.clone(); + // Which way the glyphs advance is unknown (§9.7.4.3): list the font's text as + // horizontal and record why, so editing its lines is refused (not the page). + match vertical { + Ok(v) => (ts.vertical, ts.unreadable) = (*v, None), + Err(e) => (ts.vertical, ts.unreadable) = (None, Some(Rc::from(e.as_str()))), + } + } + } + b"Tc" => ts.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => ts.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => ts.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => ts.leading = op.num(0).unwrap_or(0.0), + b"Ts" => ts.rise = op.num(0).unwrap_or(0.0), + b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1), + b"gs" => { + if let Some(name) = op.name(0) { + select_gs(&mut ts.ext_gstate, name); + } + let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p)); + if let Some(params) = params.as_deref().and_then(Object::as_dict) { + ts.gs.apply(doc, params); + // `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5). + let lw = params.get(b"LW").and_then(|w| doc.resolve(w).as_f64()).filter(|w| w.is_finite()); + ts.line_width = lw.or(ts.line_width); + let dash = params.get(b"D").map(|d| doc.resolve(d)); + let dash = dash.as_deref().and_then(Object::as_array); + if let Some(d) = dash.and_then(|d| dash_pattern(doc, d.first(), d.get(1))) { + ts.dash = d; + } + ts.line_cap = line_cap(doc, params.get(b"LC")).unwrap_or(ts.line_cap); + } + } + b"Do" => { + if let (Some(list), Some(name)) = (invoked.as_deref_mut(), op.name(0)) { + list.push((i, name.to_vec(), ts.clone())); + } + } + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.is("TD") { + ts.leading = -y; + } + tlm = Matrix([1.0, 0.0, 0.0, 1.0, x, y]).then(&tlm); + tm = tlm; + } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + tlm = Matrix(m); + tm = tlm; + } + } + b"T*" => { + tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm); + tm = tlm; + } + b"Tj" | b"TJ" | b"'" | b"\"" => { + let before = out.len(); + let mut state = TextRunState { + ts: &mut ts, + tm: &mut tm, + tlm: &mut tlm, + at_bt: &at_bt, + bt, + marked: &marked, + carry, + out: &mut out, + soft_break, + hard_break, + soft_gap, + }; + show_text(op, i, &mut state)?; + // A numeric-only TJ adjustment shows no glyphs and pushes nothing: the break + // belongs to whichever text-showing operator comes next. + if out.len() > before { + soft_break = false; + hard_break = false; + soft_gap = 1; + } + } + _ => {} + } + } + carry.ts = ts; + carry.stack = stack; + carry.marked = marked.regions; + carry.layers = marked.layers; + positioned_tracking(&mut out); + Ok(out) +} diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs new file mode 100644 index 0000000000..f811f34ea6 --- /dev/null +++ b/crates/edit/src/text/lines.rs @@ -0,0 +1,591 @@ +use super::fallback::{same_font, same_paint}; +use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; +use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; + +impl TextLine { + /// The baseline's height in user space. + pub fn origin_baseline(&self) -> f64 { + self.origin.baseline + } + + /// Paint position of the line's first operator: `(content-stream index, operator index in + /// the page's joined stream)`. A line joins runs painted at different times, so anything + /// painted after this position may lie on top of part of it. + /// (Lines from `text_lines` never come from form XObjects, so `stream` is always a + /// page content-stream index and `ops` are indices into the joined operator list.) + pub fn first_paint_pos(&self) -> (usize, usize) { + (self.stream, self.ops.iter().min().copied().unwrap_or(0)) + } + + /// The runs of text the line joins, in content order: each run's text as its glyphs show it, + /// the boxes of its words (a part of `ink`), and whether joining put a space between it and + /// the run before (`text` has one there; runs closer together join with none, so one word + /// can span runs). None when the line's text was reordered for reading (right-to-left or + /// visual-order Indic text): then only the line's text as a whole says what it reads. + pub fn runs(&self) -> impl Iterator { + let mut start = 0; + self.runs.iter().map(move |(text, end, spaced)| { + let ink = self.ink.get(start..*end).unwrap_or_default(); + start = *end; + (text.as_str(), ink, *spaced) + }) + } + + pub(crate) fn rotation(&self) -> f64 { + let m = Matrix(self.origin.tm).then(&Matrix(self.origin.ctm)).0; + m[1].atan2(m[0]).to_degrees() + } + + /// The unit direction its baseline runs in, in user space. + pub(super) fn direction(&self) -> (f64, f64) { + direction(&Matrix(self.origin.tm).then(&Matrix(self.origin.ctm))) + } + + /// Whether it reads left to right along the page's x axis (most text; a page drawn + /// turned a quarter, as landscape pages often are, isn't). + pub(crate) fn upright(&self) -> bool { + let (x, y) = self.direction(); + (x - 1.0).abs() < 1e-3 && y.abs() < 1e-3 + } + + /// Where it starts as `(along, across)` its baseline: user space turned to the text's direction. + pub(crate) fn frame_origin(&self) -> (f64, f64) { + to_frame(self.direction(), (self.origin.x, self.origin.baseline)) + } + + /// Its box in the same frame, `[along0, across0, along1, across1]` (for upright text, `rect`). + pub(crate) fn frame_rect(&self) -> [f64; 4] { + self.frame + } +} + +/// The page's content streams as listed, each with its decoded data (empty when it doesn't +/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading +/// or rewriting a page from part of its content would lose the rest. +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> { + content_streams_within(doc, page, &mut DecodeBudget::page()) +} + +/// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on +/// spending. +pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result)>, EditError> { + let list: Vec = match page.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + let mut out = Vec::with_capacity(list.len()); + for o in list { + let data = match &*doc.resolve(&o) { + Object::Stream(s) => match budget.decode(s) { + Ok(data) => data, + Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))), + Err(_) => Vec::new(), + }, + _ => Vec::new(), + }; + out.push((o, data)); + } + Ok(out) +} + +/// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators +/// to insert before operator `i` of `ops` (parsed from the joined pieces) and whether to keep it. +/// Everything else is copied byte for byte. A page's streams are one stream in pieces, split +/// between any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and +/// its keyword start the next; removing or replacing it rewrites every piece it spans. Returns +/// each piece's new bytes. +pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> (Vec, bool)) -> Vec> { + let data = pieces.data(); + let mut edits = Vec::new(); + for (i, op) in ops.iter().enumerate() { + let (insert, keep) = edit(i); + if insert.is_empty() && keep { + continue; + } + let mut bytes = Vec::new(); + if !insert.is_empty() { + if op.span.start.checked_sub(1).and_then(|p| data.get(p)).is_some_and(|b| !b.is_ascii_whitespace()) { + bytes.push(b'\n'); + } + bytes.extend_from_slice(&serialize_ops(&insert)); + } + let end = if keep { op.span.start } else { op.span.end }; + edits.push((op.span.start..end, bytes)); + } + pieces.splice(edits) +} + +/// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams +/// (keeping their dictionaries), the others stay as they were. +pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec)], new: Vec>) -> Object { + let mut contents: Vec = Vec::with_capacity(streams.len()); + for ((obj, old), data) in streams.iter().zip(new) { + if *old == data { + contents.push(obj.clone()); + continue; + } + let mut dict = match &*doc.resolve(obj) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + contents.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &data))))); + } + match <[Object; 1]>::try_from(contents) { + Ok([one]) => one, + Err(many) => Object::Array(many), + } +} + +pub(crate) fn page_dict(doc: &Document, page: usize) -> Result { + pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page)) +} + +fn fill_color(fill: &[Op]) -> [f64; 3] { + let Some(op) = fill.iter().rev().find(|op| matches!(op.op.as_slice(), b"g" | b"rg" | b"k" | b"sc" | b"scn")) else { + return [0.0, 0.0, 0.0]; + }; + let space = match op.op.as_slice() { + b"sc" | b"scn" => match op.operands.len() { + 1 => b"g".as_slice(), + 3 => b"rg".as_slice(), + 4 => b"k".as_slice(), + _ => return [0.0; 3], + }, + other => other, + }; + match space { + b"g" => { + let v = op.operands.first().and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0); + [v, v, v] + } + b"rg" => [ + op.operands.first().and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0), + op.operands.get(1).and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0), + op.operands.get(2).and_then(Object::as_f64).unwrap_or(0.0).clamp(0.0, 1.0), + ], + b"k" => { + let c = op.operands.first().and_then(Object::as_f64).unwrap_or(0.0); + let m = op.operands.get(1).and_then(Object::as_f64).unwrap_or(0.0); + let y = op.operands.get(2).and_then(Object::as_f64).unwrap_or(0.0); + let k = op.operands.get(3).and_then(Object::as_f64).unwrap_or(0.0); + [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] + } + _ => [0.0, 0.0, 0.0], + } +} + +/// The lines of text on a page (0-based), in content order. +pub fn text_lines(doc: &Document, page: usize) -> Result, EditError> { + text_lines_in(doc, &page_dict(doc, page)?, true) +} + +/// The lines `page`'s glyphs show, in content order, without `/ActualText` replacements: what the +/// page draws, not what its marked-content metadata says the glyphs mean. For checks that must +/// not trust that metadata (the redaction audit). Takes the page itself, so a caller going +/// through every page doesn't walk the page tree again for each. +pub fn glyph_text_lines(doc: &Document, page: &pdfcraft_model::Page) -> Result, EditError> { + text_lines_in(doc, page, false) +} + +/// [`text_lines`] of `p`; with `actual_text`, `/ActualText` replaces the glyphs it spans. +fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> Result, EditError> { + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut cache = HashMap::new(); + let mut lines: Vec = Vec::new(); + let mut carry = Carry::new(); + // The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them + // joined, so an operator whose operands end one piece still has them. + let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::>()); + let ops = pieces.parse().ops; + let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); + let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, actual_text.then_some(&properties))?; + let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended + for mut s in shown { + // A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a + // separate logical line. Put its letters into visual order before joining the + // surrounding glyphs; the completed line is recovered to typing order below. + // Multi-glyph and mixed-direction replacements keep their authoritative order. + if s.logical_order && s.glyph_count == 1 && s.text.chars().all(crate::bidi::is_rtl) { + s.text = crate::bidi::to_visual(&s.text); + s.logical_order = false; + } + // A line belongs to one stream piece. Within it, PDF producers often put every word + // in its own BT/ET: geometry and text state, not object boundaries, define a line. + let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0); + let joins = last.is_some_and(|(piece, pen)| { + let bt = pen.bt; + let (near, gap) = pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }); + piece == si + && lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) + && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) + && (s.text.trim().is_empty() + || lines.last().is_some_and(|l| { + (s.size - l.size).abs() < 0.01 + && s.bold == l.bold + && s.italic == l.italic + && fill_color(&s.state.fill) == l.color + && same_paint(l, &s.state) + })) + && near + // An OCR layer is one text object over the whole scan: its words continue a line + // only across a word space, not across a table cell or a tab stop. + && (s.state.render_mode != 3 || gap < pen.size * 0.6) + }); + if joins && let Some(l) = lines.last_mut() { + let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1); + let spaced = gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' '); + if spaced { + l.text.push(' '); + } + l.text.push_str(&s.text); + l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.frame = union(l.frame, s.frame); + l.ink.extend(s.ink.iter().copied()); + l.runs.push((s.text.clone(), l.ink.len(), spaced)); + l.ops.push(s.op); + l.ops.extend(s.extra_ops.iter().copied()); + l.decodable &= s.decodable; + l.visual_order |= s.visual_order; + l.actual_text |= s.actual_text; + l.clips |= s.state.render_mode >= 4; + if l.unreadable_mode.is_none() { + l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); + } + // Spaces may use a separate subset font; they do not change the visible style. + } else { + lines.push(TextLine { + text: s.text.clone(), + rect: s.rect, + frame: s.frame, + ink: s.ink.clone(), + font: String::from_utf8_lossy(&s.font).into_owned(), + base_font: s.base_font.clone(), + size: s.size, + bold: s.bold, + italic: s.italic, + color: fill_color(&s.state.fill), + decodable: s.decodable, + visual_order: s.visual_order, + face: s.face.clone(), + stream: si, + scope: s.scope, + paint: s.paint.clone(), + logical_order: s.logical_order, + actual_text: s.actual_text, + clips: s.state.render_mode >= 4, + unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), + runs: vec![(s.text.clone(), s.ink.len(), false)], + soft_break: s.soft_break, + hard_break: s.hard_break, + soft_gap: s.soft_gap, + ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), + origin: Origin { + tm: s.tm.0, + tlm: s.tlm.0, + k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(), + ctm: s.state.ctm.0, + baseline: s.baseline, + x: s.start_x, + state: TextState::of(&s.state), + bt_op: s.bt_op, + bt_state: TextState::of(&s.bt_state), + }, + }); + } + last = Some((si, Pen::after(&s))); + } + // Content streams draw right-to-left lines in visual order; the editor works in typing order. + for l in lines.iter_mut().filter(|l| !l.logical_order) { + // A visual-order Indic font draws pre-base vowel signs before their consonant. + let shown = l.text.clone(); + if l.visual_order && !l.actual_text { + l.text = crate::indic::to_logical(&l.text); + } + l.text = crate::bidi::to_logical(&l.text); + // The runs hold the text in drawing order, which no longer reads as the line does. + if l.text != shown { + l.runs.clear(); + } + } + // Lines of only spaces aren't editable text. + lines.retain(|l| !l.text.trim().is_empty()); + Ok(lines) +} + +/// Joining independent text objects is safe only when they use the same text and graphics +/// transforms and spacing. In particular, a nearby rotated label isn't part of this line. +fn compatible_word(line: &TextLine, shown: &Shown) -> bool { + let within = |a: &[f64], b: &[f64], tolerance: f64| a.iter().zip(b).all(|(a, b)| (a - b).abs() < tolerance); + let same = |a: &[f64], b: &[f64]| within(a, b, 1e-6); + let (a, b) = (&line.origin.state, TextState::of(&shown.state)); + // Spacing that differs by less than a thousandth of an em, the unit glyph widths are given in, + // can't be seen. Some producers still write it (`0.0001 Tc` on one word of a line). + let em = shown.state.size.abs() / 1000.0; + line.scope == shown.scope + && line.paint == shown.paint + && same(&line.origin.tm[..4], &shown.tm.0[..4]) + && same(&line.origin.ctm, &shown.state.ctm.0) + && within(&[a.char_spacing, a.word_spacing], &[b.char_spacing, b.word_spacing], em.max(1e-6)) + && same(&[a.scale, a.rise], &[b.scale, b.rise]) + && a.fill.len() == b.fill.len() + && a.fill.iter().zip(&b.fill).all(|(a, b)| a.op == b.op && a.operands == b.operands) +} + +/// The line `s` continues, or a new one: `last` is the previous run of the same stream +/// (`bt`, baseline, end x, size). +fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream: usize) { + let joins = last.is_some_and(|pen| { + let bt = pen.bt; + lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) + && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) + && (s.text.trim().is_empty() + || lines.last().is_some_and(|l| { + (s.size - l.size).abs() < 0.01 + && s.bold == l.bold + && s.italic == l.italic + && fill_color(&s.state.fill) == l.color + && same_paint(l, &s.state) + })) + && pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }).0 + }); + if joins && let Some(l) = lines.last_mut() { + let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1); + let spaced = gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' '); + if spaced { + l.text.push(' '); + } + l.text.push_str(&s.text); + l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.frame = union(l.frame, s.frame); + l.ink.extend(s.ink.iter().copied()); + l.runs.push((s.text.clone(), l.ink.len(), spaced)); + l.ops.push(s.op); + l.ops.extend(s.extra_ops.iter().copied()); + l.decodable &= s.decodable; + l.visual_order |= s.visual_order; + l.actual_text |= s.actual_text; + l.clips |= s.state.render_mode >= 4; + if l.unreadable_mode.is_none() { + l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); + } + } else { + lines.push(TextLine { + text: s.text.clone(), + rect: s.rect, + frame: s.frame, + ink: s.ink.clone(), + font: String::from_utf8_lossy(&s.font).into_owned(), + base_font: s.base_font.clone(), + size: s.size, + bold: s.bold, + italic: s.italic, + color: fill_color(&s.state.fill), + decodable: s.decodable, + visual_order: s.visual_order, + face: s.face.clone(), + stream, + scope: s.scope, + paint: s.paint.clone(), + logical_order: s.logical_order, + actual_text: s.actual_text, + clips: s.state.render_mode >= 4, + unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), + runs: vec![(s.text.clone(), s.ink.len(), false)], + soft_break: s.soft_break, + hard_break: s.hard_break, + soft_gap: s.soft_gap, + ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), + origin: Origin { + tm: s.tm.0, + tlm: s.tlm.0, + k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(), + ctm: s.state.ctm.0, + baseline: s.baseline, + x: s.start_x, + state: TextState::of(&s.state), + bt_op: s.bt_op, + bt_state: TextState::of(&s.bt_state), + }, + }); + } + *last = Some(Pen::after(&s)); +} + +/// How deep form XObjects nest before reading stops (they may also refer to themselves). The +/// renderer stops at the same depth, so export reads the text the page shows (#3120). +pub const MAX_FORM_DEPTH: usize = 50; + +/// The most form XObjects one page's walk enters, at any depth. The depth cap alone still lets +/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 50 +/// levels), which would hang export on a hostile PDF. The renderer paints as many forms nested +/// in forms; this budget also counts the forms the page draws itself, so a page that draws one +/// a million times stays bounded too. A label or table cell drawn as a form a few thousand +/// times is read in full (#3120). +pub const MAX_FORM_VISITS: usize = 50_000; + +/// One page's walk through the form XObjects it draws: how many it entered (the page's +/// [`MAX_FORM_VISITS`]), the bytes the page's content and forms may still decode to, and whether +/// a limit left a form unread. +pub(crate) struct FormBudget { + visits: usize, + decode: DecodeBudget, + /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`], comes past + /// [`MAX_FORM_VISITS`] or past the decode budget: what it draws is missing from the walk. + pub(crate) truncated: bool, +} + +impl FormBudget { + /// The walk of a page's forms, spending what `decode` has left after the page's content. + pub(crate) fn new(decode: DecodeBudget) -> Self { + Self { visits: 0, decode, truncated: false } + } +} + +/// A form XObject a page draws: its object, content, resources and its matrix composed with the +/// CTM at the `Do`. +pub(crate) struct FormCall { + pub(crate) obj: Option, + pub(crate) data: Vec, + pub(crate) resources: Dict, + pub(crate) ctm: Matrix, +} + +/// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names +/// and forms already being read (`path`), and for forms nested too deep or past the page's +/// budget, which `budget` records as truncated; each form entered counts one visit. +pub(crate) fn form_call( + doc: &Document, + resources: &Dict, + name: &[u8], + ctm: Matrix, + path: &[pdfcraft_cos::ObjRef], + budget: &mut FormBudget, +) -> Option { + let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS || budget.decode.remaining() == 0; + // Once a limit has cut the page short, refusing the rest costs nothing more. + if limited && budget.truncated { + return None; + } + let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; + let o = xobjects.get(name)?; + let obj = o.as_ref(); + if obj.is_some_and(|r| path.contains(&r)) { + return None; + } + let Object::Stream(s) = &*doc.resolve(o) else { return None }; + if s.dict.name(b"Subtype") != Some(b"Form") { + return None; + } + // Only a form left unread loses content; an image at the limit is still read by its caller. + if limited { + budget.truncated = true; + return None; + } + let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| { + let a = m.as_array()?; + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + <[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite())) + }); + // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). + let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let data = match budget.decode.decode(s) { + Ok(data) => data, + Err(_) if budget.decode.remaining() == 0 => { + budget.truncated = true; + return None; + } + Err(_) => Vec::new(), + }; + budget.visits = budget.visits.saturating_add(1); + Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) +} + +/// The lines a page shows as it reads, including text drawn by form XObjects (which +/// [`text_lines`] leaves out because it can only rewrite the page's own streams). Lines from a +/// form carry a stream number past the page's streams, one per form drawn. The flag is `true` +/// when forms past [`MAX_FORM_DEPTH`] or [`MAX_FORM_VISITS`] were left unread. +pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), EditError> { + struct Walk<'a> { + doc: &'a Document, + lines: Vec, + next_stream: usize, + path: Vec, + budget: FormBudget, + } + /// `stream` gives the stream number of the operator at an index of `ops`. + fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) -> Result<(), EditError> { + let mut cache = HashMap::new(); + let mut invoked = Vec::new(); + let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default(); + let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), Some(&properties))?; + let mut last = None; + let mut last_stream = None; + let mut forms = invoked.into_iter().peekable(); + for s in shown { + while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) { + enter(w, resources, &name, ts, &mut carry.text_left)?; + // Text after a form starts a new line. + last = None; + } + let si = stream(s.op); + // Text from another stream starts a new line, as when each stream was read alone. + if last_stream.replace(si).is_some_and(|prev| prev != si) { + last = None; + } + add_shown(&mut w.lines, &mut last, s, si); + } + for (_, name, ts) in forms { + enter(w, resources, &name, ts, &mut carry.text_left)?; + } + Ok(()) + } + /// `text_left`: the page's text budget, shared with the forms the page draws. + fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts, text_left: &mut usize) -> Result<(), EditError> { + let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return Ok(()) }; + let ops = parse(&form.data).ops; + // The form starts with the graphics state at its `Do`, its own matrix applied. + let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: *text_left }; + let stream = w.next_stream; + w.next_stream = w.next_stream.saturating_add(1); + if let Some(r) = form.obj { + w.path.push(r); + } + walk(w, &ops, &form.resources, &mut carry, &|_| stream)?; + *text_left = carry.text_left; + if form.obj.is_some() { + w.path.pop(); + } + Ok(()) + } + let p = page_dict(doc, page)?; + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut decode = DecodeBudget::page(); + let streams = content_streams_within(doc, &p.dict, &mut decode)?; + let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::new(decode) }; + let mut carry = Carry::new(); + // Read the page's streams as the one stream they are, in one pass, as rendering and editing + // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) + // can run on from one stream into the next. Text still counts as the stream its operator + // starts in, which keeps lines from different streams apart. + let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::>()); + let ops = pieces.parse().ops; + let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0); + walk(&mut w, &ops, &res, &mut carry, &stream_of)?; + let mut lines = w.lines; + // A visual-order Indic font draws pre-base vowel signs before their consonant. + for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) { + let shown = l.text.clone(); + l.text = crate::indic::to_logical(&l.text); + if l.text != shown { + l.runs.clear(); + } + } + lines.retain(|l| !l.text.trim().is_empty()); + Ok((lines, w.budget.truncated)) +} diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs new file mode 100644 index 0000000000..ec2fac984c --- /dev/null +++ b/crates/edit/src/text/rewrite.rs @@ -0,0 +1,1184 @@ +use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap}; +use super::fallback::{ + Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, + source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, +}; +use super::interp::{Carry, Origin, TextState, interpret}; +use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines}; +use super::{ + Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, alignment_frame, group_blocks, + is_centred, parse, rewrite_styled_block, +}; + +/// What replacing a line did. +#[derive(Clone, Debug, PartialEq)] +pub struct LineEdit { + /// `Some(font)` when the line's font couldn't show the new text and Helvetica was used. + pub substituted: Option, +} + +/// Replace the text of line `line` (an index into [`text_lines`]) on `page` with `text`. +pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> Result { + let edit = replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS)?; + drop_unused_fallback_fonts(doc, page); + Ok(edit) +} + +/// Refuses a rewrite that would remove text drawn as a clipping path (`4 Tr` to `7 Tr`). Such +/// glyphs are not ink but a mask, and whatever is painted through them takes their shape. +/// Removing the glyphs removes the mask, and the fill or image behind floods its own rectangle +/// instead -- the artwork is destroyed, in a file that still opens without a warning (#3429). +/// `clear_block` already refuses these for the same reason. +/// +/// Every line the rewrite drops is checked, not only the one being replaced: a line's runs need +/// not share a rendering mode (see [`TextLine::clips`]), and [`coincident_ops`] drops a whole +/// overlapping line without regard to its mode -- so a mask could go with text that is ink. +fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet) -> Result<(), EditError> { + if lines.iter().any(|l| l.clips && l.ops.iter().any(|op| dropped.contains(op))) { + return Err(EditError::Invalid("text used as a clipping path cannot be edited: its glyphs mask the artwork behind them".into())); + } + Ok(()) +} + +/// [`replace_line`] with the most unique characters one generated font may hold (tests lower it). +pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result { + let text = text.replace(['\n', '\r'], " "); + let lines = text_lines(doc, page)?; + let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?; + target.ensure_editable()?; + // Copies of the line drawn on the same baseline go with it, so the replacement is all that + // shows. Which operators go is settled here, before any font is generated, so that a refusal + // leaves the document untouched. + let mut drops = coincident_ops(&lines, &[&target]); + drops.extend(target.ops.iter().copied()); + refuse_clipping_text(&lines, &drops)?; + let p = page_dict(doc, page)?; + let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + let streams = content_streams(doc, &p.dict)?; + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; + // The line's own font, when it can show every character. + let font = fonts_res.get(target.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); + let visual_indic = font.as_ref().is_some_and(|m| m.visual_order) || target.visual_order; + let indic_text = if visual_indic { crate::indic::to_visual(&text) } else { text.clone() }; + // Typed in logical order; drawn left to right (right-to-left runs reversed). + let shown = crate::bidi::to_visual(&indic_text); + let reused = font.as_ref().filter(|m| source_encodes(m, &shown)); + let mut substituted = None; + let mut replacement: Vec = Vec::new(); + // ' and " also move to the next line; keep that. + match ops[first].op.as_slice() { + b"'" => replacement.push(Op::new("T*", vec![])), + b"\"" => { + replacement.push(Op::new("Tw", vec![ops[first].operands.first().cloned().unwrap_or(Object::Int(0))])); + replacement.push(Op::new("Tc", vec![ops[first].operands.get(1).cloned().unwrap_or(Object::Int(0))])); + replacement.push(Op::new("T*", vec![])); + } + _ => {} + } + match reused { + Some(metrics) => replacement.extend(source_show( + metrics, + &shown, + target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size), + source_space(doc, &fonts_res, metrics), + target.origin.state.char_spacing, + target.origin.state.word_spacing, + )?), + None => { + let fallback_shown = crate::bidi::to_visual(&text); + if needs_type3(&text) { + let fallback = type3_font(doc, &mut fonts_res, &text, source_family(&target.base_font), target.bold, max_glyphs)?; + let runs = type3_encode(&fallback, &fallback_shown) + .ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the {} fallback", fallback.family)))?; + let size = font_size_before(&ops, first).unwrap_or(target.size); + for (name, bytes) in runs { + replacement.push(Op::new("Tf", vec![Object::name(&name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); + substituted = Some(format!("{} Type3", fallback.family)); + } else { + let win = pdfcraft_fonts::win_ansi(&fallback_shown); + // WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing. + let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); + if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { + return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font))); + } + // The size in text space: the current Tf's size. + let size = font_size_before(&ops, first).unwrap_or(target.size); + let family = source_family(&target.base_font); + let base = family.base_font(target.bold, target.italic); + let substitute_name = format!("PCEd{}", base.replace('-', "")); + replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); + replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); + substituted = Some(base.to_string()); + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts_res.set(substitute_name.into_bytes(), Object::Dict(f)); + } + } + } + // Rebuild: the line's first operator becomes the replacement, its others go. + let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); + let contents = rewritten_contents(doc, &streams, new_data); + let page_ref = p.obj; + if substituted.is_some() { + res.set(b"Font".to_vec(), Object::Dict(fonts_res)); + } + doc.update_dict(page_ref, |d| { + d.set(b"Contents".to_vec(), contents); + if substituted.is_some() { + d.set(b"Resources".to_vec(), Object::Dict(res)); + } + })?; + Ok(LineEdit { substituted }) +} + +/// The size of the last `Tf` before operator `at`. +fn font_size_before(ops: &[Op], at: usize) -> Option { + ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) +} + +/// Operators of every line that is another drawing of one of `targets` (fake bold, an invisible +/// text layer). A surviving copy would show the old text under the replacement. +/// +/// A copy overlaps a target by more than half the smaller box and sits on the same baseline +/// (within the tolerance that joins showing operators onto one line). A neighbouring line can +/// share box area when the leading is tighter than the font's ascent plus descent; its baseline +/// is farther away, so it stays. +pub(crate) fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections::HashSet { + let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); + let mut drop = std::collections::HashSet::new(); + for line in lines { + let copy = targets.iter().any(|target| { + let same_text = line.text.trim() == target.text.trim() || (line.origin.state.render_mode == 3) != (target.origin.state.render_mode == 3); + same_text && overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target) + }); + if copy { + drop.extend(line.ops.iter().copied()); + } + } + drop +} + +fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bool { + let ix = (b[2].min(a[2]) - b[0].max(a[0])).max(0.0); + let iy = (b[3].min(a[3]) - b[1].max(a[1])).max(0.0); + let small = area(b).min(area(a)); + small > 0.0 && ix * iy / small > 0.5 +} + +/// Perpendicular distance between baselines, in user space. Horizontal text compares height; +/// turned text compares the distance across the writing direction. +fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { + let (dir, target_dir) = (line.direction(), target.direction()); + let same_dir = (dir.0 - target_dir.0).abs() < 1e-3 && (dir.1 - target_dir.1).abs() < 1e-3; + let same_size = line.size.min(target.size) >= line.size.max(target.size) * 0.8; + let angle = target.rotation().to_radians(); + let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; + let dx = line.origin.x - target.origin.x; + let dy = line.origin.baseline - target.origin.baseline; + let offset = dx.mul_add(-sin, dy * cos).abs(); + let size = line.size.min(target.size).max(1.0); + same_dir && same_size && offset.is_finite() && offset < size * 0.3 +} + +/// Rewrite paragraph `block` with new text (or its own) and formatting, rewrapped to its width. +pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option<&str>, style: &BlockStyle) -> Result { + let edit = rewrite_block_with_limit(doc, page, block, text, style, MAX_TYPE3_GLYPHS)?; + drop_unused_fallback_fonts(doc, page); + Ok(edit) +} + +/// [`rewrite_block`] with the most unique characters one generated font may hold (tests lower it). +pub(crate) fn rewrite_block_with_limit( + doc: &mut Document, + page: usize, + block: usize, + text: Option<&str>, + style: &BlockStyle, + max_glyphs: usize, +) -> Result { + TextSession::open(doc, page, block)?.commit_with_limit(doc, text, style, max_glyphs) +} + +struct RewriteDispatch { + upright: bool, + use_styled_path: bool, +} + +fn choose_rewrite_path( + doc: &Document, + fonts: &Dict, + block: &TextBlock, + members: &[&TextLine], + first: &TextLine, + text: Option<&str>, + style: &BlockStyle, +) -> Result { + let registered_face = fonts.iter().any(|(name, _)| name.starts_with(b"PCFull")); + // Hebrew is written back in drawing order by the single-style path. Styled regions do not + // reorder right-to-left runs. + let hebrew = text.unwrap_or(&block.text).chars().any(crate::bidi::is_hebrew); + if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid( + "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), + )); + } + // Turned text is rewritten in its own text space by the single-style path. Styled layout uses + // page x-coordinates and cannot safely apply those operations to rotated runs. + let upright = members.iter().all(|line| line.upright()); + if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); + } + let visual_indic = members.iter().any(|line| line.visual_order); + let has_special_text = text.is_some_and(|draft| { + draft.contains(['\n', '\r']) + || pdfcraft_fonts::source_shaping::required(draft) + || !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty() + }); + let has_multiple_runs = block.rows.iter().any(|row| row.len() > 1) + || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color); + let source_requires_styled_path = + has_special_text || registered_face || text.is_some_and(|draft| source_program_completes(doc, fonts, first, draft)) || has_multiple_runs; + let explicit_styled_text = !style.ranges.is_empty() || style.resource.is_some(); + Ok(RewriteDispatch { + upright, + use_styled_path: upright + && !hebrew + && !visual_indic + && (explicit_styled_text || (source_requires_styled_path && style.family.is_none() && style.bold.is_none())), + }) +} + +/// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed +/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are +/// breaks too; other white space is written as one space. +fn paragraph_text(text: &str) -> String { + text.replace("\r\n", "\n") + .chars() + .map(|c| match c { + '\r' => '\n', + c if c != '\n' && paragraph_separator(c) => ' ', + c => c, + }) + .collect() +} + +/// Where a paragraph sits on its page, for choosing the width it is laid out in. +struct ParaGeometry { + rect: [f64; 4], + frame: [f64; 4], + crop: [f64; 4], + upright: bool, + /// The text-to-user scale. + k: f64, + /// The size in text space. + size: f64, + /// The requested horizontal move, in points. + dx: f64, + single_line: bool, + anchored_right: bool, + centred: bool, +} + +impl ParaGeometry { + /// A paragraph keeps its width (or takes the one asked for); a single line grows to the right + /// (to the left when right-to-left), up to the page's margin. + fn width(&self, requested: Option) -> Result { + let [left, _, right, _] = self.rect; + let own = (right - left).max(self.size * self.k); + let width = match requested { + // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that + // (`clamp` panics when its minimum is the larger, or NaN). + Some(w) if w.is_finite() => w.clamp((self.size * self.k).min(14_400.0), 14_400.0), + Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), + // Turned text: the page's margins are along another axis. A single line grows (below). + None if !self.upright => (self.frame[2] - self.frame[0]).max(self.size * self.k), + None if self.anchored_right => own.max(right + self.dx - (self.crop[0] + 36.0)), + // A centred line is not stretched to the margin: its own box is the frame it is centred in. + None if self.single_line && !self.centred => own.max(self.crop[2] - 36.0 - (left + self.dx)), + None => own, + }; + if width.is_finite() { Ok(width) } else { Err(EditError::Invalid("the paragraph's width must be a number".into())) } + } +} + +/// How far a baseline starting at `origin` and running along `direction` goes before it is 36 pt +/// from the page edge `low` or `high`. +fn margin_reach(origin: f64, direction: f64, low: f64, high: f64) -> f64 { + if direction > 1e-6 { + (high - 36.0 - origin) / direction + } else if direction < -1e-6 { + (low + 36.0 - origin) / direction + } else { + f64::INFINITY + } +} + +/// A turned single line grows to fit, up to where its baseline reaches the page's margin. +fn turned_line_width(first: &TextLine, crop: [f64; 4], offset: [f64; 2], width: f64, text: &str, advance: &Advance<'_>) -> f64 { + let (x0, y0) = (first.origin.x + offset[0], first.origin.baseline + offset[1]); + let (ux, uy) = first.direction(); + let tx = margin_reach(x0, ux, crop[0], crop[2]); + let ty = margin_reach(y0, uy, crop[1], crop[3]); + let cap = width.max(tx.min(ty)).min(14_400.0); + text.split('\n').map(|line| advance.of(line)).fold(width, f64::max).min(cap) +} + +/// The widths of the paragraph's text in the font it will be written in, in user space. +struct Advance<'a> { + /// The source font, when its glyphs are reused. + source: Option<&'a Metrics>, + type3: Option<&'a Type3Fallback>, + family: crate::added::Family, + bold: bool, + italic: bool, + size: f64, + space: f64, + char_spacing: f64, + word_spacing: f64, + scale: f64, + k: f64, +} + +impl Advance<'_> { + fn of(&self, s: &str) -> f64 { + let extra = s.chars().count() as f64 * self.char_spacing; + let t = match (self.source, self.type3) { + (Some(m), _) => { + let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; + source_width(m, &visual_s, self.size, self.space, self.char_spacing, self.word_spacing) + } + (None, Some(fallback)) => { + // Each character's count in `s`, from one pass over `s`. + let mut counts: HashMap = HashMap::new(); + for c in s.chars() { + let count = counts.entry(c).or_insert(0); + *count = count.saturating_add(1); + } + fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + extra + } + (None, None) => self.family.std14(self.bold, self.italic).text_width(s, self.size) + extra, + }; + t * self.scale * self.k + } +} + +/// The font a rewritten paragraph is shown in, and how its text is encoded. +struct FontChoice { + show_font: String, + encode: Encoder, + /// The stand-in face to tell the caller about. + substituted: Option, +} + +/// What [`choose_font`] needs to know about the paragraph. +struct FontInputs<'a> { + /// The source font, when its glyphs are reused. + source: Option<&'a Metrics>, + type3: Option<&'a Type3Fallback>, + font_name: &'a str, + flat: &'a str, + source_base_font: &'a str, + family: crate::added::Family, + bold: bool, + italic: bool, + family_requested: bool, +} + +fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result { + if let Some(m) = inputs.source.cloned() { + let name = inputs.font_name.to_owned(); + return Ok(FontChoice { + show_font: name.clone(), + encode: Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)])), + substituted: None, + }); + } + if let Some(fallback) = inputs.type3.cloned() { + let substituted = Some(format!("{} Type3", fallback.family)); + let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; + return Ok(FontChoice { show_font: first, encode: Box::new(move |s: &str| type3_encode(&fallback, s)), substituted }); + } + let flat = inputs.flat; + let win = pdfcraft_fonts::win_ansi(flat); + let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); + let base = inputs.family.base_font(inputs.bold, inputs.italic); + if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { + return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", inputs.source_base_font))); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + // Named after its face, as the line and region rewrites name theirs: one name for every + // stand-in would repoint the paragraphs set in an earlier one at this face (#4280). + let name = format!("PCEd{}", base.replace('-', "")); + fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); + let substituted = (!inputs.family_requested).then(|| base.to_string()); + let shown = name.clone(); + Ok(FontChoice { show_font: name, encode: Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))])), substituted }) +} + +/// The paragraph's move in the space it is drawn in: the move is in page space, so it is taken +/// back through the CTM's linear part. Checked before the fallback fonts are written: a refused +/// paragraph leaves the document as it was. +fn drawn_movement(ctm: [f64; 6], dx: f64, dy: f64) -> Result, EditError> { + if dx == 0.0 && dy == 0.0 { + return Ok(None); + } + let back = Matrix([ctm[0], ctm[1], ctm[2], ctm[3], 0.0, 0.0]) + .invert() + .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; + Ok(Some(back.apply(dx, dy))) +} + +/// The paragraph's first text matrix, taken from where its text starts to where it is written. +fn emit_text_matrix(first: &Origin, emit: &Origin) -> Result<[f64; 6], EditError> { + if first.ctm == emit.ctm { + return Ok(first.tm); + } + let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + Ok(Matrix(first.tm).then(&Matrix(first.ctm)).then(&emit_inv).0) +} + +/// Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. +struct LeadScale { + k: f64, + size: f64, + old_size: f64, + leading: f64, +} + +fn line_lead(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, scale: &LeadScale) -> f64 { + if let Some(m) = style.line_spacing { + return scale.size * m.clamp(0.5, 5.0); + } + // From the first two rows (a row may hold several runs). + let rows = block.rows.first().zip(block.rows.get(1)).and_then(|(upper, lower)| upper.first().zip(lower.first())); + if let Some((upper, lower)) = rows.and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) { + return (upper.frame_origin().1 - lower.frame_origin().1) / scale.k * scale.size / scale.old_size; + } + if scale.leading > 0.0 { scale.leading } else { scale.size * 1.2 } +} + +/// The stroking twin of a fill colour operator. +fn stroke_twin(fill: &Op) -> Op { + let name = match fill.op.as_slice() { + b"g" => "G", + b"rg" => "RG", + b"k" => "K", + b"cs" => "CS", + b"sc" => "SC", + _ => "SCN", + }; + Op::new(name, fill.operands.clone()) +} + +/// Underlines in text space (the paragraph's text matrix), in its fill colour. +fn underline_ops(fill: &[Op], tm: &[f64; 6], size: f64, underlines: &[(f64, f64, f64)]) -> Vec { + let n = pdfcraft_content::num; + let mut ops = vec![Op::new("q", vec![]), Op::new("cm", tm.iter().map(|v| n(*v)).collect())]; + ops.extend(fill.iter().map(stroke_twin)); + ops.push(Op::new("w", vec![n(size * 0.06)])); + for (x0, x1, y) in underlines { + ops.push(Op::new("m", vec![n(*x0), n(*y)])); + ops.push(Op::new("l", vec![n(*x1), n(*y)])); + } + ops.push(Op::new("S", vec![])); + ops.push(Op::new("Q", vec![])); + ops +} + +/// Alignment: each line's offset from the left edge. +struct Alignment { + width: f64, + k: f64, + anchored_right: bool, + right_edge: f64, + rtl: bool, +} + +impl Alignment { + /// `visible` is the width the line is aligned on, in user space, and `align` its row's + /// alignment; the offset is in text space. + fn offset(&self, visible: f64, align: Option) -> f64 { + let free = (self.width - visible).max(0.0) / self.k; + match align { + Some(crate::added::Align::Center) => free / 2.0, + // A right-to-left line anchored at its right edge may start left of the old box. + Some(crate::added::Align::Right) if self.anchored_right => (self.right_edge - visible) / self.k, + Some(crate::added::Align::Right) => free, + // A justified right-to-left paragraph ends its last line at the right edge. + Some(crate::added::Align::Justify) if self.rtl => free, + _ => 0.0, + } + } +} + +/// Everything the wrapped lines of one paragraph are written with. +struct LineWriter<'a> { + advance: &'a Advance<'a>, + alignment: Alignment, + encode: &'a Encoder, + source: Option<&'a Metrics>, + /// The paragraph's alignment, and the rows aligned on their own ([`BlockStyle::aligns`]). + align: Option, + aligns: &'a [Option], + /// Whether `Tw` reaches every shown space. + word_spacing_reaches_spaces: bool, + width: f64, + scale: f64, + k: f64, + size: f64, + space: f64, + char_spacing: f64, + lead: f64, +} + +/// What carries over from one line to the next. +struct LineCursor { + x: f64, + tw_set: f64, + shown_font: String, +} + +impl LineWriter<'_> { + /// Row `row`'s alignment: its own, or the paragraph's. + fn align_of(&self, row: usize) -> Option { + self.aligns.get(row).copied().flatten().or(self.align) + } + + /// Justify: each line but the last fills the width. A stretched line shows only its words, as + /// before #4199: word spacing would reach the spaces before its first word and after its + /// last, so they are left out. Nothing to stretch over, or a paragraph drawn at `0 Tz`, isn't + /// justified. + fn stretch(&self, body: &str, last: bool, justify: bool) -> f64 { + let interior = body.matches(' ').count(); + if justify && !last && interior > 0 && self.scale.abs() > 1e-6 { + Some((self.width - self.advance.of(body)).max(0.0) / self.k / self.scale / interior as f64).filter(|tw| tw.is_finite()).unwrap_or(0.0) + } else { + 0.0 + } + } + + /// The text to show for a line and the width it is aligned on. Other lines show every space + /// typed. A left-to-right one is aligned on its width up to its last word; a right-to-left + /// one on its whole width, since the spaces typed after its last word are drawn on its left + /// and the ones before its first on its right. + fn shown<'t>(&self, line: &'t str, body: &'t str, tw: f64) -> (&'t str, f64) { + if tw > 0.0 { + (body, self.advance.of(body)) + } else if crate::bidi::rtl_base(line) { + (line, self.advance.of(line)) + } else { + (line, self.advance.of(line.trim_end_matches(' '))) + } + } + + /// Writes one wrapped line of its logical draft row, returning its underline (x0, x1, y) in text space. `wrap`: whether + /// this line continues the previous one because a word did not fit, and, when it does, how + /// many separator characters the wrap consumed (0 for a Chinese/Japanese word split). + fn write_line( + &self, + index: usize, + line: &str, + wrap: (usize, bool, usize), + last: bool, + cursor: &mut LineCursor, + ops: &mut Vec, + ) -> Result<(f64, f64, f64), EditError> { + let n = pdfcraft_content::num; + let (row, soft, gap) = wrap; + let line_align = self.align_of(row); + let body = line.trim_matches(' '); + let interior = body.matches(' ').count(); + let tw = self.stretch(body, last, line_align == Some(crate::added::Align::Justify)); + let (shown, visible) = self.shown(line, body, tw); + let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible, line_align) }; + if index > 0 { + // Inside this text object, so the marker starts and ends here. `/PCHard` is a newline + // that was already in the text. `/PCSoft` rejoins when the box widens, with `/N` the + // exact number of separator characters the wrap consumed (0 for a Chinese/Japanese + // word split), so a rejoin restores what was actually there instead of guessing a + // single space and silently dropping the rest of a typed run (#2529). + if soft { + let mut properties = Dict::new(); + properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX))); + ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)])); + } else { + ops.push(Op::new("BMC", vec![Object::name("PCHard")])); + } + ops.push(Op::new("EMC", vec![])); + } + if index > 0 || dx != 0.0 { + ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })])); + } + cursor.x = dx; + // Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3), so where a space is + // shown with another code (a composite font, a generated Type 3 font, a subset without a + // space) the extra space follows each space as a TJ adjustment instead. + let stretch = if self.word_spacing_reaches_spaces { 0.0 } else { tw }; + let tw = tw - stretch; + if tw != cursor.tw_set { + ops.push(Op::new("Tw", vec![n(tw)])); + cursor.tw_set = tw; + } + // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). + let visual = crate::bidi::to_visual(shown); + // Stretched without word spacing: shown up to and including each space, then moved on. + let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; + for piece in pieces { + self.show_piece(piece, line, tw, cursor, ops)?; + if stretch > 0.0 && piece.ends_with(' ') { + ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])])); + } + } + let tw = tw + stretch; + // Underlined to the line's last word, over its stretched gaps. + let w = (visible + tw * interior as f64 * self.scale * self.k) / self.k; + Ok((dx, dx + w, -(index as f64) * self.lead - self.size * 0.12)) + } + + fn show_piece(&self, piece: &str, line: &str, tw: f64, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(), EditError> { + let n = pdfcraft_content::num; + if let Some(metrics) = self.source { + let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; + ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); + return Ok(()); + } + for (font, bytes) in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if font != cursor.shown_font { + ops.push(Op::new("Tf", vec![Object::name(&font), n(self.size)])); + cursor.shown_font = font; + } + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + Ok(()) + } + + /// Writes every wrapped line, returning their underlines. + fn write_all(&self, wrapped: &[(String, bool, usize)], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { + let mut row = 0usize; + wrapped + .iter() + .enumerate() + .map(|(i, (line, soft, gap))| { + if i > 0 && !soft { + row = row.saturating_add(1); + } + self.write_line(i, line, (row, *soft, *gap), i + 1 == wrapped.len(), cursor, ops) + }) + .collect() + } +} + +/// The operators that write a paragraph in its own graphics state, so a new colour (or anything +/// else) stops at the paragraph. +struct BlockPlan<'a> { + block: &'a TextBlock, + lines: &'a [TextLine], + style: &'a BlockStyle, + emit: &'a Origin, + after: &'a TextState, + movement: Option<(f64, f64)>, + state: TextState, + tm: [f64; 6], + size: f64, + underline: bool, +} + +fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool, usize)], show_font: String) -> Result, EditError> { + let n = pdfcraft_content::num; + let mut ops = vec![Op::new("q", vec![])]; + ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); + // Moved: a translation inside that state. + if let Some((ax, ay)) = plan.movement { + ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); + } + // Painted as the paragraph was, including what its text object set before the first show. + ops.extend(plan.state.paint_ops(plan.after)); + ops.push(Op::new("BT", vec![])); + let mut state = plan.state.clone(); + state.word_spacing = 0.0; + // The font the next run is shown in: the one the BT sets, until a run of another fallback subset. + let mut cursor = LineCursor { x: 0.0, tw_set: 0.0, shown_font: show_font.clone() }; + state.font = Some((show_font, plan.size)); + if let Some([r, g, bl]) = plan.style.color { + state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; + } + ops.extend(state.ops()); + ops.push(Op::new("Tm", plan.tm.iter().map(|v| n(*v)).collect())); + let underlines = writer.write_all(wrapped, &mut cursor, &mut ops)?; + ops.push(Op::new("ET", vec![])); + if plan.underline { + ops.extend(underline_ops(&state.fill, &plan.tm, plan.size, &underlines)); + } + ops.push(Op::new("Q", vec![])); + Ok(ops) +} + +/// Whether text shown earlier in the same text object stays first: the paragraph then goes where +/// its last row was, after any backgrounds between the rows, or between two halves of the text +/// object. The paragraph's own operators (and another drawing on the same baseline: a fake-bold +/// second copy, an invisible text layer) go, so the new text is all that may show. +fn shows_before_paragraph(ops: &[Op], from: usize, to: usize, dropped: &std::collections::HashSet) -> bool { + ops.get(from..to) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !dropped.contains(&(from + i))) +} + +/// A paragraph's single style, with the text-space values read from where it starts. +struct ParaStyle { + state: TextState, + size: f64, + old_size: f64, + k: f64, +} + +/// Spacing and scaling (text state), with the chosen values. +fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle), EditError> { + let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; + let k = o.k.max(1e-6); + // The size in text space: points ÷ the text-to-user scale. + let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); + let mut state = o.state.clone(); + if let Some(c) = style.char_spacing { + state.char_spacing = c / k; + } + if let Some(sc) = style.scale { + state.scale = (sc / 100.0).clamp(0.1, 10.0); + } + Ok((font_name, ParaStyle { state, size, old_size, k })) +} + +/// The font the paragraph's existing runs use, for reusing its glyphs. +struct SourceFont { + metrics: Option, + reuse: bool, + space: f64, +} + +fn source_font(doc: &Document, fonts_res: &Dict, font_name: &str, first: &TextLine, flat: &str, style: &BlockStyle) -> SourceFont { + let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); + let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; + let indic_text = if visual_indic { crate::indic::to_visual(flat) } else { flat.to_owned() }; + let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); + let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, fonts_res, m)); + SourceFont { metrics, reuse, space } +} + +/// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now. +pub(crate) fn rewrite_block_from( + doc: &mut Document, + page: usize, + lines: Vec, + block: usize, + text: Option<&str>, + style: &BlockStyle, + max_glyphs: usize, +) -> Result { + let blocks = group_blocks(&lines); + let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; + // Existing PDF paragraphs do not yet persist selected-row alignment intent. Refuse + // that operation before writing anything; whole-paragraph `align` remains supported. + if style.aligns.iter().any(Option::is_some) { + return Err(EditError::Invalid( + "selected-row alignment of existing paragraphs is not supported until it can be preserved across editing sessions".into(), + )); + } + // An alignment for a one-row paragraph is laid out across the column it sits in, as a + // paragraph of that width moved to its left edge. + let framed; + let style = match alignment_frame(&lines, &blocks, &b, style, text.unwrap_or(&b.editor_text)) { + Some([left, right]) => { + framed = BlockStyle { width: Some(right - left), offset: Some([left - b.rect[0], 0.0]), ..style.clone() }; + &framed + } + None => style, + }; + let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect(); + // One clipping run is enough: rewriting the paragraph would drop its mask with the rest. + let mut dropped = coincident_ops(&lines, &members); + dropped.extend(members.iter().flat_map(|l| l.ops.iter().copied())); + refuse_clipping_text(&lines, &dropped)?; + let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; + members.iter().try_for_each(|line| line.ensure_editable())?; + let p = page_dict(doc, page)?; + let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; + if dispatch.use_styled_path && style.align == Some(crate::added::Align::Justify) { + return Err(EditError::Invalid("justification of mixed-style existing paragraphs is not supported".into())); + } + if dispatch.use_styled_path { + return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs); + } + let upright = dispatch.upright; + let streams = content_streams(doc, &p.dict)?; + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let text = paragraph_text(text.unwrap_or(&b.text)); + // The characters to show, for choosing fonts: the breaks are moves, not glyphs. + let flat = text.replace('\n', " "); + let o = &first.origin; + let last = members.last().copied().unwrap_or(first); + let emit = &last.origin; + let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style)?; + let source = source_font(doc, &fonts_res, &font_name, first, &flat, style); + let reuse = source.reuse; + // The chosen style also determines the real Japanese fallback outlines and advances. + let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); + let bold = style.bold.unwrap_or(bold); + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + if !(dx.is_finite() && dy.is_finite()) { + return Err(EditError::Invalid("the paragraph can't be moved that far".into())); + } + // Right-to-left text (more Hebrew or Arabic letters than Latin) is right-aligned unless an + // alignment is asked for, and a single right-to-left line keeps its right edge as it grows. + let rtl = crate::bidi::rtl_base(&flat); + let anchored_right = rtl && upright && style.width.is_none() && members.len() == 1; + // The paragraph's width along its lines: in user space unless it is turned. + let frame = if upright { + b.rect + } else { + members.iter().map(|l| l.frame_rect()).reduce(|a, f| [a[0].min(f[0]), a[1].min(f[1]), a[2].max(f[2]), a[3].max(f[3])]).unwrap_or(b.rect) + }; + // A centred title is laid out in the box it already sits in, so it keeps its centre as it + // grows or wraps, rather than being grown to the right up to the page's margin (#2581). + // Turned text is left alone: its centre lives along its own baseline, not in page space. + let crop = p.crop(doc); + let centred = upright && !rtl && style.width.is_none() && style.align.is_none() && is_centred(&b, &lines, (crop[0] + crop[2]) / 2.0, first.size); + let geometry = ParaGeometry { rect: b.rect, frame, crop, upright, k, size, dx, single_line: members.len() == 1, anchored_right, centred }; + let width = geometry.width(style.width)?; + let align = if centred { Some(crate::added::Align::Center) } else { style.align.or(rtl.then_some(crate::added::Align::Right)) }; + // Where a right-aligned line ends, from the paragraph's left edge (user space). + let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width }; + let movement = drawn_movement(emit.ctm, dx, dy)?; + let tm = emit_text_matrix(o, emit)?; + let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; + let source_metrics = source.metrics.as_ref().filter(|_| reuse); + let advance = Advance { + source: source_metrics, + type3: type3.as_ref(), + family, + bold, + italic, + size, + space: source.space, + char_spacing: o_state.char_spacing, + word_spacing: o_state.word_spacing, + scale: o_state.scale, + k, + }; + let width = if !upright && style.width.is_none() && members.len() == 1 { + turned_line_width(first, crop, [dx, dy], width, &text, &advance) + } else { + width + }; + let right_edge = if anchored_right { right_edge } else { width }; + let wrapped = wrap(&text, width + 0.5, |s| advance.of(s)); + let font = choose_font( + &FontInputs { + source: source_metrics, + type3: type3.as_ref(), + font_name: &font_name, + flat: &flat, + source_base_font: &b.base_font, + family, + bold, + italic, + family_requested: style.family.is_some(), + }, + &mut fonts_res, + )?; + let lead = line_lead(&b, &lines, style, &LeadScale { k, size, old_size, leading: o_state.leading }); + let start = last.ops.first().copied().unwrap_or(emit.bt_op); + let split = shows_before_paragraph(&ops, emit.bt_op, start, &dropped); + // What the text after the paragraph expects, and what is in force where the paragraph is + // drawn: the state at its BT (or where it was split). + let after = if split { &emit.state } else { &emit.bt_state }; + // Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3); see `write_line`. + let word_spacing_reaches_spaces = match source_metrics { + Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))), + None => type3.is_none(), + }; + let writer = LineWriter { + advance: &advance, + alignment: Alignment { width, k, anchored_right, right_edge, rtl }, + encode: &font.encode, + source: source_metrics, + align, + aligns: &style.aligns, + word_spacing_reaches_spaces, + width, + scale: o_state.scale, + k, + size, + space: source.space, + char_spacing: o_state.char_spacing, + lead, + }; + let plan = + BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) }; + let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; + let n = pdfcraft_content::num; + block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); + if split { + block_ops.insert(0, Op::new("ET", vec![])); + block_ops.push(Op::new("BT", vec![])); + block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); + } + let at = if split { start } else { emit.bt_op }; + let new_data = splice(&pieces, &ops, |i| { + let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; + (insert, !dropped.contains(&i)) + }); + let contents = rewritten_contents(doc, &streams, new_data); + let new_font = !reuse; + if new_font { + res.set(b"Font".to_vec(), Object::Dict(fonts_res)); + } + let page_ref = p.obj; + doc.update_dict(page_ref, |d| { + d.set(b"Contents".to_vec(), contents); + if new_font { + d.set(b"Resources".to_vec(), Object::Dict(res)); + } + })?; + Ok(LineEdit { substituted: font.substituted }) +} + +/// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a +/// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text. +/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it. +pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result, EditError> { + if !block.invisible { + return Ok(Vec::new()); + } + let n = pdfcraft_content::num; + let mut ops = Vec::new(); + if let Some(colour) = style.cover { + if colour.iter().any(|c| !c.is_finite() || !(0.0..=1.0).contains(c)) { + return Err(EditError::Invalid("the cover colour must be RGB values from 0 to 1".into())); + } + let back = Matrix(ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be covered in".into()))?; + ops.push(Op::new("q", vec![])); + ops.push(Op::new("rg", colour.map(n).to_vec())); + for rect in block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect) { + // Scanned ink reaches a little past the recognised box. + let pad = ((rect[3] - rect[1]).abs() * 0.1).clamp(0.5, 2.0); + let corners = + [[rect[0] - pad, rect[1] - pad], [rect[2] + pad, rect[1] - pad], [rect[2] + pad, rect[3] + pad], [rect[0] - pad, rect[3] + pad]]; + for (i, [x, y]) in corners.into_iter().enumerate() { + let (x, y) = back.apply(x, y); + if !(x.is_finite() && y.is_finite()) { + return Err(EditError::Invalid("the paragraph's area can't be covered".into())); + } + ops.push(Op::new(if i == 0 { "m" } else { "l" }, vec![n(x), n(y)])); + } + ops.push(Op::new("h", vec![])); + } + ops.push(Op::new("f", vec![])); + ops.push(Op::new("Q", vec![])); + } + ops.push(Op::new("Tr", vec![Object::Int(0)])); + Ok(ops) +} + +/// Existing paragraph indexes, excluding streams owned by an added-content item. +pub(crate) fn selectable_blocks( + doc: &Document, + page: usize, + excluded: &std::collections::BTreeSet, +) -> Result, EditError> { + let p = page_dict(doc, page)?; + let streams = content_streams(doc, &p.dict)?; + let lines = text_lines(doc, page)?; + Ok(group_blocks(&lines) + .into_iter() + .enumerate() + .filter(|(_, b)| { + !b.lines + .iter() + .filter_map(|i| lines.get(*i)) + .any(|l| streams.get(l.stream).and_then(|(o, _)| o.as_ref()).is_some_and(|r| excluded.contains(&r))) + }) + .collect()) +} + +/// The vertical advance per unit of font size of a font that writes vertically: `/DW2`'s w1 +/// (default −1000, §9.7.4.3), or `None` for horizontal text. Per-glyph `/W2` entries are not +/// read. An encoding whose writing mode can't be read is an error, as in [`vertical_encoding`]; +/// listing text treats it as horizontal and records the error (see [`Ts::unreadable`]). +pub(super) fn vertical_advance(doc: &Document, font: &Dict) -> Result, EditError> { + if !vertical_encoding(doc, font)? { + return Ok(None); + } + let descendant = font.get(b"DescendantFonts").and_then(|d| doc.resolve(d).as_array().and_then(|a| a.first().cloned())); + let w1 = descendant + .and_then(|d| doc.resolve(&d).as_dict().and_then(|d| d.get(b"DW2").cloned())) + .and_then(|a| doc.resolve(&a).as_array().and_then(|a| a.get(1).cloned())) + .and_then(|w| doc.resolve(&w).as_f64()) + .filter(|w| w.is_finite()) + .unwrap_or(-1000.0); + Ok(Some(w1 / 1000.0)) +} + +/// Detect vertical CMaps before moving horizontal text. A broken/cyclic embedded CMap is +/// refused rather than guessing which direction TJ advances its text matrix. +fn vertical_encoding(doc: &Document, font: &Dict) -> Result { + if font.name(b"Subtype") != Some(b"Type0") { + return Ok(false); + } + let mut next = font.get(b"Encoding").cloned(); + let mut seen = std::collections::BTreeSet::new(); + for _ in 0..8 { + let Some(o) = next.take() else { return Ok(false) }; + if let Some(r) = o.as_ref() + && !seen.insert(r) + { + return Err(EditError::Invalid("the text encoding is cyclic".into())); + } + match &*doc.resolve(&o) { + Object::Name(n) => return Ok(n.ends_with(b"-V")), + Object::Stream(stream) => { + if let Some(mode) = stream.dict.get(b"WMode") { + match mode.as_int() { + Some(1) => return Ok(true), + Some(0) => {} + _ => return Err(EditError::Invalid("the text writing mode is invalid".into())), + } + } + let bytes = stream + .decoded_within(8 * 1024 * 1024) + .map_err(|_| EditError::Invalid("the text encoding cannot be decoded within its size limit".into()))?; + let ops = parse(&bytes).ops; + if let Some(mode) = ops.iter().rev().find(|op| op.is("def") && op.name(0) == Some(b"WMode")) { + return match mode.num(1) { + Some(0.0) => Ok(false), + Some(1.0) => Ok(true), + _ => Err(EditError::Invalid("the text writing mode is invalid".into())), + }; + } + next = stream + .dict + .get(b"UseCMap") + .cloned() + .or_else(|| ops.iter().rev().find(|op| op.is("usecmap")).and_then(|op| op.operands.first().cloned())); + } + _ => return Err(EditError::Invalid("the text encoding is invalid".into())), + } + } + Err(EditError::Invalid("the text encoding is nested too deeply".into())) +} + +/// Translate paragraphs together without re-encoding glyphs, changing fonts or reflowing. +/// Numeric TJ offsets and text rise temporarily move each selected show operation, then +/// restore both advances and rise. The PDF reader advances the original glyph bytes itself: +/// no approximate font metrics are used to position following, unselected text (§9.4.4). +pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { + if indexes.is_empty() { + return Ok(()); + } + let p = page_dict(doc, page)?; + let streams = content_streams(doc, &p.dict)?; + let lines = text_lines(doc, page)?; + let blocks = group_blocks(&lines); + let mut selected = std::collections::BTreeSet::new(); + for index in indexes { + let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?; + for line in &b.lines { + let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?; + l.ensure_editable()?; + selected.extend(l.ops.iter().copied()); + } + } + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; + let mut patches = HashMap::new(); + // Keep full precision: a tiny rounded text-space change can be magnified by a CTM, + // and restoring rise/spacing must not alter following unselected text. + let n = Object::Real; + for s in shown.into_iter().filter(|s| selected.contains(&s.op)) { + if !(0..=3).contains(&s.state.render_mode) { + return Err(EditError::Invalid("text used as a clipping path cannot be moved in a group".into())); + } + let font = fonts + .get(&s.font) + .map(|f| doc.resolve(f)) + .and_then(|f| f.as_dict().cloned()) + .ok_or_else(|| EditError::Invalid("a selected paragraph has no font".into()))?; + if vertical_encoding(doc, &font)? { + return Err(EditError::Invalid("vertical text cannot be moved in a group".into())); + } + let inverse = s.tm.then(&s.state.ctm).invert().ok_or_else(|| EditError::Invalid("a selected paragraph has no area".into()))?; + let [a, b, c, d, _, _] = inverse.0; + let (dx, dy) = (a * offset[0] + c * offset[1], b * offset[0] + d * offset[1]); + let denominator = s.state.size * s.state.scale; + let shift = if dx == 0.0 { + 0.0 + } else if denominator.abs() > 1e-12 { + -1000.0 * dx / denominator + } else { + return Err(EditError::Invalid("a selected paragraph has no horizontal advance".into())); + }; + let rise = s.state.rise + dy; + if ![shift, rise, dx, dy].iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the paragraph move is too large".into())); + } + let original = ops.get(s.op).ok_or_else(|| EditError::Invalid("a selected text operation is missing".into()))?; + let mut replacement = Vec::new(); + let show = if original.is("'") || original.is("\"") { + if original.is("\"") { + let spacing = original + .operands + .get(..2) + .filter(|a| a.iter().all(|o| o.as_f64().is_some())) + .ok_or_else(|| EditError::Invalid("a text operation has invalid spacing".into()))?; + for (operator, operand) in ["Tw", "Tc"].into_iter().zip(spacing) { + replacement.push(Op::new(operator, vec![operand.clone()])); + } + } + replacement.push(Op::new("T*", vec![])); + Op::new("Tj", vec![original.operands.last().cloned().ok_or_else(|| EditError::Invalid("a text operation has no string".into()))?]) + } else { + original.clone() + }; + if dy != 0.0 { + replacement.push(Op::new("Ts", vec![n(rise)])); + } + if shift != 0.0 { + replacement.push(Op::new("TJ", vec![Object::Array(vec![n(shift)])])); + } + replacement.push(show); + if shift != 0.0 { + replacement.push(Op::new("TJ", vec![Object::Array(vec![n(-shift)])])); + } + if dy != 0.0 { + replacement.push(Op::new("Ts", vec![n(s.state.rise)])); + } + patches.insert(s.op, replacement); + } + if patches.len() != selected.len() { + return Err(EditError::Invalid("a selected text operation cannot be moved".into())); + } + let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false))); + let contents = rewritten_contents(doc, &streams, data); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index e08068d9f5..83046bfbf7 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -1,847 +1,16 @@ //! Geometry-based regions and mixed-style rewriting of existing text. -use super::*; +use super::{ + BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, + RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, + no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family, + source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs, +}; -fn same_plane(a: &TextLine, b: &TextLine) -> bool { - let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); - let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm)); - a.scope == b.scope - && [0, 2].iter().all(|axis| { - let ax = at.0.get(*axis).copied().unwrap_or(0.0); - let ay = at.0.get(axis + 1).copied().unwrap_or(0.0); - let bx = bt.0.get(*axis).copied().unwrap_or(0.0); - let by = bt.0.get(axis + 1).copied().unwrap_or(0.0); - let al = ax.hypot(ay); - let bl = bx.hypot(by); - al > 0.0 && bl > 0.0 && (ax / al - bx / bl).abs() < 0.001 && (ay / al - by / bl).abs() < 0.001 - }) -} +#[path = "text_regions/grouping.rs"] +mod grouping; +#[path = "text_regions/styled.rs"] +mod styled; -fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { - row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) -} - -/// [`row_rect`] in its text's own frame ([`TextLine::frame_rect`]), so rows of turned text -/// compare along and across their baselines. -fn frame_row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { - row.iter().filter_map(|i| lines.get(*i)).map(TextLine::frame_rect).reduce(union) -} - -pub(super) fn group_blocks(lines: &[TextLine]) -> Vec { - group_layout(lines, true) -} - -/// Exporters currently represent a block with one style. Keep their style boundaries until -/// they support rich runs, while edit selection may encompass the complete visual region. -pub(super) fn group_reading_blocks(lines: &[TextLine]) -> Vec { - group_layout(lines, false) -} - -/// Text-space units → user space along the baseline. It is the vertical scale ([`Origin::k`]) -/// unless the text matrix stretches the glyphs, as an OCR layer fitting each word to the scan does. -fn across(line: &TextLine) -> f64 { - let m = Matrix(line.origin.tm).then(&Matrix(line.origin.ctm)).0; - let h = m[0].hypot(m[1]); - if h.is_finite() && h > 1e-9 { h } else { line.origin.k } -} - -/// Invisible text (rendering mode 3), as in an OCR layer over a scanned image. -fn invisible(line: &TextLine) -> bool { - line.origin.state.render_mode == 3 -} - -fn same_style(a: &TextLine, b: &TextLine) -> bool { - same_font(a, b.font.as_bytes(), b.face.as_deref()) - && a.base_font == b.base_font - && a.bold == b.bold - && a.italic == b.italic - && a.color == b.color - && (a.rotation() - b.rotation()).abs() < 0.01 - && (a.size - b.size).abs() < 0.01 -} - -fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { - let mut rows: Vec> = Vec::new(); - for (i, line) in lines.iter().enumerate() { - let joins = rows.last().and_then(|r| r.last()).and_then(|i| lines.get(*i)).is_some_and(|prev| { - let gap = line.frame_rect()[0] - prev.frame_rect()[2]; - // OCR words (invisible text over a scan) join only across a word space: a wider gap - // is a table cell or a tab stop, which a rewrite would close up. - let scanned = !mixed || (invisible(prev) == invisible(line) && (!invisible(line) || gap < prev.size.max(line.size) * 0.6)); - scanned - && (mixed || same_style(prev, line)) - && same_plane(prev, line) - && (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.min(line.size) * 0.2 - && gap >= -prev.size.min(line.size) * 0.2 - && gap < prev.size.max(line.size) * 1.5 - // Separate same-style text objects that merely touch or overlap, with no space - // between them, are separate labels (for example a paragraph dragged against - // another, #594). Style runs and words separated by a space or gap still join. - && (prev.origin.bt_op == line.origin.bt_op - || gap > prev.size.min(line.size) * 0.01 - || !same_style(prev, line) - || prev.text.ends_with(char::is_whitespace) - || line.text.starts_with(char::is_whitespace)) - }); - if joins && let Some(row) = rows.last_mut() { - row.push(i); - } else { - rows.push(vec![i]); - } - } - let mut blocks: Vec = Vec::new(); - for row in rows { - let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue }; - let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue }; - let joins = blocks.last().is_some_and(|block| { - let Some(prev_row) = block.rows.last() else { return false }; - let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false }; - let Some(pr) = frame_row_rect(prev_row, lines) else { return false }; - let gap = prev.frame_origin().1 - first.frame_origin().1; - let tol = prev.size.max(first.size).max(1.0); - let aligned = - (pr[0] - frame[0]).abs() < tol || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol || (pr[2] - frame[2]).abs() < tol; - // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. - (!mixed || !(invisible(prev) || invisible(first))) - && (mixed || same_style(prev, first)) - && same_plane(prev, first) - && aligned - && first.size.min(prev.size) >= first.size.max(prev.size) * 0.4 - && gap > first.size.min(prev.size) * 0.8 - && gap < first.size.max(prev.size) * 2.5 - }); - if !joins { - blocks.push(TextBlock { - text: String::new(), - editor_text: String::new(), - spans: Vec::new(), - rect, - base_font: first.base_font.clone(), - size: first.size, - bold: first.bold, - italic: first.italic, - color: first.color, - rotation: first.rotation(), - lines: Vec::new(), - rows: Vec::new(), - invisible: true, - }); - } - let Some(block) = blocks.last_mut() else { continue }; - let mut row_text = String::new(); - if !block.rows.is_empty() { - block.editor_text.push('\n'); - } - for &index in &row { - let Some(line) = lines.get(index) else { continue }; - // Some producers position separate words without encoding a space. - if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i)) - && !row_text.ends_with(char::is_whitespace) - && !line.text.starts_with(char::is_whitespace) - && line.frame_rect()[0] - prev.frame_rect()[2] > line.size * 0.12 - { - row_text.push(' '); - block.editor_text.push(' '); - } - let start = block.editor_text.len(); - block.editor_text.push_str(&line.text); - block.spans.push((start..block.editor_text.len(), index)); - row_text.push_str(&line.text); - block.invisible &= line.origin.state.render_mode == 3; - block.lines.push(index); - } - if !block.text.is_empty() { - if block.text.ends_with('-') { - block.text.pop(); - } else { - block.text.push(' '); - } - } - block.text.push_str(row_text.trim_matches(paragraph_separator)); - block.rect = union(block.rect, rect); - block.rows.push(row); - } - blocks -} - -/// Shape in the source or an explicitly registered matching full face. Never use interface fonts. -fn shape_source( - doc: &Document, - fonts: &Dict, - name: &str, - text: &str, -) -> Result, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> { - if !pdfcraft_fonts::source_shaping::required(text) { - return Ok(None); - } - let source = - fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?; - let family = Metrics::from_dict(doc, &source).base_font; - let mut candidates = vec![source]; - candidates.extend( - fonts - .iter() - .filter_map(|(_, f)| doc.dict(f)) - .filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)), - ); - for font in candidates { - if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) - && pdfcraft_fonts::embedding::covers(&bytes, text) - && let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text) - { - return Ok(Some((bytes, shaped, family))); - } - } - Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) -} - -/// For each output row (a range of the draft's characters), whether it is exactly the source row -/// at the same position: the same characters, none of them inserted or changed by the edit. -fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range]) -> Vec { - let old: Vec = block.editor_text.chars().collect(); - let new: Vec = draft.chars().collect(); - let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); - let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count(); - // The source character a draft character is, if the edit left it alone. - let origin = |i: usize| { - if i < prefix { - Some(i) - } else if i >= new.len().saturating_sub(suffix) { - (old.len() + i).checked_sub(new.len()) - } else { - None - } - }; - let mut source_rows = Vec::new(); - let mut start = 0; - for (i, ch) in old.iter().enumerate() { - if *ch == '\n' { - source_rows.push(start..i); - start = i + 1; - } - } - source_rows.push(start..old.len()); - // A line break inside a source run would pair editor rows with the wrong visual rows. - if source_rows.len() != block.rows.len() { - return vec![false; rows.len()]; - } - rows.iter() - .enumerate() - .map(|(r, range)| { - source_rows.get(r).is_some_and(|source| { - !range.is_empty() && range.len() == source.len() && range.clone().zip(source.clone()).all(|(i, j)| origin(i) == Some(j)) - }) - }) - .collect() -} - -/// Rewrite visual rows while keeping the source style on each surviving character. Resource -/// names may differ between Unicode subsets of the same face; encoding searches those subsets. -pub(super) fn rewrite_styled_block( - doc: &mut Document, - page: usize, - block: &TextBlock, - lines: &[TextLine], - text: Option<&str>, - style: &BlockStyle, -) -> Result { - let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; - let draft = match text { - Some(t) if t != block.text => t, - _ => &block.editor_text, - }; - if draft.len() > 4_000_000 { - return Err(EditError::Invalid("the replacement text is too large".into())); - } - if draft == block.editor_text && *style == BlockStyle::default() { - return Ok(LineEdit { substituted: None }); - } - let mut ranges: Vec<_> = style.ranges.iter().collect(); - ranges.sort_by_key(|r| r.range.start); - if ranges.len() > 1024 - || ranges.iter().any(|r| { - r.range.start >= r.range.end - || draft.get(r.range.clone()).is_none() - || [r.size, r.char_spacing, r.scale].iter().flatten().any(|n| !n.is_finite()) - || r.size.is_some_and(|n| n <= 0.0 || n > 14400.0) - || r.color.is_some_and(|c| c.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))) - }) - || ranges.windows(2).any(|r| r[0].range.end > r[1].range.start) - { - return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); - } - let source_count = lines.len(); - let mut styled_lines = lines.to_vec(); - let mut styled_spans = Vec::new(); - let mut underlines = HashMap::new(); - let mut overrides = HashMap::new(); - for (span, source) in block.edited_spans(draft) { - let mut edges = vec![span.start, span.end]; - for r in &ranges { - for edge in [r.range.start, r.range.end] { - if span.contains(&edge) { - edges.push(edge); - } - } - } - edges.sort_unstable(); - edges.dedup(); - for bounds in edges.windows(2) { - let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; - let mut index = source; - let selected = ranges.iter().find(|r| r.range.contains(&start)).copied(); - if selected.is_some() || style.family.is_some() || style.bold.is_some() { - let default_range = RangeStyle { - range: start..end, - family: None, - resource: None, - size: None, - color: None, - underline: None, - char_spacing: None, - scale: None, - }; - let r = selected.unwrap_or(&default_range); - let mut line = lines.get(source).unwrap_or(first).clone(); - if let Some((family, bold, italic)) = - r.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) - { - let base = family.base_font(bold, italic); - line.font = format!("PCRange{}", base.replace('-', "")); - line.base_font = base.to_owned(); - line.bold = bold; - line.italic = italic; - } - if let Some(resource) = &r.resource { - line.font = resource.clone(); - } - if let Some(size) = r.size { - line.size = size; - } - if let Some(color) = r.color { - line.color = color; - line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; - } - if let Some(spacing) = r.char_spacing { - line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); - } - if let Some(scale) = r.scale { - line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); - } - index = styled_lines.len(); - styled_lines.push(line); - if let Some(r) = selected { - overrides.insert(index, r); - } - if let Some(underline) = r.underline { - underlines.insert(index, underline); - } - } - styled_spans.push((start..end, index)); - } - } - let lines = styled_lines.as_slice(); - let p = page_dict(doc, page)?; - let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(line.font.as_bytes().to_vec(), Object::Dict(f)); - } - let mut metrics = std::collections::BTreeMap::new(); - for (name, object) in fonts.iter().take(256) { - if let Some(font) = doc.resolve(object).as_dict() { - metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); - } - } - for index in &block.lines { - if let Some(line) = lines.get(*index) - && !metrics.contains_key(&line.font) - && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()) - { - metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); - } - } - if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|v| !v.is_finite()) - || style.color.is_some_and(|c| c.iter().any(|v| !v.is_finite())) - { - return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); - } - let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); - if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) { - return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); - } - // A visual row grows even when it contains several source/font runs. Multi-row - // paragraphs retain their container width; an explicit width always wins. - let natural = if block.invisible || block.rows.len() == 1 { - (p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) - } else { - block.rect[2] - block.rect[0] - }; - let width = style.width.unwrap_or(natural).clamp(1.0, 14_400.0); - // Each tuple is a character, its source style and an encodable resource name. - let mut chars = Vec::new(); - let mut substituted = None; - let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new(); - let mut full_faces: HashMap = HashMap::new(); - let mut programs: HashMap>>> = HashMap::new(); - for (range, index) in styled_spans { - let source = lines.get(index).unwrap_or(first); - for ch in draft.get(range).unwrap_or_default().chars() { - if ch == '\n' { - chars.push((ch, index, source.font.clone())); - continue; - } - let s = ch.to_string(); - let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { - if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) { - return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch)))); - } - resource.clone() - } else if metrics.get(&source.font).is_some_and(|m| source_encodes(m, &s)) { - source.font.clone() - } else if let Some((name, _)) = metrics.iter().find(|(_, m)| { - pdfcraft_fonts::embedding::face_key(&m.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) && source_encodes(m, &s) - }) { - name.clone() - } else if let Some(face) = - pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|f| pdfcraft_fonts::embedding::covers(f.bytes, &s)) - { - let key = face.name(); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the full source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else if let Some(bytes) = programs - .entry(source.font.clone()) - .or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new)) - .clone() - .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s)) - { - // The document's own complete program of this font has the glyph: draw it with - // that face rather than another family's fallback. - let key = format!("source {}", source.font); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else { - let family = source_family(&source.base_font); - if needs_type3(&s) { - let key = (source.base_font.rsplit('+').next().unwrap_or(&source.base_font).to_owned(), ch); - if let Some(name) = fallback_names.get(&key) { - chars.push((ch, index, name.clone())); - continue; - } - if fallback_names.len() >= MAX_TYPE3_GLYPHS { - return Err(EditError::Invalid("the replacement has too many fallback glyphs".into())); - } - let fallback = type3_font(doc, &mut fonts, &s, family, source.bold, MAX_TYPE3_GLYPHS)?; - let name = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; - let dict = fonts - .get(name.as_bytes()) - .and_then(|f| doc.resolve(f).as_dict().cloned()) - .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &dict)); - substituted = Some(fallback.family.to_string()); - fallback_names.insert(key, name.clone()); - name - } else { - let base = family.base_font(source.bold, source.italic); - let name = format!("PCEd{}", base.replace('-', "")); - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(doc, &f)); - fonts.set(name.clone().into_bytes(), Object::Dict(f)); - substituted = Some(base.to_string()); - name - } - }; - chars.push((ch, index, name)); - } - } - let size_of = |source: &TextLine, index: usize| { - overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) - }; - // A source run may carry TJ kerning/justification that is absent from its text state. - // Keep its measured advance when rebuilding the run instead of making it wrap on entry. - // Each font's space width, worked out once: when a font can't show a space, finding one - // reads every font on the page, and doing that for every character of a paragraph made each - // keystroke in a long paragraph take half a second (#2616). - // Only for the fonts the paragraph uses, when first asked: working out every page font's width - // up front would scan the page once per font for a short edit on a page of many fonts. - let spaces: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); - let space_of = |name: &str, m: &Metrics| { - if let Some(w) = spaces.borrow().get(name) { - return *w; - } - let w = source_space(doc, &fonts, m); - spaces.borrow_mut().insert(name.to_owned(), w); - w - }; - let mut calibration = HashMap::new(); - for (index, source) in lines.iter().enumerate() { - if let Some(m) = metrics.get(&source.font) { - let nominal = source_width( - m, - &source.text, - source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s), - space_of(&source.font, m), - source.origin.state.char_spacing, - source.origin.state.word_spacing, - ) * source.origin.state.scale - * across(source); - let changed_face = style.resource.is_some() || overrides.get(&index).is_some_and(|r| r.family.is_some() || r.resource.is_some()); - let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; - calibration.insert(index, factor); - } - } - let advance = |item: &(char, usize, String)| -> f64 { - let source = lines.get(item.1).unwrap_or(first); - let Some(m) = metrics.get(&item.2) else { return 0.0 }; - source_width( - m, - &item.0.to_string(), - size_of(source, item.1), - space_of(&item.2, m), - overrides - .get(&item.1) - .and_then(|r| r.char_spacing) - .or(style.char_spacing) - .map_or(source.origin.state.char_spacing, |c| c / source.origin.k.max(1e-6)), - source.origin.state.word_spacing, - ) * overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)) - * across(source) - * calibration.get(&item.1).copied().unwrap_or(1.0) - }; - let measure = |items: &[(char, usize, String)]| -> Result { - let mut width = 0.0; - let mut at = 0; - while let Some(item) = items.get(at) { - let mut end = at + 1; - while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { - end += 1; - } - let segment = items.get(at..end).unwrap_or_default(); - let string: String = segment.iter().map(|v| v.0).collect(); - if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? { - let source = lines.get(item.1).unwrap_or(first); - let scale = - overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)); - let spacing = overrides - .get(&item.1) - .and_then(|r| r.char_spacing) - .or(style.char_spacing) - .map_or(source.origin.state.char_spacing, |s| s / source.origin.k.max(1e-6)); - width += (shaped.advance * size_of(source, item.1) - + spacing * string.chars().count() as f64 - + source.origin.state.word_spacing * string.matches(' ').count() as f64) - * scale - * source.origin.k; - } else { - width += segment.iter().map(&advance).sum::(); - } - at = end; - } - Ok(width) - }; - let mut rows: Vec> = Vec::new(); - // Each row's characters, as indexes into the draft's characters (`chars` holds all of them). - let mut row_ranges = Vec::new(); - let mut row_start = 0; - let mut row = Vec::new(); - let mut row_width = 0.0; - // Wrap at word boundaries, retaining explicit source row breaks and whitespace. - let mut at = 0; - while at < chars.len() { - let Some(item) = chars.get(at) else { break }; - if item.0 == '\n' { - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - at += 1; - row_start = at; - continue; - } - let mut end = at + 1; - if !item.0.is_whitespace() { - // A word ends at a space, or where Chinese or Japanese text may break between characters. - while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end)) - && !c.0.is_whitespace() - && !crate::linebreak::between(before.0, c.0) - { - end += 1; - } - } - let word = chars.get(at..end).unwrap_or_default(); - let w = measure(word)?; - if !row.is_empty() && !item.0.is_whitespace() && row_width + w > width + 0.5 { - while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) { - row.pop(); - } - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - row_start = at; - } - row.extend_from_slice(word); - row_width += w; - at = end; - } - if !row.is_empty() { - row_ranges.push(row_start..row_start + row.len()); - rows.push(row); - } - let streams = content_streams(doc, &p.dict); - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); - let ops = pieces.parse().ops; - // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and - // glyph widths stay exactly as they were instead of being rebuilt from the text state. - let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default(); - let mut kept = if plain { - untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect() - } else { - vec![false; rows.len()] - }; - let kept_lines: std::collections::HashSet = - kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); - let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); - // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted - // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. - let lead = - rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); - let (members, lead) = match lead { - Some(lead) if !kept_lines.is_empty() => (rewritten, lead), - _ => { - kept.iter_mut().for_each(|k| *k = false); - let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); - let last = all.last().copied().unwrap_or(first); - (all, last) - } - }; - let o = &lead.origin; - for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { - let text: String = row.iter().map(|v| v.0).collect(); - if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { - return Err(EditError::Invalid( - "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" - .into(), - )); - } - } - let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::>()?; - let mut shaped_runs = HashMap::new(); - let mut segment_advances = HashMap::new(); - for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) { - let mut at = 0; - while let Some(item) = row.get(at) { - let mut end = at + 1; - while row.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { - end += 1; - } - segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::()); - let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect(); - if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? { - shaped_runs.insert((row_index, at), (bytes, shaped, family)); - } - at = end; - } - } - let mut glyph_resources = HashMap::new(); - for (key, (bytes, shaped, family)) in &shaped_runs { - let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect(); - let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; - glyph_resources.insert(*key, resource); - } - let n = pdfcraft_content::num; - let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); - let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0]) - .invert() - .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; - let (tx, ty) = back.apply(dx, dy); - block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); - let centre = (block.rect[0] + block.rect[2]) / 2.0; - let lefts: Vec<_> = block.rows.iter().filter_map(|r| row_rect(r, lines).map(|r| r[0])).collect(); - let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min); - let centered = block.rows.len() > 1 - && left_span > first.size * 0.5 - && block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < first.size)); - let source_leading = block - .rows - .windows(2) - .filter_map(|rows| { - let a = rows[0].first().and_then(|i| lines.get(*i))?; - let b = rows[1].first().and_then(|i| lines.get(*i))?; - Some((a.origin.baseline - b.origin.baseline).abs()) - }) - .rfind(|v| *v > 0.0) - .unwrap_or(first.size * 1.5); - let mut baseline = first.origin.baseline; - for (row_index, row) in rows.iter().enumerate() { - let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i)); - if style.line_spacing.is_none() - && style.size.is_none() - && let Some(anchor) = anchor - { - baseline = anchor.origin.baseline; - } - let anchor = anchor.unwrap_or(first); - if kept.get(row_index).copied().unwrap_or(false) { - // Kept rows only occur without spacing or size changes, so this is the step below. - baseline -= source_leading; - continue; - } - let w = measured.get(row_index).copied().unwrap_or(0.0); - let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); - let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect); - let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] }; - let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width }; - let mut x = left - + match align { - crate::added::Align::Center => (available - w) / 2.0, - crate::added::Align::Right => available - w, - _ => 0.0, - }; - let mut pos = 0; - while pos < row.len() { - let Some(item) = row.get(pos) else { break }; - let source = lines.get(item.1).unwrap_or(first); - let mut end = pos + 1; - while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) { - end += 1; - } - let segment = row.get(pos..end).unwrap_or_default(); - let string: String = segment.iter().map(|c| c.0).collect(); - let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?; - let mut state = source.origin.state.clone(); - state.font = Some((item.2.clone(), size_of(source, item.1))); - if let Some(c) = overrides.get(&item.1).and_then(|r| r.char_spacing).or(style.char_spacing) { - state.char_spacing = c / source.origin.k.max(1e-6); - } - if let Some(s) = overrides.get(&item.1).and_then(|r| r.scale).or(style.scale) { - state.scale = (s / 100.0).clamp(0.1, 10.0); - } - if !shaped_runs.contains_key(&(row_index, pos)) { - state.scale *= calibration.get(&item.1).copied().unwrap_or(1.0); - } - if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) { - state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])]; - } - let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; - let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0; - let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline); - tm[4] += ax; - tm[5] += ay; - let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0); - if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { - let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; - let mut properties = Dict::new(); - properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string))); - block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); - state.font = Some((resource.clone(), size_of(source, item.1))); - // Absolute glyph matrices preserve marks and contextual positions; font-program - // codes are separate from Unicode and ToUnicode/ActualText keep logical text. - let size = size_of(source, item.1); - let mut extra = 0.0; - for (i, glyph) in shaped.glyphs.iter().enumerate() { - let mut matrix = tm; - let x = (glyph.position[0] * size + extra) * state.scale; - let y = glyph.position[1] * size; - matrix[4] += matrix[0] * x + matrix[2] * y; - matrix[5] += matrix[1] * x + matrix[3] * y; - let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; - block_ops.push(Op::new("BT", vec![])); - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", matrix.map(n).to_vec())); - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.to_be_bytes().to_vec()))])); - block_ops.push(Op::new("ET", vec![])); - extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64; - } - block_ops.push(Op::new("EMC", vec![])); - segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k; - } else { - block_ops.push(Op::new("BT", vec![])); - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); - block_ops.extend(source_show( - m, - &string, - size_of(source, item.1), - spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)), - state.char_spacing, - state.word_spacing, - )?); - block_ops.push(Op::new("ET", vec![])); - } - if underlines.get(&item.1).copied().or(style.underline) == Some(true) { - let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); - let underline_size = size_of(source, item.1) * source.origin.k; - let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); - let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); - block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); - block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); - block_ops.push(Op::new("m", vec![n(x0), n(y0)])); - block_ops.push(Op::new("l", vec![n(x1), n(y1)])); - block_ops.push(Op::new("S", vec![])); - } - x += segment_width; - pos = end; - } - baseline -= if style.line_spacing.is_none() && style.size.is_none() { - source_leading - } else { - style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) - }; - } - block_ops.push(Op::new("Q", vec![])); - let mut drop = coincident_ops(lines, &members); - drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let start = lead.ops.first().copied().unwrap_or(o.bt_op); - let split = ops - .get(o.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); - let after = if split { &o.state } else { &o.bt_state }; - block_ops.extend(after.ops()); - if split { - block_ops.insert(0, Op::new("ET", vec![])); - block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); - } - let at = if split { start } else { o.bt_op }; - let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i))); - let contents = rewritten_contents(doc, &streams, data); - res.set(b"Font".to_vec(), Object::Dict(fonts)); - doc.update_dict(p.obj, |d| { - d.set(b"Contents".to_vec(), contents); - d.set(b"Resources".to_vec(), Object::Dict(res)); - })?; - Ok(LineEdit { substituted }) -} +pub(super) use grouping::{alignment_frame, group_blocks, group_reading_blocks, is_centred}; +pub(super) use styled::rewrite_styled_block; diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs new file mode 100644 index 0000000000..788499fafe --- /dev/null +++ b/crates/edit/src/text_regions/grouping.rs @@ -0,0 +1,454 @@ +use super::{BlockStyle, HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union}; + +fn same_plane(a: &TextLine, b: &TextLine) -> bool { + let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); + let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm)); + a.scope == b.scope + && a.paint == b.paint + && [0, 2].iter().all(|axis| { + let ax = at.0.get(*axis).copied().unwrap_or(0.0); + let ay = at.0.get(axis + 1).copied().unwrap_or(0.0); + let bx = bt.0.get(*axis).copied().unwrap_or(0.0); + let by = bt.0.get(axis + 1).copied().unwrap_or(0.0); + let al = ax.hypot(ay); + let bl = bx.hypot(by); + al > 0.0 && bl > 0.0 && (ax / al - bx / bl).abs() < 0.001 && (ay / al - by / bl).abs() < 0.001 + }) +} + +pub(super) fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { + row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) +} + +/// [`row_rect`] in its text's own frame ([`TextLine::frame_rect`]), so rows of turned text +/// compare along and across their baselines. +fn frame_row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { + row.iter().filter_map(|i| lines.get(*i)).map(TextLine::frame_rect).reduce(union) +} + +/// The left edge the rest of the page's text sits on, ignoring the rows of `block`. A single row +/// that does not share it is not merely an indented or full-width left-aligned line, so its own +/// centre is the only remaining explanation for where it sits. +fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { + let mut edges: HashMap = HashMap::new(); + for (i, line) in lines.iter().enumerate() { + if block.lines.contains(&i) { + continue; + } + // Quarter-point buckets: equal edges found through different runs still agree. + *edges.entry((line.rect[0] * 4.0).round() as i64).or_insert(0) += 1; + } + let (best, _) = edges.into_iter().max_by_key(|(_, count)| *count)?; + Some(best as f64 / 4.0) +} + +/// Where an alignment picked for a one-row paragraph is measured, as `[left, right]`: the column of +/// text it sits in (an overlapping paragraph's left edge, to the furthest right its rows reach) +/// when that column holds it, otherwise its own box. Of the columns that hold it, the narrowest is +/// its own, the nearest on a tie: a heading across the whole page, even one closer than the body, +/// is wider than the column the title sits in, and gives it no frame while a narrower one does. A lone row's own box is no frame +/// to align in: left-aligning a title centred on the page left it where it was, and since a single +/// row grows to the page's margin, centring it again moved it right each time. `None` when no +/// alignment is asked for, when the paragraph is several rows, turned, or given a width or a move of +/// its own, or when its `draft` text changes and no column holds it. +pub(crate) fn alignment_frame(lines: &[TextLine], blocks: &[TextBlock], block: &TextBlock, style: &BlockStyle, draft: &str) -> Option<[f64; 2]> { + // Every row of the draft is aligned, by its own alignment or the paragraph's: moving the + // paragraph to its column's edge would move a row that has none. Entries past the draft's + // rows name no row. + let asked = (0..crate::paragraph_align::count(draft)).all(|row| style.aligns.get(row).copied().flatten().or(style.align).is_some()); + if !asked || style.width.is_some() || style.offset.is_some() || block.rows.len() != 1 || block.rotation.abs() > 0.01 { + return None; + } + let own = [block.rect[0], block.rect[2]]; + let tolerance = block.size.max(1.0) * 0.5; + let gap = |other: &TextBlock| (other.rect[1] - block.rect[3]).max(block.rect[1] - other.rect[3]).max(0.0); + // The column an overlapping paragraph makes, when it holds the row. + let column = |other: &TextBlock| -> Option<[f64; 2]> { + let left = other.rect[0]; + // Whole rows, not their runs: a row of several styles reaches its last run's right edge. + let right = other + .rows + .iter() + .filter_map(|row| row_rect(row, lines)) + .filter(|row| (row[0] - left).abs() < tolerance) + .map(|row| row[2]) + .fold(f64::NEG_INFINITY, f64::max); + (right.is_finite() && right - left > own[1] - own[0] && own[0] >= left - tolerance && own[1] <= right + tolerance).then_some([left, right]) + }; + let frame = blocks + .iter() + .filter(|other| other.lines.iter().all(|i| !block.lines.contains(i)) && other.rect[2] > own[0] && other.rect[0] < own[1]) + .filter_map(|other| column(other).map(|frame| (frame, gap(other)))) + .min_by(|(a, near_a), (b, near_b)| (a[1] - a[0]).total_cmp(&(b[1] - b[0])).then(near_a.total_cmp(near_b))) + .map(|(frame, _)| frame); + // The row's own box, with no column to align in, is no width for new text to wrap at: edited, + // the row keeps the width it would have had without the alignment. + match frame { + Some(frame) => Some(frame), + None if draft != block.editor_text => None, + None => Some(own), + } +} + +/// Whether a block reads as centred, and so must keep its centre when it grows or wraps. +/// +/// Several ragged rows whose left edges disagree while their centres agree are centred. A one row +/// block cannot say that: all of its left edges are the same, so the ragged test never fires and a +/// centred title would be taken for left-aligned and grow to the right (#2581). Fall back to the +/// page instead — a lone row sitting on the page's own centre, and not on the edge the rest of the +/// text uses, is a centred title. A full-width left-aligned line still lines up with that edge, so +/// it is not mistaken for one. +pub(crate) fn is_centred(block: &TextBlock, lines: &[TextLine], page_centre: f64, size: f64) -> bool { + let centre = (block.rect[0] + block.rect[2]) / 2.0; + let lefts: Vec<_> = block.rows.iter().filter_map(|r| row_rect(r, lines).map(|r| r[0])).collect(); + if block.rows.len() > 1 { + let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min); + return left_span > size * 0.5 + && block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < size)); + } + lefts + .first() + .is_some_and(|left| (centre - page_centre).abs() < size && neighbour_left(lines, block).is_none_or(|edge| (*left - edge).abs() > size * 0.5)) +} + +pub(crate) fn group_blocks(lines: &[TextLine]) -> Vec { + group_layout(lines, true) +} + +/// Exporters currently represent a block with one style. Keep their style boundaries until +/// they support rich runs, while edit selection may encompass the complete visual region. +/// Dotted-leader entries stay on one row and use the title run's style. +pub(crate) fn group_reading_blocks(lines: &[TextLine]) -> Vec { + let mut blocks = Vec::new(); + let mut start = 0; + let mut offset = 0; + for row in lines.chunk_by(|a, b| same_plane(a, b) && (a.origin.baseline - b.origin.baseline).abs() < a.size.min(b.size) * 0.2) { + let mut text = String::new(); + for (i, line) in row.iter().enumerate() { + if !text.ends_with(char::is_whitespace) + && !line.text.starts_with(char::is_whitespace) + && i.checked_sub(1).and_then(|i| row.get(i)).is_some_and(|prev| line.rect[0] - prev.rect[2] > line.size * 0.12) + { + text.push(' '); + } + text.push_str(&line.text); + } + if leader_entry(&text) + && let Some(first) = row.first() + { + // Tab leaders are a single reading row, even when the title, dots and page + // number are separate text objects far apart. Do not merge neighbouring entries. + blocks.extend(group_layout(lines.get(start..offset).unwrap_or_default(), false)); + let mut line = first.clone(); + line.text = text; + line.rect = row.iter().map(|line| line.rect).reduce(union).unwrap_or(first.rect); + blocks.extend(group_layout(std::slice::from_ref(&line), false)); + start = offset + row.len(); + } + offset += row.len(); + } + blocks.extend(group_layout(lines.get(start..).unwrap_or_default(), false)); + blocks +} + +/// Dotted leaders end in a page number, with a title before the dots. A dot in an article +/// number, or an ellipsis in ordinary prose, is not enough to make a contents entry. +fn leader_entry(text: &str) -> bool { + let text = text.trim_end(); + let prefix = text.trim_end_matches(|c: char| { + c.is_ascii_digit() || matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm' | 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M') + }); + if prefix.len() == text.len() { + return false; + } + let title = prefix.trim_end_matches(|c: char| c.is_whitespace() || matches!(c, '.' | '…')); + let dots = prefix + .get(title.len()..) + .unwrap_or_default() + .chars() + .map(|c| match c { + '.' => 1, + '…' => 3, + _ => 0, + }) + .sum::(); + dots >= 3 && title.chars().any(char::is_alphabetic) +} + +/// Text-space units → user space along the baseline. It is the vertical scale ([`Origin::k`]) +/// unless the text matrix stretches the glyphs, as an OCR layer fitting each word to the scan does. +pub(super) fn across(line: &TextLine) -> f64 { + let m = Matrix(line.origin.tm).then(&Matrix(line.origin.ctm)).0; + let h = m[0].hypot(m[1]); + if h.is_finite() && h > 1e-9 { h } else { line.origin.k } +} + +/// Invisible text (rendering mode 3), as in an OCR layer over a scanned image. +fn invisible(line: &TextLine) -> bool { + line.origin.state.render_mode == 3 +} + +fn same_style(a: &TextLine, b: &TextLine) -> bool { + same_font(a, b.font.as_bytes(), &b.base_font, b.face.as_deref()) + // With no `/BaseFont` (or only a subset tag) there is no name to compare: the resource or + // fallback face `same_font` matched is the whole identity, so keep the lines together. + && (same_base_font(&a.base_font, &b.base_font) + || (strip_subset_tag(&a.base_font).is_empty() && strip_subset_tag(&b.base_font).is_empty())) + && a.bold == b.bold + && a.italic == b.italic + && a.color == b.color + && (a.rotation() - b.rotation()).abs() < 0.01 + && (a.size - b.size).abs() < 0.01 +} + +/// Does a row open with a list marker? A bullet or a dash, or an ordered marker — digits (or a +/// single letter) followed by `.` or `)` and then a space or the row's end, so prose such as +/// `1.5` or `Dr. Smith` is left alone. +fn opens_with_marker(text: &str) -> bool { + let Some(first) = text.chars().next() else { return false }; + if matches!(first, '•' | '◦' | '▪' | '‣' | '⁃' | '*' | '-' | '–' | '—') { + return true; + } + if !first.is_ascii_digit() && !first.is_ascii_alphabetic() { + return false; + } + let digits = text.chars().take_while(char::is_ascii_digit).count(); + let letters = text.chars().take_while(char::is_ascii_alphabetic).count(); + // Both runs are ASCII characters, so their length in characters is their length in bytes: + // the slice below starts on a character boundary. + let rest = if digits > 0 { + &text[digits..] + } else if letters == 1 { + &text[1..] + } else { + return false; + }; + let mut after = rest.chars(); + matches!(after.next(), Some('.' | ')')) && after.next().is_none_or(char::is_whitespace) +} + +/// A row that opens with a list marker opens its own item: joining it to the row above is what +/// let an edit of one paragraph delete every item after it (#2564). The other error, splitting +/// an ordinary paragraph, costs one more paragraph to click and never any text — so err this way. +fn starts_list_item(row: &[usize], lines: &[TextLine]) -> bool { + row.iter().filter_map(|i| lines.get(*i)).map(|l| l.text.trim_start()).find(|t| !t.is_empty()).is_some_and(opens_with_marker) +} + +fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { + let mut rows: Vec> = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let joins = rows.last().and_then(|r| r.last()).and_then(|i| lines.get(*i)).is_some_and(|prev| { + let gap = line.frame_rect()[0] - prev.frame_rect()[2]; + // OCR words (invisible text over a scan) join only across a word space: a wider gap + // is a table cell or a tab stop, which a rewrite would close up. + let scanned = !mixed || (invisible(prev) == invisible(line) && (!invisible(line) || gap < prev.size.max(line.size) * 0.6)); + scanned + && (mixed || same_style(prev, line)) + && same_plane(prev, line) + // A superscript or subscript run (a footnote marker, say) sits on the same visual + // row as the text around it, raised or lowered by `Ts` from the body baseline + // (#3252). That shift is typically up to half the body size, not the marker's own + // smaller size — but the wider tolerance only applies between a run and a + // distinctly smaller or larger one, with no horizontal overlap (`gap >= 0.0`): + // two equal-size runs can land within it too, at a baseline shift tight columns + // or compressed text (`Tz`) can produce between genuinely separate rows, and the + // original 20% tolerance is what keeps those apart. + && ((prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.min(line.size) * 0.2 + || (gap >= 0.0 + && prev.size.min(line.size) < prev.size.max(line.size) * 0.9 + && (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.max(line.size) * 0.5)) + && gap >= -prev.size.min(line.size) * 0.2 + && gap < prev.size.max(line.size) * 1.5 + // Separate same-style text objects that merely touch or overlap, with no space + // between them, are separate labels (for example a paragraph dragged against + // another, #594). Style runs and words separated by a space or gap still join. + && (prev.origin.bt_op == line.origin.bt_op + || gap > prev.size.min(line.size) * 0.01 + || !same_style(prev, line) + || prev.text.ends_with(char::is_whitespace) + || line.text.starts_with(char::is_whitespace)) + }); + if joins && let Some(row) = rows.last_mut() { + row.push(i); + } else { + rows.push(vec![i]); + } + } + let mut blocks: Vec = Vec::new(); + for row_at in 0..rows.len() { + let Some(row) = rows.get(row_at) else { continue }; + let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue }; + let (Some(rect), Some(frame)) = (row_rect(row, lines), frame_row_rect(row, lines)) else { continue }; + let joins = blocks.last().is_some_and(|block| { + // A bullet of its own opens a list item (#2564): such a row is a new paragraph, and + // letting it join is what made an edit of the row above delete this item and the ones + // after it. + if starts_list_item(row, lines) { + return false; + } + let Some(prev_row) = block.rows.last() else { return false }; + let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false }; + let Some(pr) = frame_row_rect(prev_row, lines) else { return false }; + let gap = prev.frame_origin().1 - first.frame_origin().1; + let tol = prev.size.max(first.size).max(1.0); + // The block opens with a list marker and this row sits further right: the item's text + // is at a hanging indent, so the row it wrapped onto continues it (#2564). A row at + // the block's own left edge is already covered by the match on `pr[0]`. Both boxes are + // in the frame, as the rest of this comparison is, so turned text matches too. + let hanging = frame[0] > pr[0] + && frame[0] - pr[0] <= tol * 3.0 + && block.lines.first().and_then(|&i| lines.get(i)).is_some_and(|l| opens_with_marker(l.text.trim_start())); + let aligned = (pr[0] - frame[0]).abs() < tol + || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol + || (pr[2] - frame[2]).abs() < tol + || hanging; + // Once a paragraph has two rows, its own leading is the measured baseline gap. A + // blank line is about twice that, so a wider gap is the next paragraph (#2517). A + // single row uses the text object's TL when it has one: a gap equal to that TL + // joins, and a gap of twice the TL does not. `Td` does not set TL (`TD` does). A + // step that repeats on the following row is that leading; one large gap, with no + // second step to compare, stays a break. The old cap of 2.5 × the size joined the + // blank line too. + let own_leading = block.rows.len().checked_sub(2).and_then(|i| { + let above = block.rows.get(i)?.first().and_then(|n| lines.get(*n)); + let below = prev_row.first().and_then(|n| lines.get(*n)); + match (above, below) { + (Some(above), Some(below)) => Some((above.frame_origin().1 - below.frame_origin().1).abs()), + _ => None, + } + }); + let stated = (prev.origin.state.leading > 0.0).then(|| prev.wrap_leading()).filter(|lead| lead.is_finite() && *lead > tol * 0.4); + // The next row is not in this block yet. Its gap is only a leading when it matches + // this one: a blank line is a single wider gap and has no matching step after it. + let repeated = rows.get(row_at.saturating_add(1)).and_then(|next| { + let nfirst = next.first().and_then(|i| lines.get(*i))?; + let nframe = frame_row_rect(next, lines)?; + let ngap = first.frame_origin().1 - nfirst.frame_origin().1; + let same = same_plane(first, nfirst) + && (mixed || same_style(first, nfirst)) + && first.size.min(nfirst.size) >= first.size.max(nfirst.size) * 0.4 + && (nframe[0] - frame[0]).abs() < tol; + let scale = gap.abs().max(ngap.abs()); + if same && ngap > tol * 0.8 && scale < tol * 4.0 && (ngap - gap).abs() <= scale * 0.08 + 0.5 { Some(gap) } else { None } + }); + let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).or(repeated).map_or(tol * 2.05, |lead| lead * 1.5); + // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. + (!mixed || !(invisible(prev) || invisible(first))) + && (mixed || same_style(prev, first)) + && same_plane(prev, first) + // The rewrite sets every row in the first one's horizontal scale. + && (prev.origin.state.scale - first.origin.state.scale).abs() < 1e-6 + && aligned + && first.size.min(prev.size) >= first.size.max(prev.size) * 0.4 + && gap > first.size.min(prev.size) * 0.8 + && gap < max_gap + }); + if !joins { + blocks.push(TextBlock { + text: String::new(), + editor_text: String::new(), + reflow_text: String::new(), + spans: Vec::new(), + rect, + base_font: first.base_font.clone(), + size: first.size, + bold: first.bold, + italic: first.italic, + color: first.color, + rotation: first.rotation(), + lines: Vec::new(), + rows: Vec::new(), + invisible: true, + }); + } + let Some(block) = blocks.last_mut() else { continue }; + let mut row_text = String::new(); + if !block.rows.is_empty() { + block.editor_text.push('\n'); + } + for &index in row { + let Some(line) = lines.get(index) else { continue }; + // Some producers position separate words without encoding a space. + if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i)) + && !row_text.ends_with(char::is_whitespace) + && !line.text.starts_with(char::is_whitespace) + && line.frame_rect()[0] - prev.frame_rect()[2] > line.size * 0.12 + { + row_text.push(' '); + block.editor_text.push(' '); + } + let start = block.editor_text.len(); + block.editor_text.push_str(&line.text); + block.spans.push((start..block.editor_text.len(), index)); + row_text.push_str(&line.text); + block.invisible &= line.origin.state.render_mode == 3; + block.lines.push(index); + } + if !block.text.is_empty() { + if block.text.ends_with('-') { + block.text.pop(); + } else { + block.text.push(' '); + } + } + block.text.push_str(row_text.trim_matches(paragraph_separator)); + block.rect = union(block.rect, rect); + block.rows.push(row.clone()); + } + for block in &mut blocks { + block.reflow_text = reflow_text(block, lines); + } + blocks +} + +/// The text a width change should reflow. A `/PCSoft` line is a wrap and rejoins. A `/PCHard` +/// line, or a short line that is not marked, keeps its newline. An unmarked line that reaches +/// the paragraph's right edge and is wide enough to be a measure rejoins too: that is how a +/// file written elsewhere wraps a body line. +fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String { + let parts: Vec<&str> = block.editor_text.split('\n').collect(); + if parts.len() != block.rows.len() { + return block.editor_text.clone(); + } + let mut out = String::new(); + for (index, part) in parts.iter().enumerate() { + if index > 0 { + match break_gap(block, lines, index) { + Some(_) if out.ends_with('-') => { + out.pop(); + } + // `gap` is the exact number of separator characters the wrap consumed (0 for a + // Chinese/Japanese word split, which has no space of its own) when the marker or + // the source recorded it; a line already ending in whitespace is left alone so a + // row whose own text kept a trailing space isn't doubled. + Some(gap) if !out.ends_with(char::is_whitespace) => out.push_str(&" ".repeat(gap)), + Some(_) => {} + None => out.push('\n'), + } + } + out.push_str(part); + } + out +} + +/// `None`: a hard break, kept as a newline. `Some(gap)`: a soft wrap, rejoined with exactly `gap` +/// spaces — the count a `/PCSoft` marker recorded ([`TextLine::soft_gap`]), or, for a line this +/// app did not mark (a file written elsewhere, or an older save), a single space when the +/// geometry looks like an ordinary wrapped body line (`#2529`). +fn break_gap(block: &TextBlock, lines: &[TextLine], row_index: usize) -> Option { + let line = block.rows.get(row_index).and_then(|row| row.first()).and_then(|i| lines.get(*i))?; + if line.hard_break { + return None; + } + if line.soft_break { + return Some(line.soft_gap); + } + let prev = block.rows.get(row_index.wrapping_sub(1)).and_then(|row| row_rect(row, lines))?; + let next = block.rows.get(row_index).and_then(|row| row_rect(row, lines))?; + let size = line.size.max(1.0); + let slack = block.rect[2] - prev[2]; + let width = prev[2] - prev[0]; + // Short rows define the right edge by their own letters. A body line that fills the + // measure leaves almost no room for the next word. + (slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack).then_some(1) +} diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs new file mode 100644 index 0000000000..885a0a7158 --- /dev/null +++ b/crates/edit/src/text_regions/styled.rs @@ -0,0 +1,1194 @@ +use super::grouping::{across, is_centred, row_rect}; +use super::{ + BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, + RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, + no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, source_show, source_space, source_width, + splice, type3_faces, type3_glyph, write_type3_glyphs, +}; + +// The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. +// A glyph budget worth 64 full batches (15,360 glyphs with the production batch size). +// Lowering the internal batch size for tests lowers the budget by the same ratio. +const MAX_STYLED_FALLBACK_BATCHES: usize = 64; + +struct FallbackGroup { + face: &'static CraftFont, + seen: std::collections::HashSet, + glyphs: Vec, + positions: Vec, +} + +struct FallbackBuilder { + groups: Vec, + names: std::collections::BTreeMap<(String, bool, char), usize>, + glyphs: usize, + per_font: usize, + budget: usize, +} + +struct FontResolver<'a> { + doc: &'a mut Document, + fonts: &'a mut Dict, + metrics: &'a mut std::collections::BTreeMap, + full_faces: HashMap, + programs: HashMap>>>, +} + +impl FontResolver<'_> { + fn encodable_font(&mut self, source: &TextLine, text: &str, requested: Option<&str>) -> Result, EditError> { + if let Some(resource) = requested { + if !self.metrics.get(resource).is_some_and(|metrics| source_encodes(metrics, text)) { + return Err(EditError::Invalid(format!( + "the selected font resource {resource} cannot show U+{:04X}", + text.chars().next().map_or(0, u32::from) + ))); + } + return Ok(Some(resource.to_owned())); + } + if self.metrics.get(&source.font).is_some_and(|metrics| source_encodes(metrics, text)) { + return Ok(Some(source.font.clone())); + } + if let Some((name, _)) = self.metrics.iter().find(|(_, metrics)| { + pdfcraft_fonts::embedding::face_key(&metrics.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) + && source_encodes(metrics, text) + }) { + return Ok(Some(name.clone())); + } + if let Some(face) = + pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|face| pdfcraft_fonts::embedding::covers(face.bytes, text)) + { + return self.embed_face(face.name(), face.bytes, face.family, "the full source font could not be loaded").map(Some); + } + let Some(bytes) = self + .programs + .entry(source.font.clone()) + .or_insert_with(|| source_program(self.doc, self.fonts, &source.font).map(std::sync::Arc::new)) + .clone() + .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, text)) + else { + return Ok(None); + }; + let key = format!("source {}", source.font); + if let Some(name) = self.full_faces.get(&key) { + return Ok(Some(name.clone())); + } + let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); + self.embed_face(key, &bytes, &family, "the source font could not be loaded").map(Some) + } + + fn embed_face(&mut self, key: String, bytes: &[u8], family: &str, missing_message: &str) -> Result { + if let Some(name) = self.full_faces.get(&key) { + return Ok(name.clone()); + } + let name = pdfcraft_fonts::embedding::embed(self.doc, self.fonts, bytes, family).map_err(EditError::Invalid)?; + let font = + self.fonts.get(name.as_bytes()).and_then(|resource| self.doc.dict(resource)).ok_or_else(|| EditError::Invalid(missing_message.into()))?; + self.metrics.insert(name.clone(), Metrics::from_dict(self.doc, &font)); + self.full_faces.insert(key, name.clone()); + Ok(name) + } + + fn substitute_font(&mut self, source: &TextLine) -> String { + let family = source_family(&source.base_font); + let base = family.base_font(source.bold, source.italic); + let name = format!("PCEd{}", base.replace('-', "")); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(base)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + self.metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(self.doc, &font)); + self.fonts.set(name.clone().into_bytes(), Object::Dict(font)); + name + } +} + +struct PreparedCharacters { + values: Vec, + substituted: Option, +} + +struct CharacterPreparation<'a> { + doc: &'a mut Document, + first: &'a TextLine, + lines: &'a [TextLine], + draft: &'a str, + spans: Vec<(std::ops::Range, usize)>, + style: &'a BlockStyle, + overrides: &'a HashMap, + fonts: &'a mut Dict, + metrics: &'a mut std::collections::BTreeMap, + max_glyphs: usize, +} + +impl CharacterPreparation<'_> { + fn prepare(self) -> Result { + let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self; + let mut chars = Vec::new(); + let mut substituted = None; + let mut fallback = FallbackBuilder::new(max_glyphs); + let mut resolver = FontResolver { doc, fonts, metrics, full_faces: HashMap::new(), programs: HashMap::new() }; + for (range, index) in spans { + let source = lines.get(index).unwrap_or(first); + let start = range.start; + for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { + if ch == '\n' { + chars.push((ch, index, source.font.clone())); + continue; + } + if ch == '\r' { + let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); + chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); + continue; + } + let text = ch.to_string(); + let requested = overrides.get(&index).and_then(|range| range.resource.as_deref()).or(style.resource.as_deref()); + let name = if let Some(name) = resolver.encodable_font(source, &text, requested)? { + name + } else if needs_type3(&text) { + fallback.prepare(source, ch, &text, chars.len())? + } else { + let name = resolver.substitute_font(source); + substituted = Some(source_family(&source.base_font).base_font(source.bold, source.italic).to_owned()); + name + }; + chars.push((ch, index, name)); + } + } + drop(resolver); + for group in fallback.groups { + let written = write_type3_glyphs(doc, fonts, group.face, false, group.glyphs, fallback.per_font); + for name in &written.names { + let font = fonts + .get(name.as_bytes()) + .and_then(|resource| doc.dict(resource)) + .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + } + for position in group.positions { + let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; + let (font, _) = written.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; + item.2 = written.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; + } + substituted = Some(written.family.to_owned()); + } + Ok(PreparedCharacters { values: chars, substituted }) + } +} + +impl FallbackBuilder { + fn new(max_glyphs: usize) -> Self { + let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); + Self { + groups: Vec::new(), + names: std::collections::BTreeMap::new(), + glyphs: 0, + per_font, + budget: per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES), + } + } + + fn prepare(&mut self, source: &TextLine, ch: char, text: &str, position: usize) -> Result { + let key = (source.base_font.clone(), source.bold, ch); + let group_index = if let Some(index) = self.names.get(&key) { + *index + } else { + let (faces, _) = type3_faces(text, source_family(&source.base_font), source.bold)?; + let face = fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(text))?; + let index = self.groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { + let index = self.groups.len(); + self.groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); + index + }); + self.names.insert(key, index); + index + }; + let group = self.groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; + if group.seen.insert(ch) { + if self.glyphs >= self.budget { + return Err(EditError::Invalid(format!( + "the replacement needs more than {} fallback glyphs; edit a smaller paragraph or load a complete font", + self.budget + ))); + } + let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; + let code = u8::try_from((group.glyphs.len() % self.per_font).saturating_add(1)) + .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; + group.glyphs.push(Type3Glyph { code, ch, path, width }); + self.glyphs = self.glyphs.saturating_add(1); + } + group.positions.push(position); + Ok(String::new()) + } +} + +struct StyledLines<'a> { + lines: Vec, + spans: Vec<(std::ops::Range, usize)>, + underlines: HashMap, + overrides: HashMap, +} + +type StyledChar = (char, usize, String); +type StyledRows = Vec>; +type RowRanges = Vec>; +type ShapedRunCache = HashMap<(usize, usize), (Vec, pdfcraft_fonts::source_shaping::ShapedRun, String)>; + +struct ShapedRows { + runs: ShapedRunCache, + advances: HashMap<(usize, usize), f64>, +} + +fn prepare_shaped_rows( + doc: &Document, + fonts: &Dict, + rows: &[Vec], + kept: &[bool], + advance: impl Fn(&StyledChar) -> f64, +) -> Result { + let mut runs = HashMap::new(); + let mut advances = HashMap::new(); + for (row_index, row) in rows.iter().enumerate().filter(|(index, _)| !kept.get(*index).copied().unwrap_or(false)) { + let mut at = 0; + while let Some(item) = row.get(at) { + let mut end = at + 1; + while row.get(end).is_some_and(|next| next.1 == item.1 && next.2 == item.2) { + end += 1; + } + let segment = row.get(at..end).unwrap_or_default(); + advances.insert((row_index, at), segment.iter().map(&advance).sum::()); + let text: String = segment.iter().map(|character| character.0).collect(); + if let Some((bytes, shaped, family)) = shape_source(doc, fonts, &item.2, &text)? { + runs.insert((row_index, at), (bytes, shaped, family)); + } + at = end; + } + } + Ok(ShapedRows { runs, advances }) +} + +fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result, EditError> { + let mut resources = HashMap::new(); + for (key, (bytes, shaped, family)) in runs { + let mappings: Vec<_> = shaped.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect(); + let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; + resources.insert(*key, resource); + } + Ok(resources) +} + +fn validate_contextual_rows(rows: &[Vec], kept: &[bool]) -> Result<(), EditError> { + for (row, _) in rows.iter().zip(kept).filter(|(_, retained)| !**retained) { + let text: String = row.iter().map(|character| character.0).collect(); + if pdfcraft_fonts::source_shaping::required(&text) + && row.first().is_some_and(|first| row.iter().any(|character| character.1 != first.1 || character.2 != first.2)) + { + return Err(EditError::Invalid( + "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" + .into(), + )); + } + } + Ok(()) +} + +fn calibration_factors( + lines: &[TextLine], + metrics: &std::collections::BTreeMap, + style: &BlockStyle, + overrides: &HashMap, + space_of: impl Fn(&str, &Metrics) -> f64, +) -> HashMap { + let mut calibration = HashMap::new(); + for (index, source) in lines.iter().enumerate() { + if let Some(metrics) = metrics.get(&source.font) { + let nominal = source_width( + metrics, + &source.text, + source.origin.state.font.as_ref().map_or(1.0, |(_, size)| *size), + space_of(&source.font, metrics), + source.origin.state.char_spacing, + source.origin.state.word_spacing, + ) * source.origin.state.scale + * across(source); + let changed_face = + style.resource.is_some() || overrides.get(&index).is_some_and(|range| range.family.is_some() || range.resource.is_some()); + let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; + calibration.insert(index, factor); + } + } + calibration +} + +fn validated_ranges<'a>(draft: &str, style: &'a BlockStyle) -> Result, EditError> { + let mut ranges: Vec<_> = style.ranges.iter().collect(); + ranges.sort_by_key(|range| range.range.start); + let invalid_range = ranges.iter().any(|range| { + range.range.start >= range.range.end + || draft.get(range.range.clone()).is_none() + || [range.size, range.char_spacing, range.scale].iter().flatten().any(|value| !value.is_finite()) + || range.size.is_some_and(|value| value <= 0.0 || value > 14_400.0) + || range.color.is_some_and(|color| color.iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))) + }); + if ranges.len() > 1024 || invalid_range || ranges.windows(2).any(|pair| pair[0].range.end > pair[1].range.start) { + return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); + } + Ok(ranges) +} + +fn replacement_text<'a>(block: &'a TextBlock, text: Option<&'a str>) -> Result<&'a str, EditError> { + let draft = match text { + Some(text) if text != block.text => text, + _ => &block.editor_text, + }; + if draft.len() > 4_000_000 { + return Err(EditError::Invalid("the replacement text is too large".into())); + } + Ok(draft) +} + +fn has_no_effective_style_change(block: &TextBlock, draft: &str, style: &BlockStyle) -> bool { + draft == block.editor_text && *style == BlockStyle::default() +} + +fn page_font_metrics( + doc: &Document, + resource_dict: &Dict, + block: &TextBlock, + lines: &[TextLine], +) -> (Dict, std::collections::BTreeMap) { + let mut fonts = resource_dict.get(b"Font").and_then(|resource| doc.resolve(resource).as_dict().cloned()).unwrap_or_default(); + for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(line.font.as_bytes().to_vec(), Object::Dict(font)); + } + let mut metrics = std::collections::BTreeMap::new(); + for (name, object) in fonts.iter().take(256) { + if let Some(font) = doc.resolve(object).as_dict() { + metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); + } + } + for index in &block.lines { + if let Some(line) = lines.get(*index) + && !metrics.contains_key(&line.font) + && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|resource| doc.resolve(resource).as_dict().cloned()) + { + metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); + } + } + (fonts, metrics) +} + +fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextBlock, style: &BlockStyle) -> Result<(f64, f64, f64), EditError> { + if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|value| !value.is_finite()) + || style.color.is_some_and(|color| color.iter().any(|value| !value.is_finite())) + { + return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); + } + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|width| !width.is_finite()) { + return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); + } + let natural = if block.invisible || block.rows.len() == 1 { + (page.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) + } else { + block.rect[2] - block.rect[0] + }; + Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0))) +} + +type WrappedStyledRows = (StyledRows, RowRanges, Vec, Vec, Vec); + +fn wrap_styled_rows( + chars: &[StyledChar], + width: f64, + measure: impl Fn(&[StyledChar]) -> Result, +) -> Result { + let mut rows: Vec> = Vec::new(); + let mut row_ranges = Vec::new(); + // True when this row starts because the previous word did not fit, not because of a newline. + let mut row_soft = Vec::new(); + let mut next_soft = false; + // How many separator characters a wrap trimmed off the row that just ended, for the row + // about to start (0 for a Chinese/Japanese word split, which trims none; meaningless unless + // the row it belongs to is soft). Mirrors `next_soft`. + let mut row_gap = Vec::new(); + let mut next_gap = 0usize; + // Logical draft rows drive per-paragraph alignment independently of soft wraps. + let mut row_lines: Vec = Vec::new(); + let mut draft_line = 0usize; + let mut row_start = 0; + let mut row = Vec::new(); + let mut row_width = 0.0; + let mut at = 0; + while at < chars.len() { + let Some(item) = chars.get(at) else { break }; + if item.0 == '\r' { + at += 1; + continue; + } + if item.0 == '\n' { + row_soft.push(next_soft); + next_soft = false; + row_gap.push(next_gap); + next_gap = 0; + row_ranges.push(row_start..row_start + row.len()); + row_lines.push(draft_line); + draft_line = draft_line.saturating_add(1); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + at += 1; + row_start = at; + continue; + } + let mut end = at + 1; + if !item.0.is_whitespace() { + // A word ends at a space, or where Chinese or Japanese text may break between characters. + while let (Some(before), Some(current)) = (chars.get(end - 1), chars.get(end)) + && !current.0.is_whitespace() + && !crate::linebreak::between(before.0, current.0) + { + end += 1; + } + } + let word = chars.get(at..end).unwrap_or_default(); + let word_width = measure(word)?; + if !row.is_empty() && !item.0.is_whitespace() && row_width + word_width > width + 0.5 { + let mut trimmed = 0usize; + while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) { + row.pop(); + trimmed += 1; + } + row_soft.push(next_soft); + next_soft = true; + row_gap.push(next_gap); + next_gap = trimmed; + row_ranges.push(row_start..row_start + row.len()); + row_lines.push(draft_line); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + row_start = at; + } + row.extend_from_slice(word); + row_width += word_width; + at = end; + } + if !row.is_empty() { + row_soft.push(next_soft); + row_gap.push(next_gap); + row_ranges.push(row_start..row_start + row.len()); + row_lines.push(draft_line); + rows.push(row); + } + Ok((rows, row_ranges, row_lines, row_soft, row_gap)) +} + +fn apply_range_styles<'a>( + first: &TextLine, + block: &TextBlock, + lines: &[TextLine], + draft: &str, + ranges: &[&'a RangeStyle], + style: &BlockStyle, +) -> StyledLines<'a> { + let mut styled_lines = lines.to_vec(); + let mut styled_spans = Vec::new(); + let mut underlines = HashMap::new(); + let mut overrides = HashMap::new(); + for (span, source) in block.edited_spans(draft) { + let mut edges = vec![span.start, span.end]; + for range in ranges { + for edge in [range.range.start, range.range.end] { + if span.contains(&edge) { + edges.push(edge); + } + } + } + edges.sort_unstable(); + edges.dedup(); + for bounds in edges.windows(2) { + let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; + let selected = ranges.iter().find(|range| range.range.contains(&start)).copied(); + let mut index = source; + if selected.is_some() || style.family.is_some() || style.bold.is_some() { + let default_range = RangeStyle { + range: start..end, + family: None, + resource: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let range = selected.unwrap_or(&default_range); + let mut line = lines.get(source).unwrap_or(first).clone(); + if let Some((family, bold, italic)) = + range.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) + { + let base = family.base_font(bold, italic); + line.font = format!("PCRange{}", base.replace('-', "")); + line.base_font = base.to_owned(); + line.bold = bold; + line.italic = italic; + } + if let Some(resource) = &range.resource { + line.font = resource.clone(); + } + if let Some(size) = range.size { + line.size = size; + } + if let Some(color) = range.color { + line.color = color; + line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; + } + if let Some(spacing) = range.char_spacing { + line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); + } + if let Some(scale) = range.scale { + line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); + } + index = styled_lines.len(); + styled_lines.push(line); + if let Some(range) = selected { + overrides.insert(index, range); + } + if let Some(underline) = range.underline { + underlines.insert(index, underline); + } + } + styled_spans.push((start..end, index)); + } + } + StyledLines { lines: styled_lines, spans: styled_spans, underlines, overrides } +} + +/// Shape in the source or an explicitly registered matching full face. Never use interface fonts. +fn shape_source( + doc: &Document, + fonts: &Dict, + name: &str, + text: &str, +) -> Result, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> { + if !pdfcraft_fonts::source_shaping::required(text) { + return Ok(None); + } + let source = + fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?; + let family = Metrics::from_dict(doc, &source).base_font; + let mut candidates = vec![source]; + candidates.extend( + fonts + .iter() + .filter_map(|(_, f)| doc.dict(f)) + .filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)), + ); + for font in candidates { + if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) + && pdfcraft_fonts::embedding::covers(&bytes, text) + && let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text) + { + return Ok(Some((bytes, shaped, family))); + } + } + Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) +} + +/// For each output row (a range of the draft's characters), whether it is exactly the source row +/// at the same position: the same characters, none of them inserted or changed by the edit. +fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range]) -> Vec { + let old: Vec = block.editor_text.chars().collect(); + let new: Vec = draft.chars().collect(); + let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); + let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count(); + // The source character a draft character is, if the edit left it alone. + let origin = |i: usize| { + if i < prefix { + Some(i) + } else if i >= new.len().saturating_sub(suffix) { + (old.len() + i).checked_sub(new.len()) + } else { + None + } + }; + let mut source_rows = Vec::new(); + let mut start = 0; + for (i, ch) in old.iter().enumerate() { + if *ch == '\n' { + source_rows.push(start..i); + start = i + 1; + } + } + source_rows.push(start..old.len()); + // A line break inside a source run would pair editor rows with the wrong visual rows. + if source_rows.len() != block.rows.len() { + return vec![false; rows.len()]; + } + rows.iter() + .enumerate() + .map(|(r, range)| { + source_rows.get(r).is_some_and(|source| { + !range.is_empty() && range.len() == source.len() && range.clone().zip(source.clone()).all(|(i, j)| origin(i) == Some(j)) + }) + }) + .collect() +} + +/// Operator index of the paragraph's first text object, and of the next line's text object +/// when one follows. +fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option)> { + let belongs: std::collections::HashSet = block.lines.iter().copied().collect(); + let start = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.origin.bt_op).min()?; + let end = + block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?; + let next = lines + .iter() + .enumerate() + .filter(|(index, _)| !belongs.contains(index)) + .filter_map(|(_, line)| (line.origin.bt_op > end).then_some(line.origin.bt_op)) + .min(); + Some((start, next)) +} + +/// The scope shared by every line of the paragraph, when each one is in the same marked region. +fn shared_scope(block: &TextBlock, lines: &[TextLine]) -> Option { + let mut scopes = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.scope); + let first = scopes.next().flatten()?; + scopes.all(|scope| scope == Some(first)).then_some(first) +} + +/// Where an extra `/P` marker belongs. +/// +/// The marker is omitted when the paragraph is already one marked region and the next line is +/// in a different one. Otherwise it closes after the paragraph's last `ET` (`ops.len()` means +/// append it after the stream). Closing at the next line's `BT` is too late when that line's +/// region opened with `BDC` before the `BT`: the `EMC` would end the neighbour's region. Closing +/// inside the rewritten operators leaves a kept source row outside the marker. +/// +/// A neighbour in the same text object has no `BT` of its own. Closing after the shared `ET` +/// would take that neighbour into the marker. The marker then opens after `BT` and closes +/// before the neighbour's text, so `BT` and `ET` stay matched. +fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option<(usize, Option, usize)> { + let (start, next) = paragraph_span(block, lines)?; + if let Some(region) = shared_scope(block, lines) + && let Some(at) = next + { + let next_scope = lines.iter().find(|line| line.origin.bt_op == at).map(|line| line.scope); + if next_scope != Some(Some(region)) { + return None; + } + } + let belongs: std::collections::HashSet = block.lines.iter().copied().collect(); + let end = + block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?; + let after_text = ops + .iter() + .enumerate() + .skip(end.saturating_add(1)) + .find(|(_, op)| op.op.as_slice() == b"ET") + .map(|(index, _)| index.saturating_add(1)) + .unwrap_or(ops.len()); + let neighbour = lines + .iter() + .enumerate() + .filter(|(index, _)| !belongs.contains(index)) + .filter_map(|(_, line)| line.ops.iter().copied().filter(|op| *op > end).min()) + .min(); + let open = block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied()).min()?; + if let Some(op) = neighbour.filter(|op| *op < after_text) { + // `BT` stays outside the marker. Both ends are inside the one text object. + if open > start { + return Some((open, None, op)); + } + } + // The last paragraph of a shared text object has no neighbour after it. Opening at the + // shared `BT` would take the preceding paragraph into the marker. Open at this paragraph's + // first operator instead, and close before the shared `ET`, so both ends stay inside it. + let preceding = + lines.iter().enumerate().filter(|(index, _)| !belongs.contains(index)).any(|(_, line)| line.ops.iter().any(|op| *op > start && *op < open)); + if preceding { + if let Some(et) = after_text.checked_sub(1).filter(|index| ops.get(*index).is_some_and(|op| op.op.as_slice() == b"ET")) + && open > start + && open < et + { + return Some((open, None, et)); + } + return None; + } + // `None` is the next-line index: the close is after this paragraph's `ET`, not at the + // following line's `BT`. + Some((start, None, after_text)) +} + +struct RewriteAnchor<'a> { + lead: &'a TextLine, + drop: std::collections::HashSet, + start: usize, + split: bool, +} + +fn rewrite_anchor<'a>( + block: &TextBlock, + lines: &'a [TextLine], + first: &'a TextLine, + ops: &[Op], + kept_lines: &std::collections::HashSet, + kept: &mut [bool], +) -> RewriteAnchor<'a> { + let rewritten: Vec<&TextLine> = block.lines.iter().filter(|index| !kept_lines.contains(index)).filter_map(|index| lines.get(*index)).collect(); + let lead = rewritten + .last() + .copied() + .filter(|_| !rewritten.iter().flat_map(|line| &line.ops).any(|index| ops.get(*index).is_some_and(|op| op.is("'") || op.is("\"")))); + let (members, lead) = match lead { + Some(lead) if !kept_lines.is_empty() => (rewritten, lead), + _ => { + kept.iter_mut().for_each(|value| *value = false); + let all: Vec<&TextLine> = block.lines.iter().filter_map(|index| lines.get(*index)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) + } + }; + let origin = &lead.origin; + let mut drop = coincident_ops(lines, &members); + drop.extend(members.iter().flat_map(|line| line.ops.iter().copied())); + let start = lead.ops.first().copied().unwrap_or(origin.bt_op); + let split = ops + .get(origin.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(index, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(origin.bt_op + index))); + RewriteAnchor { lead, drop, start, split } +} + +/// Open (`op` is `"BMC"`) or close (`"EMC"`) the `/P` marker that wraps the whole rewrite when +/// `marked` (every row is in this rewrite, see [`rewrite_styled_block`]), so the paragraph +/// cannot be mistaken for a neighbour by geometry alone (#2517). +fn push_whole_paragraph_marker(block_ops: &mut Vec, marked: bool, op: &str) { + if !marked { + return; + } + let operands = if op == "BMC" { vec![Object::name("P")] } else { Vec::new() }; + block_ops.push(Op::new(op, operands)); +} + +/// An empty marker between two rows of one rewrite. `/PCHard` is a newline that was already in +/// the text. `/PCSoft` is a wrap and rejoins when the box widens, with `/N` the exact number of +/// separator characters it consumed (0 for a Chinese/Japanese word split), so a rejoin restores +/// what was actually there instead of guessing a single space (#2529). Neither is a paragraph +/// region, so the lines stay in one scope. +fn push_row_break_marker(block_ops: &mut Vec, row_soft: &[bool], row_gap: &[usize], row_index: usize) { + if row_soft.get(row_index).copied().unwrap_or(false) { + let mut properties = Dict::new(); + let gap = row_gap.get(row_index).copied().unwrap_or(1); + properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX))); + block_ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)])); + } else { + block_ops.push(Op::new("BMC", vec![Object::name("PCHard")])); + } + block_ops.push(Op::new("EMC", vec![])); +} + +/// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P` +/// marker opened at its start and closed at its `next` line or, when nothing follows, after the +/// paragraph's own last `ET` (appended past the stream's last operator when that close is at or +/// beyond `ops.len()`, since `splice`'s callback never visits that index). +fn splice_with_marker( + pieces: &Pieces, + ops: &[Op], + marker: Option<(usize, Option, usize)>, + at: usize, + mut block_ops: Vec, + drop: &std::collections::HashSet, +) -> Vec> { + let data = splice(pieces, ops, |i| { + let mut insert = Vec::new(); + if let Some((start_at, next, close)) = marker { + if i == start_at { + insert.push(Op::new("BMC", vec![Object::name("P")])); + } + if next.is_none() && i == close { + insert.push(Op::new("EMC", vec![])); + } + if next == Some(i) { + insert.push(Op::new("EMC", vec![])); + } + } + if i == at { + insert.append(&mut block_ops); + } + (insert, !drop.contains(&i)) + }); + if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) { + return data; + } + let mut data = data; + if let Some(last) = data.last_mut() { + if last.last().is_some_and(|byte| !byte.is_ascii_whitespace()) { + last.push(b'\n'); + } + // `splice` never visits `ops.len()`, so a close after the final operator is appended. + last.extend_from_slice(&pdfcraft_content::serialize_ops(&[Op::new("EMC", vec![])])); + } + data +} + +/// Rewrite visual rows while keeping the source style on each surviving character. Resource +/// names may differ between Unicode subsets of the same face; encoding searches those subsets. +pub(crate) fn rewrite_styled_block( + doc: &mut Document, + page: usize, + block: &TextBlock, + lines: &[TextLine], + text: Option<&str>, + style: &BlockStyle, + max_glyphs: usize, +) -> Result { + let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; + let draft = replacement_text(block, text)?; + if has_no_effective_style_change(block, draft, style) { + return Ok(LineEdit { substituted: None }); + } + let ranges = validated_ranges(draft, style)?; + let source_count = lines.len(); + let styled = apply_range_styles(first, block, lines, draft, &ranges, style); + let lines = styled.lines.as_slice(); + let styled_spans = styled.spans; + let underlines = styled.underlines; + let overrides = styled.overrides; + let p = page_dict(doc, page)?; + let mut res = p.dict.get(b"Resources").and_then(|resource| doc.resolve(resource).as_dict().cloned()).unwrap_or_default(); + let (mut fonts, mut metrics) = page_font_metrics(doc, &res, block, lines); + let (dx, dy, width) = paragraph_geometry(doc, &p, block, style)?; + let prepared = CharacterPreparation { + doc, + first, + lines, + draft, + spans: styled_spans, + style, + overrides: &overrides, + fonts: &mut fonts, + metrics: &mut metrics, + max_glyphs, + } + .prepare()?; + let chars = prepared.values; + let substituted = prepared.substituted; + let size_of = |source: &TextLine, index: usize| { + overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) + }; + // A source run may carry TJ kerning/justification that is absent from its text state. + // Keep its measured advance when rebuilding the run instead of making it wrap on entry. + // Each font's space width, worked out once: when a font can't show a space, finding one + // reads every font on the page, and doing that for every character of a paragraph made each + // keystroke in a long paragraph take half a second (#2616). + // Only for the fonts the paragraph uses, when first asked: working out every page font's width + // up front would scan the page once per font for a short edit on a page of many fonts. + let spaces: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); + let space_of = |name: &str, m: &Metrics| { + if let Some(w) = spaces.borrow().get(name) { + return *w; + } + let w = source_space(doc, &fonts, m); + spaces.borrow_mut().insert(name.to_owned(), w); + w + }; + let calibration = calibration_factors(lines, &metrics, style, &overrides, space_of); + let advance = |item: &(char, usize, String)| -> f64 { + let source = lines.get(item.1).unwrap_or(first); + let Some(m) = metrics.get(&item.2) else { return 0.0 }; + source_width( + m, + &item.0.to_string(), + size_of(source, item.1), + space_of(&item.2, m), + overrides + .get(&item.1) + .and_then(|r| r.char_spacing) + .or(style.char_spacing) + .map_or(source.origin.state.char_spacing, |c| c / source.origin.k.max(1e-6)), + source.origin.state.word_spacing, + ) * overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)) + * across(source) + * calibration.get(&item.1).copied().unwrap_or(1.0) + }; + let measure = |items: &[(char, usize, String)]| -> Result { + let mut width = 0.0; + let mut at = 0; + while let Some(item) = items.get(at) { + let mut end = at + 1; + while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { + end += 1; + } + let segment = items.get(at..end).unwrap_or_default(); + let string: String = segment.iter().map(|v| v.0).collect(); + if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? { + let source = lines.get(item.1).unwrap_or(first); + let scale = + overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)); + let spacing = overrides + .get(&item.1) + .and_then(|r| r.char_spacing) + .or(style.char_spacing) + .map_or(source.origin.state.char_spacing, |s| s / source.origin.k.max(1e-6)); + width += (shaped.advance * size_of(source, item.1) + + spacing * string.chars().count() as f64 + + source.origin.state.word_spacing * string.matches(' ').count() as f64) + * scale + * source.origin.k; + } else { + width += segment.iter().map(&advance).sum::(); + } + at = end; + } + Ok(width) + }; + let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?; + let streams = content_streams(doc, &p.dict)?; + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and + // glyph widths stay exactly as they were instead of being rebuilt from the text state. A row + // aligned on its own is reached; the rows around it are not. + let plain = BlockStyle { ranges: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default(); + let mut kept = if plain { + untouched_rows(block, draft, &row_ranges) + .into_iter() + .zip(&rows) + .zip(&row_lines) + .map(|((same, row), line)| same && row.iter().all(|c| c.1 < source_count) && style.aligns.get(*line).copied().flatten().is_none()) + .collect() + } else { + vec![false; rows.len()] + }; + let kept_lines: std::collections::HashSet = + kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); + // The rewrite is placed after the last rewritten row unless a text-show operator there + // would make a kept following row depend on that position. + let anchor = rewrite_anchor(block, lines, first, &ops, &kept_lines, &mut kept); + let RewriteAnchor { lead, drop, start, split } = anchor; + let o = &lead.origin; + // What is in force where the rewrite is drawn, and what the text after it expects. + let after = if split { &o.state } else { &o.bt_state }; + validate_contextual_rows(&rows, &kept)?; + let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; + let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; + let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped; + let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?; + let n = pdfcraft_content::num; + let mut block_ops = vec![Op::new("q", vec![])]; + block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); + let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0]) + .invert() + .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; + let (tx, ty) = back.apply(dx, dy); + block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); + // A new row can land one leading above the next paragraph and look like the same + // paragraph. When every row is in this rewrite, a paragraph marker keeps them in one + // scope so the neighbour stays out (#2517). A row left in the source stream cannot share + // that marker, so it is omitted and geometry groups those rows as before. + let marked = kept.iter().all(|row| !*row); + push_whole_paragraph_marker(&mut block_ops, marked, "BMC"); + let crop = p.crop(doc); + let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size); + let source_leading = block + .rows + .windows(2) + .filter_map(|rows| { + let a = rows[0].first().and_then(|i| lines.get(*i))?; + let b = rows[1].first().and_then(|i| lines.get(*i))?; + Some((a.origin.baseline - b.origin.baseline).abs()) + }) + .rfind(|v| *v > 0.0) + .unwrap_or(first.size * 1.5); + let mut baseline = first.origin.baseline; + for (row_index, row) in rows.iter().enumerate() { + let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i)); + if style.line_spacing.is_none() + && style.size.is_none() + && let Some(anchor) = anchor + { + baseline = anchor.origin.baseline; + } + let anchor = anchor.unwrap_or(first); + if kept.get(row_index).copied().unwrap_or(false) { + // Kept rows only occur without spacing or size changes, so this is the step below. + baseline -= source_leading; + continue; + } + if !row.is_empty() && row_index > 0 { + push_row_break_marker(&mut block_ops, &row_soft, &row_gap, row_index); + } + let w = measured.get(row_index).copied().unwrap_or(0.0); + // The row's own alignment, or the paragraph's. + let asked = row_lines.get(row_index).and_then(|l| style.aligns.get(*l)).copied().flatten().or(style.align); + let align = asked.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); + let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect); + let left = if style.width.is_none() && asked.is_none() { original_rect[0] } else { block.rect[0] }; + let available = if style.width.is_none() && asked.is_none() { original_rect[2] - original_rect[0] } else { width }; + let mut x = left + + match align { + crate::added::Align::Center => (available - w) / 2.0, + crate::added::Align::Right => available - w, + _ => 0.0, + }; + let mut pos = 0; + while pos < row.len() { + let Some(item) = row.get(pos) else { break }; + let source = lines.get(item.1).unwrap_or(first); + let mut end = pos + 1; + while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) { + end += 1; + } + let segment = row.get(pos..end).unwrap_or_default(); + let string: String = segment.iter().map(|c| c.0).collect(); + let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?; + let mut state = source.origin.state.clone(); + state.font = Some((item.2.clone(), size_of(source, item.1))); + if let Some(c) = overrides.get(&item.1).and_then(|r| r.char_spacing).or(style.char_spacing) { + state.char_spacing = c / source.origin.k.max(1e-6); + } + if let Some(s) = overrides.get(&item.1).and_then(|r| r.scale).or(style.scale) { + state.scale = (s / 100.0).clamp(0.1, 10.0); + } + if !shaped_runs.contains_key(&(row_index, pos)) { + state.scale *= calibration.get(&item.1).copied().unwrap_or(1.0); + } + if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) { + state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])]; + } + let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0; + let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline); + tm[4] += ax; + tm[5] += ay; + let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0); + // Each run in its own graphics state, restating what its source set inside its text + // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is + // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. + block_ops.push(Op::new("q", vec![])); + block_ops.extend(state.paint_ops(after)); + if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { + let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + let mut properties = Dict::new(); + properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string))); + block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); + state.font = Some((resource.clone(), size_of(source, item.1))); + // One text object for the run: each glyph is placed where shaping put it by a TJ + // adjustment from where the glyph before it ends (its advance in the embedded + // font's widths), and raised by the text rise for a mark set above or below + // (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode; + // ToUnicode and ActualText keep the logical text. + let size = size_of(source, item.1); + let per_unit = 1000.0 / size; + if !per_unit.is_finite() { + return Err(EditError::Invalid("the shaped text has no size".into())); + } + let shown = fonts + .get(resource.as_bytes()) + .and_then(|f| doc.resolve(f).as_dict().cloned()) + .map(|d| Metrics::from_dict(doc, &d)) + .ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + // Spacing is part of each glyph's position, so the run itself has none. + let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise); + state.char_spacing = 0.0; + state.word_spacing = 0.0; + block_ops.push(Op::new("BT", vec![])); + block_ops.extend(state.ops()); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); + // `pen`: where the next glyph would start, in thousandths of the font size. + let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise); + let mut shown_glyphs: Vec = Vec::new(); + for (i, glyph) in shaped.glyphs.iter().enumerate() { + let target = (glyph.position[0] * size + extra) * per_unit; + let glyph_rise = rise + glyph.position[1] * size; + if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 { + if !shown_glyphs.is_empty() { + block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))])); + } + if (glyph_rise - raised).abs() > 1e-9 { + block_ops.push(Op::new("Ts", vec![n(glyph_rise)])); + raised = glyph_rise; + } + } + if (target - pen).abs() > 1e-6 { + shown_glyphs.push(n(pen - target)); + } + let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; + shown_glyphs.push(Object::String(PdfString::literal(code.to_be_bytes().to_vec()))); + pen = target + shown.width(u32::from(code)) * 1000.0; + extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64; + } + if !shown_glyphs.is_empty() { + block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)])); + } + if (raised - rise).abs() > 1e-9 { + block_ops.push(Op::new("Ts", vec![n(rise)])); + } + block_ops.push(Op::new("ET", vec![])); + (state.char_spacing, state.word_spacing) = (char_spacing, word_spacing); + block_ops.push(Op::new("EMC", vec![])); + segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k; + } else { + block_ops.push(Op::new("BT", vec![])); + block_ops.extend(state.ops()); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); + block_ops.extend(source_show( + m, + &string, + size_of(source, item.1), + spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)), + state.char_spacing, + state.word_spacing, + )?); + block_ops.push(Op::new("ET", vec![])); + } + if underlines.get(&item.1).copied().or(style.underline) == Some(true) { + let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); + let underline_size = size_of(source, item.1) * source.origin.k; + let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); + block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); + block_ops.push(Op::new("m", vec![n(x0), n(y0)])); + block_ops.push(Op::new("l", vec![n(x1), n(y1)])); + block_ops.push(Op::new("S", vec![])); + } + block_ops.push(Op::new("Q", vec![])); + x += segment_width; + pos = end; + } + baseline -= if style.line_spacing.is_none() && style.size.is_none() { + source_leading + } else { + style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) + }; + } + push_whole_paragraph_marker(&mut block_ops, marked, "EMC"); + block_ops.push(Op::new("Q", vec![])); + block_ops.extend(after.ops()); + if split { + block_ops.insert(0, Op::new("ET", vec![])); + block_ops.push(Op::new("BT", vec![])); + block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); + } + let at = if split { start } else { o.bt_op }; + // A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is + // safe. The close is not part of `block_ops`: that vector can end inside a text object + // when the rewrite splits one, and a kept row's operators come later in the stream. + let marker = if marked { None } else { paragraph_marker(block, lines, &ops) }; + let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop); + let contents = rewritten_contents(doc, &streams, data); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), contents); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + Ok(LineEdit { substituted }) +} diff --git a/crates/edit/src/text_session.rs b/crates/edit/src/text_session.rs new file mode 100644 index 0000000000..fe1aa78688 --- /dev/null +++ b/crates/edit/src/text_session.rs @@ -0,0 +1,72 @@ +//! A text editing session for one paragraph (#2512). Opening it reads the page once; each draft +//! is laid out from that reading, on a copy of the document as it was opened, so no draft sees +//! another one's output; the commit writes into the document it was opened on, and refuses if +//! that document has changed since. + +use super::{ + BlockStyle, Document, EditError, LineEdit, MAX_TYPE3_GLYPHS, TextBlock, TextEditLayout, TextLine, group_blocks, layout_draft, rewrite_block_from, + text_lines, +}; + +/// One paragraph being edited: the document as it was opened, and the page's lines then. +pub struct TextSession { + base: Document, + base_state: pdfcraft_cos::ReadState, + page: usize, + block: usize, + lines: Vec, + source: TextBlock, +} + +impl TextSession { + /// Opens paragraph `block` on `page` (both 0-based) of `doc`. + pub fn open(doc: &Document, page: usize, block: usize) -> Result { + let lines = text_lines(doc, page)?; + let source = group_blocks(&lines) + .get(block) + .cloned() + .ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; + Ok(Self { base: doc.clone(), base_state: doc.read_state(), page, block, lines, source }) + } + + /// The paragraph as it was when the session opened. + pub fn source(&self) -> &TextBlock { + &self.source + } + + pub fn page(&self) -> usize { + self.page + } + + pub fn block(&self) -> usize { + self.block + } + + /// Whether `doc` is still the document the session was opened on (nothing applied since). + pub fn is_current(&self, doc: &Document) -> bool { + self.base_state.matches(doc) + } + + /// Lays a draft out without writing it: the rewrite Apply runs, on a copy of the opened document. + pub fn layout(&self, text: &str, style: &BlockStyle) -> Result { + layout_draft(&self.base, self.page, &self.lines, &self.source, self.block, text, style) + } + + /// Writes the draft into `doc`, which must still be the document the session was opened on. + pub fn commit(&self, doc: &mut Document, text: Option<&str>, style: &BlockStyle) -> Result { + self.commit_with_limit(doc, text, style, MAX_TYPE3_GLYPHS) + } + + pub(crate) fn commit_with_limit( + &self, + doc: &mut Document, + text: Option<&str>, + style: &BlockStyle, + max_glyphs: usize, + ) -> Result { + if !self.is_current(doc) { + return Err(EditError::Invalid("the paragraph changed while it was being edited".into())); + } + rewrite_block_from(doc, self.page, self.lines.clone(), self.block, text, style, max_glyphs) + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ff5e7611d8..6dae6adf0a 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -48,5 +48,13 @@ icu_properties = "2.3" unicode-segmentation = "1.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -# Already in the graph through lopdf; used only for the local UTC offset ("today"). -chrono = { version = "0.4", default-features = false, features = ["clock"] } +# Already in the graph through lopdf; used only for the local UTC offset ("today"). The workspace +# entry is std-only; this target adds `clock` for `chrono::Local::now`. +chrono = { workspace = true, features = ["clock"] } +getrandom = "0.4" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.4", features = ["wasm_js"] } + +[dev-dependencies] +pdfcraft-testkit = { workspace = true } diff --git a/crates/engine/README.md b/crates/engine/README.md index 4bf80e3f73..86492bc043 100644 --- a/crates/engine/README.md +++ b/crates/engine/README.md @@ -16,6 +16,14 @@ Two frontends exist on top of it today: `pdfcraft-ui-egui` (desktop and web) and `pdfcraft-automation` (headless tools, the CLI and MCP). Anything one can do, the other must be able to do — see AGENTS.md §3. +When dynamic XFA layout is active, `JsRunner` does not initialize Acrobat JavaScript with +scripts from the PDF container's `/Names /JavaScript` tree. Those scripts belong to the +fallback PDF view and can otherwise show a misleading viewer-compatibility warning. +They remain in the document and in `Document::document_scripts`. Requested console scripts, +field actions and XFA events can still show messages; ordinary PDFs, static XFA and shells +without an active layout keep their document scripts. Converting XFA to a static form and +undoing that conversion switches the context with the document state. + ## The editing model Each open document holds a `pdfcraft_cos::Document`: the object graph plus a copy-on-write overlay. diff --git a/crates/engine/examples/export_bench.rs b/crates/engine/examples/export_bench.rs new file mode 100644 index 0000000000..41520d2ffa --- /dev/null +++ b/crates/engine/examples/export_bench.rs @@ -0,0 +1,61 @@ +//! Times Export ▸ Image over every page of a PDF, for before and after comparisons. +//! +//! Usage: `export_bench FILE [DPI] [png|jpeg|tiff] [RUNS]` (defaults: 150 dpi, PNG, 7 runs). Each +//! run exports every page through [`Exporter::images`] from a fresh exporter, as Export ▸ Image +//! does, and prints its wall time; the summary gives the median and the minimum, the total size +//! and an FNV-1a hash of every file in page order. Equal hashes mean equal output. Build with +//! `--release`; timings are diagnostic, never a CI deadline. + +use std::error::Error; +use std::sync::Arc; +use std::time::Instant; + +use pdfcraft_engine::Session; +use pdfcraft_engine::export::{Exporter, ImageFormat}; + +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let path = args.next().ok_or("usage: export_bench FILE [DPI] [png|jpeg|tiff] [RUNS]")?; + let dpi: f64 = args.next().map_or(Ok(150.0), |s| s.parse())?; + let format = match args.next().as_deref() { + None | Some("png") => ImageFormat::Png, + Some("jpeg" | "jpg") => ImageFormat::Jpeg { quality: 85 }, + Some("tiff" | "tif") => ImageFormat::Tiff, + Some(other) => return Err(format!("unknown format {other:?}").into()), + }; + let runs: usize = args.next().map_or(Ok(7), |s| s.parse())?; + let mut session = Session::new(); + let id = session.open("bench.pdf", None, Arc::new(std::fs::read(&path)?), None)?; + let src = session.get(id).ok_or("the document did not open")?.export_source(); + let pages: Vec = (0..src.pages).collect(); + let mut times = Vec::new(); + let mut summary = None; + for run in 0..runs { + let start = Instant::now(); + let mut exporter = Exporter::from_source(src.clone()); + let (mut hash, mut bytes) = (FNV_OFFSET, 0usize); + exporter.images(&pages, dpi, format, |_, file| { + bytes += file.len(); + for b in file { + hash = (hash ^ u64::from(b)).wrapping_mul(FNV_PRIME); + } + Ok(()) + })?; + let ms = start.elapsed().as_secs_f64() * 1000.0; + println!("run {}: {ms:.1} ms", run + 1); + times.push(ms); + summary = Some((bytes, hash)); + } + times.sort_by(f64::total_cmp); + if let (Some(median), Some(min), Some((bytes, hash))) = (times.get(times.len() / 2), times.first(), summary) { + println!( + "{} pages, {} at {dpi} dpi: median {median:.1} ms, min {min:.1} ms ({runs} runs), {bytes} bytes, hash {hash:016x}", + pages.len(), + format.label() + ); + } + Ok(()) +} diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs index f679cf87cd..f7d11d47ed 100644 --- a/crates/engine/src/actions.rs +++ b/crates/engine/src/actions.rs @@ -191,9 +191,13 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) -> return Ok(None); } }; - // Steps that find nothing to do (no comments to flatten, …) aren't failures. + // Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step + // that didn't remove is: the file would be passed on as cleaned with the data still in it. + let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize); match s.apply(id, edit) { Ok(()) => log.push(step.label().to_string()), + Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())), + Err(e) if removes => return Err(e), Err(e) => log.push(format!("{}: {e}", step.label())), } Ok(None) diff --git a/crates/engine/src/bookmarks.rs b/crates/engine/src/bookmarks.rs new file mode 100644 index 0000000000..deaece22e4 --- /dev/null +++ b/crates/engine/src/bookmarks.rs @@ -0,0 +1,61 @@ +//! The bookmark list. + +/// A bookmark as the bookmark list shows it. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkRow { + /// Child indices from the top level, 0-based, as the edit functions take them. + pub path: Vec, + pub title: String, + /// Shown expanded (a positive `/Count`). + pub open: bool, + /// The 0-based page it goes to. + pub page: Option, + /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). + pub view: pdfcraft_render::DestView, + /// The title's colour (`/C`), `None` for the default black, and its style (`/F`). + pub color: Option<[f32; 3]>, + pub italic: bool, + pub bold: bool, +} + +/// One page of the bookmark list, in outline order. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkPage { + pub rows: Vec, + /// Where the next page starts, or `None` at the end. + pub next: Option, + /// Set only when the document's structure could not be read for editing: its listing then + /// comes from the viewer's copy of the outline, which is shorter than the file's. + pub truncated: bool, +} + +/// The rows of a viewer outline, in outline order, with their paths. +pub(crate) fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { + let mut rows = Vec::new(); + // One entry per open level: its items, and how many of them have been listed. + let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; + while let Some(&(list, next)) = levels.last() { + let Some(item) = list.get(next) else { + levels.pop(); + continue; + }; + if let Some(top) = levels.last_mut() { + top.1 += 1; + } + let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); + rows.push(BookmarkRow { + path, + title: item.title.clone(), + open: item.open, + page: item.page, + view: item.view, + color: item.color, + italic: item.italic, + bold: item.bold, + }); + if !item.children.is_empty() { + levels.push((&item.children, 0)); + } + } + rows +} diff --git a/crates/engine/src/camera_edges.rs b/crates/engine/src/camera_edges.rs index fd8dbd5f28..cee5c8f694 100644 --- a/crates/engine/src/camera_edges.rs +++ b/crates/engine/src/camera_edges.rs @@ -311,6 +311,35 @@ fn refine(q: [Point; 4], edges: &[Option], w: usize, h: usize) -> Option<[ order_quad(refined) } +/// A candidate quad's score (its area times the square of its sides' mean support), or `None` when it +/// strays off the image, is too small, or has a side without enough edge support. +fn quad_score(q: &[Point; 4], edges: &[Option], w: usize, h: usize) -> Option { + let (fw, fh) = (w as f64, h as f64); + let (mx, my) = (MARGIN * fw, MARGIN * fh); + if q.iter().any(|p| p[0] < -mx || p[1] < -my || p[0] > fw + mx || p[1] > fh + my) { + return None; + } + let area = (0..4) + .map(|k| { + let (p, n) = (q[k], q[(k + 1) % 4]); + p[0] * n[1] - n[0] * p[1] + }) + .sum::() + .abs() + / 2.0; + if area < MIN_AREA * fw * fh { + return None; + } + let sides: Vec = (0..4).map(|k| support(q[k], q[(k + 1) % 4], edges, w, h)).collect(); + if sides.iter().any(|&s| s < MIN_SUPPORT) { + return None; + } + // Large, and with every side on its edge: a slightly larger quad with a side + // off the sheet loses to the sheet itself. + let mean = sides.iter().sum::() / 4.0; + Some(area * mean * mean) +} + /// The convex quadrilateral of two near-horizontal and two near-vertical lines whose four sides /// all lie on edges, scored by its area times the square of its sides' mean support. fn best_quad(lines: &[Line], edges: &[Option], w: usize, h: usize) -> Option<[Point; 4]> { @@ -318,8 +347,6 @@ fn best_quad(lines: &[Line], edges: &[Option], w: usize, h: usize) -> Opti let flat = |l: &&Line| (l.theta.to_degrees() - 90.0).abs() <= 45.0; let horizontal: Vec<&Line> = lines.iter().filter(flat).collect(); let vertical: Vec<&Line> = lines.iter().filter(|l| !flat(l)).collect(); - let (fw, fh) = (w as f64, h as f64); - let (mx, my) = (MARGIN * fw, MARGIN * fh); let mut best: Option<(f64, [Point; 4])> = None; for (i, &h1) in horizontal.iter().enumerate() { for &h2 in horizontal.iter().skip(i + 1) { @@ -328,28 +355,7 @@ fn best_quad(lines: &[Line], edges: &[Option], w: usize, h: usize) -> Opti let corners = [intersect(*h1, *v1), intersect(*h1, *v2), intersect(*h2, *v2), intersect(*h2, *v1)]; let [Some(a), Some(b), Some(c), Some(d)] = corners else { continue }; let Some(q) = order_quad([a, b, c, d]) else { continue }; - if q.iter().any(|p| p[0] < -mx || p[1] < -my || p[0] > fw + mx || p[1] > fh + my) { - continue; - } - let area = (0..4) - .map(|k| { - let (p, n) = (q[k], q[(k + 1) % 4]); - p[0] * n[1] - n[0] * p[1] - }) - .sum::() - .abs() - / 2.0; - if area < MIN_AREA * fw * fh { - continue; - } - let sides: Vec = (0..4).map(|k| support(q[k], q[(k + 1) % 4], edges, w, h)).collect(); - if sides.iter().any(|&s| s < MIN_SUPPORT) { - continue; - } - // Large, and with every side on its edge: a slightly larger quad with a side - // off the sheet loses to the sheet itself. - let mean = sides.iter().sum::() / 4.0; - let score = area * mean * mean; + let Some(score) = quad_score(&q, edges, w, h) else { continue }; if best.is_none_or(|(b, _)| score > b) { best = Some((score, q)); } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 3d2ba30820..e479232bbb 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -147,7 +147,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Link", "link-2", "edit.link", Ready), item("Bates numbering", "hash", "edit.bates", Ready), menu_item("Background", "palette", &["edit.background", "edit.background.update", "edit.background.remove"]), - item("Attach file", "paperclip", "edit.attach", Planned("M12")), + item("Attach file", "paperclip", "edit.attach", Ready), ], }, ], @@ -347,6 +347,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Field properties", "settings-2", "form.field.properties", Ready), item("Tab order by rows", "rows-3", "form.tab_order.row", Ready), item("Tab order by columns", "columns-3", "form.tab_order.column", Ready), + item("Calculation order", "list-ordered", "form.calculation_order", Ready), item("Flatten form fields", "layers", "form.flatten", Ready), item("Import data", "file-input", "form.import_data", Ready), item("Export data", "file-output", "form.export_data", Ready), @@ -356,6 +357,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ title: "Add form components", items: &[ item("Text field", "text-cursor-input", "form.add.text", Ready), + item("Text field table", "grid-3x3", "form.add.text_table", Ready), item("Checkbox", "check-circle-2", "form.add.checkbox", Ready), item("Radio button", "circle", "form.add.radio", Ready), item("Drop-down list", "chevron-down", "form.add.combo", Ready), @@ -548,6 +550,23 @@ pub fn group(id: &str) -> Option<&'static ToolGroup> { TOOL_GROUPS.iter().find(|g| g.id == id) } +/// A heading of the expanded All tools view, as Acrobat files its tools under "Create & Edit", +/// "Forms & Signatures" and so on. Every group belongs to exactly one category. +#[derive(Clone, Copy, Debug)] +pub struct ToolCategory { + pub label: &'static str, + /// [`ToolGroup::id`]s, in display order. + pub groups: &'static [&'static str], +} + +pub static TOOL_CATEGORIES: &[ToolCategory] = &[ + ToolCategory { label: "Create & Edit", groups: &["create", "edit", "export", "combine", "organize", "scan", "compress"] }, + ToolCategory { label: "Review & Compare", groups: &["comment", "stamp", "compare", "measure"] }, + ToolCategory { label: "Forms & Signatures", groups: &["fill_sign", "form", "certificate"] }, + ToolCategory { label: "Protect & Standardize", groups: &["protect", "redact", "accessibility", "standards", "print_production"] }, + ToolCategory { label: "Customize & Automate", groups: &["actions", "javascript", "search_index", "ai"] }, +]; + #[cfg(test)] mod tests { use super::*; @@ -562,6 +581,18 @@ mod tests { } } + #[test] + fn every_group_is_in_exactly_one_category() { + let mut seen = HashSet::new(); + for c in TOOL_CATEGORIES { + for id in c.groups { + assert!(group(id).is_some(), "{} lists unknown group {id}", c.label); + assert!(seen.insert(*id), "{id} is in two categories"); + } + } + assert_eq!(seen.len(), TOOL_GROUPS.len(), "a group is in no category"); + } + #[test] fn commands_are_namespaced() { for g in TOOL_GROUPS { @@ -594,6 +625,19 @@ mod tests { assert!(menus >= 3); } + // Regression test for #2028: Edit text & images sat under "Add content", though it changes + // what is already on the page; it belongs first under "Modify page". + #[test] + fn edit_text_and_images_is_the_first_item_under_modify_page() { + let edit = group("edit").expect("the Edit a PDF tool"); + let section_of = |command: &str| edit.sections.iter().find(|s| s.items.iter().any(|i| i.command == command)).map(|s| s.title); + assert_eq!(section_of("edit.edit_text"), Some("Modify page"), "Edit text & images is a Modify page tool"); + let modify = edit.sections.iter().find(|s| s.title == "Modify page").expect("a Modify page section"); + assert_eq!(modify.items.first().map(|i| i.command), Some("edit.edit_text"), "and the first one, so it is not below the fold"); + let add = edit.sections.iter().find(|s| s.title == "Add content").expect("an Add content section"); + assert!(add.items.iter().all(|i| i.command != "edit.edit_text"), "it adds nothing, so it is not listed under Add content"); + } + #[test] fn every_update_and_remove_command_is_reachable_from_a_tool_entry() { let reachable: HashSet<&str> = diff --git a/crates/engine/src/clock.rs b/crates/engine/src/clock.rs new file mode 100644 index 0000000000..dc17ab56fb --- /dev/null +++ b/crates/engine/src/clock.rs @@ -0,0 +1,13 @@ +/// Seconds to add to UTC for local time (0 where unknown). +pub(crate) fn local_utc_offset() -> i64 { + #[cfg(not(target_arch = "wasm32"))] + return i64::from(chrono::Local::now().offset().local_minus_utc()); + #[cfg(target_arch = "wasm32")] + return 0; +} + +/// (year, month, day) from a PDF date `D:YYYYMMDD…`. +pub(crate) fn parse_ymd(d: &str) -> Option<(i64, u32, u32)> { + let d = d.strip_prefix("D:").unwrap_or(d); + Some((d.get(0..4)?.parse().ok()?, d.get(4..6)?.parse().ok()?, d.get(6..8)?.parse().ok()?)) +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index fcd56efee4..6bfe9fa826 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -26,6 +26,8 @@ pub enum Needs { Annotate, /// The open document's security allows copying text and images out of it. Copying, + /// The open document's security allows printing (low resolution counts). + Printing, /// The document has form fields and its security allows filling them in. FillForms, /// The document has comments and allows changes. @@ -188,7 +190,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("file.close", "Close file", FILE, Some(Shortcut::cmd("W")), Document, "x"), c("file.close_all", "Close all", FILE, Some(Shortcut::cmd_shift("W")), Document, "x"), c("file.revert", "Revert", FILE, None, Undo, "rotate-ccw"), - c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Document, "printer"), + c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Printing, "printer"), c("file.properties", "Document properties…", FILE, Some(Shortcut::cmd("D")), Document, "info"), c("file.word_count", "Word count…", FILE, None, Copying, "hash"), ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"), @@ -202,6 +204,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("view.layout.single", "Single page", None, None, Document, "file-text"), c("view.layout.two_up", "Two-page view", None, None, Document, "columns-2"), c("view.continuous", "Continuous scrolling", None, None, Document, "arrow-up-down"), + c("view.page_gaps", "Show gaps between pages", None, None, Document, "arrow-up-down"), c("view.layout.cover", "Show cover page in two-page view", None, None, TwoPageView, "bookmark"), // Acrobat's view modes, a page display and a zoom at once (the rail's Page display menu). c("view.fit_width_scrolling", "Fit to width scrolling", None, None, Document, "arrow-left-right"), @@ -228,7 +231,23 @@ pub const COMMANDS: &[CommandSpec] = &[ c("view.previous_view", "Previous view", None, Some(Shortcut::cmd("[")), Document, "chevron-left"), c("view.next_view", "Next view", None, Some(Shortcut::cmd("]")), Document, "chevron-right"), c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"), - c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"), + c( + "view.side_panel", + "Show or hide the side panel", + VIEW, + Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F4" }), + Nothing, + "panel-right", + ), + c( + "view.tools_pane", + "Show or hide the Tools panel", + VIEW, + Some(Shortcut { command: false, shift: true, mac_ctrl: false, key: "F4" }), + Nothing, + "panel-left", + ), + c("ui.dock.reset", "Reset side panels", VIEW, None, Nothing, "settings"), c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"), c("view.theme.system", "Use system setting", None, None, Nothing, "settings"), c("view.theme.light", "Light gray", None, None, Nothing, "sun"), @@ -275,9 +294,11 @@ pub const COMMANDS: &[CommandSpec] = &[ c("form.tab_order.row", "Tab order: by rows", None, None, HasFields, "rows-3"), c("form.tab_order.column", "Tab order: by columns", None, None, HasFields, "columns-3"), c("form.tab_order.structure", "Tab order: by document structure", None, None, HasFields, "list"), + c("form.calculation_order", "Calculation order", None, None, Document, "list-ordered"), c("edit.text", "Add text", None, None, Modification, "type"), c("edit.edit_text", "Edit text & images", None, None, Modification, "text-cursor-input"), c("edit.link", "Add or edit links", None, None, Modification, "link-2"), + c("edit.attach", "Attach a file…", EDIT, None, Modification, "paperclip"), c("edit.links_from_urls", "Create links from URLs", None, None, Modification, "link-2"), c("edit.remove_links", "Remove all links", None, None, Modification, "trash-2"), c("edit.bates", "Add Bates numbering…", None, None, Modification, "hash"), @@ -292,6 +313,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("redact.sanitize", "Sanitize document…", None, None, Modification, "sparkles"), c("form.field.properties", "Field properties…", None, None, HasFields, "settings-2"), c("form.add.text", "Add a text field", None, None, Modification, "text-cursor-input"), + c("form.add.text_table", "Add text field table", None, None, Modification, "grid-3x3"), c("form.add.checkbox", "Add a checkbox", None, None, Modification, "check-circle-2"), c("form.add.radio", "Add a radio button", None, None, Modification, "circle"), c("form.add.combo", "Add a drop-down list", None, None, Modification, "chevron-down"), @@ -356,6 +378,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"), c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"), c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"), + // Bookmarks from the lines set in chosen text styles, for headings that aren't tagged (#1693). + c("bookmark.from_text_styles", "New bookmarks from text styles…", PAGES, None, Assembly, "list"), c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"), c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"), c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"), @@ -370,6 +394,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"), c("help.app_page", "PdfCraft web page", HELP, None, Nothing, "globe"), @@ -398,6 +423,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Modification => doc.is_some_and(|d| d.allows_modification()), Annotate => doc.is_some_and(|d| d.allows_annotation()), Copying => doc.is_some_and(|d| d.allows_copying()), + Printing => doc.is_some_and(|d| d.allows_printing()), FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()), Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks().contains(&k)), HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()), diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs new file mode 100644 index 0000000000..eee799ed2f --- /dev/null +++ b/crates/engine/src/comments.rs @@ -0,0 +1,163 @@ +//! The comment list and its summary. + +/// The comment list, read from the object graph (as `inspect` lists comments). +pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { + let mut list: Vec = pdfcraft_annot::summaries(doc) + .into_iter() + .map(|s| pdfcraft_render::Annotation { + page: s.page, + subtype: s.subtype, + author: s.author, + contents: s.contents, + modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)), + name: s.name, + in_reply_to: s.in_reply_to, + parent: s.parent, + grouped: s.grouped, + rect: s.rect, + color: s.color, + index: s.index, + state: s.state, + quads: s.quads, + locked: s.locked, + intent: s.intent, + fill_sign: s.fill_sign, + no_rotate: s.no_rotate, + points: s.points, + }) + .collect(); + pdfcraft_render::link_replies(&mut list); + list +} + +/// Order of Summarize Comments. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SummarySort { + #[default] + Page, + Author, + Date, + Type, +} + +impl SummarySort { + pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; + pub fn name(self) -> &'static str { + match self { + SummarySort::Page => "Page", + SummarySort::Author => "Author", + SummarySort::Date => "Date", + SummarySort::Type => "Type", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) + } +} + +/// Acrobat's name for a comment type, as comment lists show it. +pub fn comment_type_name(subtype: &str) -> &str { + match subtype { + "Text" => "Sticky Note", + "FreeText" => "Text Box", + "Highlight" => "Highlight", + "Underline" => "Underline", + "StrikeOut" => "Strikethrough", + "Squiggly" => "Squiggly", + "Square" => "Rectangle", + "Circle" => "Oval", + "Line" => "Line", + "Ink" => "Pencil", + "Polygon" => "Polygon", + "PolyLine" => "Polygonal Line", + "Stamp" => "Stamp", + "Caret" => "Inserted Text", + "FileAttachment" => "File Attachment", + "Sound" => "Sound", + "Redact" => "Redaction", + other => other, + } +} + +/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. +pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { + match a.intent.as_deref() { + Some("FreeTextCallout") => "Callout", + Some("PolygonCloud") => "Cloud", + Some("FreeTextTypeWriter") => "Typewriter", + _ => comment_type_name(&a.subtype), + } +} + +/// The text of a comment summary: one block per comment (replies indented under it), with a +/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are +/// pop-ups; the number is the comment's position on its page. +pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { + use std::fmt::Write; + let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect(); + // Each reply under the annotation its `/IRT` points at, in document order, gathered once. + type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>; + let mut children = Children::new(); + for r in all.iter().filter(|r| r.state.is_none()) { + if let Some(parent) = r.parent { + children.entry(parent).or_default().push(r); + } + } + // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the + // direct parent, so following one level would leave the deeper replies out of the summary. + fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { + use std::fmt::Write; + if depth > 64 { + return; + } + for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { + let indent = " ".repeat(depth + 1); + let _ = + writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); + for line in r.contents.as_deref().unwrap_or("").lines() { + let _ = writeln!(out, "{indent}{line}"); + } + write_replies(out, children, r, depth + 1); + } + } + let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); + let mut last = usize::MAX; + let mut n = 0; + for a in &top { + if a.page != last { + last = a.page; + n = 0; + } + n += 1; + numbered.push((n, a)); + } + match sort { + SummarySort::Page => {} + SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), + SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), + SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))), + } + let mut out = format!("Summary of Comments on {name}\n\n"); + if numbered.is_empty() { + out.push_str("This document has no comments.\n"); + return out; + } + let mut heading = usize::MAX; + for (n, a) in numbered { + if sort == SummarySort::Page && a.page != heading { + heading = a.page; + let _ = writeln!(out, "Page: {}", a.page + 1); + } + let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a)); + if sort != SummarySort::Page { + let _ = write!(out, " Page: {}", a.page + 1); + } + let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); + if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { + let _ = writeln!(out, "{c}"); + } + write_replies(&mut out, &children, a, 0); + out.push('\n'); + } + out +} diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 3c03870f34..dca1e24af5 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -1,12 +1,22 @@ //! Compare files: the text differences between two open documents, a report, and the //! differences marked as comments in the newer one. +use std::collections::HashMap; use std::sync::Arc; +use pdfcraft_cos::ObjRef; + pub use pdfcraft_compare::{Change, Comparison, Kind, Side}; +// How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages +// that draw more. +pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape}; +/// Most bytes of encoded pictures one Office export prepares, counting each image object once +/// (the Word and PowerPoint writers keep at most 512 MiB of them as well). +pub const MAX_EXPORT_IMAGE_BYTES: usize = 512 << 20; + /// Acrobat's compare colours: replaced blue, inserted green, deleted red. pub fn colour(kind: Kind) -> crate::Rgb { match kind { @@ -43,10 +53,13 @@ impl crate::Document { } impl Session { - /// Compare files: the text differences from document `old` to document `new`. + /// Compare files: the text differences from document `old` to document `new`. The result + /// carries both documents' text, so both must allow copying it. pub fn compare(&self, old: DocId, new: DocId) -> Result { let a = self.get(old).ok_or(EditError::NoDocument)?; let b = self.get(new).ok_or(EditError::NoDocument)?; + a.check_copying()?; + b.check_copying()?; Ok(pdfcraft_compare::compare(&a.words(), &b.words())) } @@ -128,6 +141,7 @@ pub enum OfficeFormat { Pptx, Html, Rtf, + Markdown, } impl OfficeFormat { @@ -137,6 +151,7 @@ impl OfficeFormat { OfficeFormat::Pptx => "pptx", OfficeFormat::Html => "html", OfficeFormat::Rtf => "rtf", + OfficeFormat::Markdown => "md", } } @@ -146,17 +161,46 @@ impl OfficeFormat { "pptx" => Some(OfficeFormat::Pptx), "html" | "htm" => Some(OfficeFormat::Html), "rtf" => Some(OfficeFormat::Rtf), + "md" | "markdown" => Some(OfficeFormat::Markdown), _ => None, } } } +/// A Word, PowerPoint, HTML, RTF or Markdown export ([`crate::Document::export_office_checked`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OfficeExport { + /// The exported file. + pub bytes: Vec, + /// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them + /// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops + /// reading there to stay bounded on hostile files, so the text and images of those forms, or + /// those pictures, are missing from `bytes`. + pub truncated_pages: Vec, +} + +/// An image object prepared for Office export: its file extension and encoded bytes, or why it is +/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded). +type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>; + impl crate::Document { /// The pages as paragraphs and images (for Word, HTML and RTF export), including what /// form XObjects draw. pub fn export_pages(&self) -> Vec { - let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() }; - self.info + self.export_pages_checked(true, MAX_EXPORT_IMAGE_BYTES).0 + } + + /// [`Self::export_pages`], and the pages whose forms were left partly unread or whose + /// pictures went past `image_budget` bytes ([`OfficeExport::truncated_pages`]). Without + /// `images` no picture is prepared. + pub(crate) fn export_pages_checked(&self, images: bool, image_budget: usize) -> (Vec, Vec) { + let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) }; + let mut truncated = Vec::new(); + // Each image object is encoded once and shared by every place that draws it. + let mut encoded: HashMap = HashMap::new(); + let mut image_bytes = image_budget; + let pages = self + .info .pages .iter() .enumerate() @@ -171,13 +215,18 @@ impl crate::Document { let crop_width = f64::from(info.crop[2] - info.crop[0]); let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height }); let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 }; - let blocks = pdfcraft_edit::reading_blocks(cos, i) - .unwrap_or_default() + let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default(); + let forms_unread = reading.truncated; + let blocks = reading + .blocks .into_iter() .filter(|b| !b.text.trim().is_empty()) .map(|b| { let f = b.base_font.to_ascii_lowercase(); + // `text` is trimmed; the editor text keeps the source's own spaces. + let space_around = [b.editor_text.starts_with(char::is_whitespace), b.editor_text.ends_with(char::is_whitespace)]; pdfcraft_export::Block { + space_around, text: b.text, rect: visible_rect(b.rect), size: b.size * unit, @@ -188,13 +237,21 @@ impl crate::Document { } }) .collect(); - let images = pdfcraft_edit::reading_images(cos, i) - .unwrap_or_default() + let placed = if images { pdfcraft_edit::reading_images(cos, i).unwrap_or_default() } else { Vec::new() }; + let mut over_budget = false; + let images = placed .iter() .filter_map(|im| { - let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?; + let object = im.object?; + let prepared = encoded.entry(object).or_insert_with(|| { + let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?; + image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?; + Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes))) + }); + over_budget |= matches!(prepared, Err(true)); + let (ext, bytes) = prepared.clone().ok()?; Some(pdfcraft_export::Image { - ext: if ext == "jpg" { "jpg" } else { "png" }, + ext, bytes, rect: visible_rect(im.rect), size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit], @@ -202,20 +259,36 @@ impl crate::Document { }) }) .collect(); - pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images } + if forms_unread || over_budget { + truncated.push(i); + } + // The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one. + let background = pdfcraft_edit::page_background(cos, i).ok().flatten(); + pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background } }) - .collect() + .collect(); + (pages, truncated) } /// The document as a Word, PowerPoint, HTML or RTF file. pub fn export_office(&self, format: OfficeFormat) -> Vec { - let pages = self.export_pages(); + self.export_office_checked(format).bytes + } + + /// [`Self::export_office`], and the pages whose text it had to leave out + /// ([`OfficeExport::truncated_pages`]). + pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport { + // RTF and Markdown leave pictures out. + let (pages, truncated_pages) = + self.export_pages_checked(!matches!(format, OfficeFormat::Rtf | OfficeFormat::Markdown), MAX_EXPORT_IMAGE_BYTES); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); - match format { + let bytes = match format { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), - } + OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(), + }; + OfficeExport { bytes, truncated_pages } } } diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs index 642c84e670..b377903ee4 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -59,6 +59,57 @@ impl Document { let rgba = image::DynamicImage::from_decoder(decoder).map_err(|e| e.to_string())?.into_rgba8(); Ok((rgba.width(), rgba.height(), rgba.into_raw())) } + + /// Edit ▸ Copy of an added image (`index` among `page`'s added items): its picture as a file + /// (extension, bytes), a JPEG or JPEG 2000 file as stored, otherwise a lossless PNG, for + /// [`Edit::PasteContent`](crate::Edit::PasteContent). Refused when the document doesn't allow + /// copying. + pub fn added_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { + self.check_copying().map_err(|e| e.to_string())?; + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let item = self.added().iter().filter(|a| a.page == page).nth(index); + let Some(pdfcraft_edit::Content::Image(img)) = item.map(|a| &a.content) else { + return Err(format!("item {} on page {} is not an added image", index + 1, page + 1)); + }; + picture_file(&editor.cos, img.image) + } +} + +/// An added image's picture as a file: [`pdfcraft_create::image_file`], except that a JPEG 2000 +/// picture, which Add Image stores as the file it was given, is that file. +fn picture_file(cos: &Cos, image: ObjRef) -> Result<(&'static str, Vec), String> { + match &*cos.get(image) { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") && s.dict.name(b"Filter") == Some(b"JPXDecode") => Ok(("jp2", s.raw.to_vec())), + _ => pdfcraft_create::image_file(cos, image), + } +} + +/// Whether image object `image` in `cos` is the picture `file` (as [`picture_file`] writes it). +/// The sizes are compared first, so an unrelated image is not re-encoded to find out. +pub(crate) fn is_picture(cos: &Cos, image: ObjRef, file: &[u8]) -> bool { + let size = |s: &pdfcraft_cos::Stream| Some((u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?)); + // The object at that number now, not one of an earlier generation of it. + if cos.generation(image.num) != image.generation { + return false; + } + let stored = match &*cos.get(image) { + // A JPEG 2000 picture is stored as its file. + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") && s.dict.name(b"Filter") == Some(b"JPXDecode") => return s.raw[..] == *file, + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => size(s), + _ => return false, + }; + let given = image::ImageReader::new(std::io::Cursor::new(file)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); + stored.is_some() && stored == given && picture_file(cos, image).is_ok_and(|(_, data)| data == file) +} + +/// The image object an added image in `cos` already draws picture `file` from, if any: where a +/// second paste from another document finds the first one's. +pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option { + let mut seen = std::collections::HashSet::new(); + pdfcraft_edit::list_added(cos).into_iter().find_map(|a| match a.content { + pdfcraft_edit::Content::Image(img) if seen.insert(img.image) && is_picture(cos, img.image, file) => Some(img.image), + _ => None, + }) } /// The largest raster image Form `form` draws through its resources (nested Forms included). diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index 8c14a5b1b3..c9382c1b02 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -13,7 +13,7 @@ use crate::Session; pub const DEFAULT_DATE_FORMAT: &str = "m/d/yyyy"; /// Ready-made formats for Preferences ▸ Date format. Any other valid pattern works too. -pub const DATE_FORMATS: [&str; 12] = [ +pub const DATE_FORMATS: [&str; 14] = [ "m/d/yyyy", "mm/dd/yyyy", "d/m/yyyy", @@ -21,6 +21,9 @@ pub const DATE_FORMATS: [&str; 12] = [ "dd.mm.yyyy", "yyyy-mm-dd", "yyyy.mm.dd.", + // Year first with slashes, as Taiwan, Japan and China write it (Windows' zh-TW short date). + "yyyy/mm/dd", + "yyyy/m/d", "d mmm yyyy", "d mmmm yyyy", "mmm d, yyyy", @@ -54,9 +57,13 @@ const ZH_DAYS: [&str; 7] = ["星期日", "星期一", "星期二", "星期三", const AR_MONTHS: [&str; 12] = ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"]; const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت"]; +const CKB_MONTHS: [&str; 12] = + ["کانوونی دووەم", "شوبات", "ئازار", "نیسان", "ئایار", "حوزەیران", "تەممووز", "ئاب", "ئەیلوول", "تشرینی یەکەم", "تشرینی دووەم", "کانوونی یەکەم"]; +const CKB_DAYS: [&str; 7] = ["یەکشەممە", "دووشەممە", "سێشەممە", "چوارشەممە", "پێنجشەممە", "هەینی", "شەممە"]; + /// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language): /// the interface languages, in the same order and with the same names. -pub static DATE_LANGUAGES: [DateLanguage; 23] = [ +pub static DATE_LANGUAGES: [DateLanguage; 35] = [ DateLanguage { code: "en", name: "English", @@ -250,6 +257,27 @@ pub static DATE_LANGUAGES: [DateLanguage; 23] = [ days: AR_DAYS, days_short: AR_DAYS, }, + // Central Kurdish (Sorani) has no shorter written forms of its month and weekday names either. + DateLanguage { + code: "ckb", + name: "کوردیی ناوەندی", + months: CKB_MONTHS, + months_with_day: None, + months_short: CKB_MONTHS, + days: CKB_DAYS, + days_short: CKB_DAYS, + }, + // Persian (Farsi); Gregorian month and weekday names, right to left like Arabic and Kurdish. + // Persian does not change the month form after a day number, so `months_with_day` is `None`. + DateLanguage { + code: "fa", + name: "فارسی", + months: ["ژانویه", "فوریه", "مارس", "آوریل", "مه", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"], + months_with_day: None, + months_short: ["ژانویه", "فوریه", "مارس", "آوریل", "مه", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"], + days: ["یکشنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه", "شنبه"], + days_short: ["یک", "دو", "سه", "چه", "پن", "جم", "شن"], + }, DateLanguage { code: "pl", name: "Polski", @@ -360,6 +388,17 @@ pub static DATE_LANGUAGES: [DateLanguage; 23] = [ days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"], days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"], }, + // Indonesian. Month and weekday names are the same with and after a day number, so + // `months_with_day` is `None`. + DateLanguage { + code: "id", + name: "Bahasa Indonesia", + months: ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"], + months_with_day: None, + months_short: ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"], + days: ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"], + days_short: ["Min", "Sen", "Sel", "Rab", "Kam", "Jum", "Sab"], + }, // Vietnamese: numeric month forms (`tháng 1` with a day, `Tháng 1` standing alone). // Fill & Sign still refuses any format whose final text fails the existing WinAnsi check // (for example weekday names); `dd.mm.yyyy` remains writable. @@ -398,6 +437,144 @@ pub static DATE_LANGUAGES: [DateLanguage; 23] = [ days: ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"], days_short: ["CN", "T2", "T3", "T4", "T5", "T6", "T7"], }, + DateLanguage { + code: "fi", + name: "Suomi", + months: [ + "tammikuu", + "helmikuu", + "maaliskuu", + "huhtikuu", + "toukokuu", + "kesäkuu", + "heinäkuu", + "elokuu", + "syyskuu", + "lokakuu", + "marraskuu", + "joulukuu", + ], + // After a day number the month takes the partitive: `14. lokakuuta 2026`. + months_with_day: Some([ + "tammikuuta", + "helmikuuta", + "maaliskuuta", + "huhtikuuta", + "toukokuuta", + "kesäkuuta", + "heinäkuuta", + "elokuuta", + "syyskuuta", + "lokakuuta", + "marraskuuta", + "joulukuuta", + ]), + months_short: ["tammi", "helmi", "maalis", "huhti", "touko", "kesä", "heinä", "elo", "syys", "loka", "marras", "joulu"], + days: ["sunnuntai", "maanantai", "tiistai", "keskiviikko", "torstai", "perjantai", "lauantai"], + days_short: ["su", "ma", "ti", "ke", "to", "pe", "la"], + }, + DateLanguage { + code: "sq", + name: "Shqip", + months: ["janar", "shkurt", "mars", "prill", "maj", "qershor", "korrik", "gusht", "shtator", "tetor", "nëntor", "dhjetor"], + months_with_day: None, + months_short: ["jan", "shk", "mar", "pri", "maj", "qer", "korr", "gush", "sht", "tet", "nën", "dhj"], + days: ["e diel", "e hënë", "e martë", "e mërkurë", "e enjte", "e premte", "e shtunë"], + days_short: ["die", "hën", "mar", "mër", "enj", "pre", "sht"], + }, + DateLanguage { + code: "th", + name: "ไทย", + months: ["มกราคม", "กุมภาพันธ์", "มีนาคม", "เมษายน", "พฤษภาคม", "มิถุนายน", "กรกฎาคม", "สิงหาคม", "กันยายน", "ตุลาคม", "พฤศจิกายน", "ธันวาคม"], + months_with_day: None, + months_short: ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."], + days: ["วันอาทิตย์", "วันจันทร์", "วันอังคาร", "วันพุธ", "วันพฤหัสบดี", "วันศุกร์", "วันเสาร์"], + days_short: ["อา.", "จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส."], + }, + DateLanguage { + code: "ca", + name: "Català", + months: ["gener", "febrer", "març", "abril", "maig", "juny", "juliol", "agost", "setembre", "octubre", "novembre", "desembre"], + // After a day the month carries its own preposition, elided before a vowel: `7 d'abril`. + months_with_day: Some([ + "de gener", + "de febrer", + "de març", + "d'abril", + "de maig", + "de juny", + "de juliol", + "d'agost", + "de setembre", + "d'octubre", + "de novembre", + "de desembre", + ]), + months_short: ["gen.", "febr.", "març", "abr.", "maig", "juny", "jul.", "ag.", "set.", "oct.", "nov.", "des."], + days: ["diumenge", "dilluns", "dimarts", "dimecres", "dijous", "divendres", "dissabte"], + days_short: ["dg.", "dl.", "dt.", "dx.", "dj.", "dv.", "ds."], + }, + // Romanian: month and weekday names are lowercase, also standing alone (`noiembrie 2023`). + DateLanguage { + code: "ro", + name: "Română", + months: [ + "ianuarie", + "februarie", + "martie", + "aprilie", + "mai", + "iunie", + "iulie", + "august", + "septembrie", + "octombrie", + "noiembrie", + "decembrie", + ], + months_with_day: None, + months_short: ["ian.", "feb.", "mar.", "apr.", "mai", "iun.", "iul.", "aug.", "sept.", "oct.", "nov.", "dec."], + days: ["duminică", "luni", "marți", "miercuri", "joi", "vineri", "sâmbătă"], + days_short: ["dum.", "lun.", "mar.", "mie.", "joi", "vin.", "sâm."], + }, + DateLanguage { + code: "lo", + name: "ພາສາລາວ", + months: ["ມັງກອນ", "ກຸມພາ", "ມີນາ", "ເມສາ", "ພຶດສະພາ", "ມິຖຸນາ", "ກໍລະກົດ", "ສິງຫາ", "ກັນຍາ", "ຕຸລາ", "ພະຈິກ", "ທັນວາ"], + months_with_day: None, + months_short: ["ມ.ກ.", "ກ.ພ.", "ມ.ນ.", "ມ.ສ.", "ພ.ພ.", "ມິ.ຖ.", "ກ.ລ.", "ສ.ຫ.", "ກ.ຍ.", "ຕ.ລ.", "ພ.ຈ.", "ທ.ວ."], + days: ["ວັນອາທິດ", "ວັນຈັນ", "ວັນອັງຄານ", "ວັນພຸດ", "ວັນພະຫັດ", "ວັນສຸກ", "ວັນເສົາ"], + days_short: ["ອາທິດ", "ຈັນ", "ອັງຄານ", "ພຸດ", "ພະຫັດ", "ສຸກ", "ເສົາ"], + }, + // Hindi (Devanagari). Month and weekday names are the same with and after a day number. + DateLanguage { + code: "hi", + name: "हिन्दी", + months: ["जनवरी", "फ़रवरी", "मार्च", "अप्रैल", "मई", "जून", "जुलाई", "अगस्त", "सितंबर", "अक्तूबर", "नवंबर", "दिसंबर"], + months_with_day: None, + months_short: ["जन॰", "फ़र॰", "मार्च", "अप्रैल", "मई", "जून", "जुला॰", "अग॰", "सित॰", "अक्तू॰", "नव॰", "दिस॰"], + days: ["रविवार", "सोमवार", "मंगलवार", "बुधवार", "गुरुवार", "शुक्रवार", "शनिवार"], + days_short: ["रवि", "सोम", "मंगल", "बुध", "गुरु", "शुक्र", "शनि"], + }, + // Korean: months are numbered (`3월`) and weekdays end in 요일; no names change after a day. + DateLanguage { + code: "ko", + name: "한국어", + months: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"], + months_with_day: None, + months_short: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"], + days: ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"], + days_short: ["일", "월", "화", "수", "목", "금", "토"], + }, + DateLanguage { + code: "nb", + name: "Norsk bokmål", + months: ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"], + months_with_day: None, + months_short: ["jan.", "feb.", "mar.", "apr.", "mai", "jun.", "jul.", "aug.", "sep.", "okt.", "nov.", "des."], + days: ["søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag"], + days_short: ["søn.", "man.", "tir.", "ons.", "tor.", "fre.", "lør."], + }, ]; /// The date language for `code` (any case), or `None` when there's none. @@ -604,6 +781,8 @@ mod tests { "07.03.2026", "2026-03-07", "2026.03.07.", + "2026/03/07", + "2026/3/7", "7 Mar 2026", "7 March 2026", "Mar 7, 2026", @@ -701,6 +880,10 @@ mod tests { assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "ja"), "2026年3月7日 土曜日"); assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "zh-hans"), "2026年三月7日 星期六"); assert_eq!(in_lang("ddd", "zh-hant"), "週六"); + assert_eq!(in_lang("dddd, d mmmm yyyy", "sq"), "e shtunë, 7 mars 2026"); + assert_eq!(in_lang("ddd d mmm", "sq"), "sht 7 mar"); + assert_eq!(in_lang("dddd d. mmmm yyyy", "nb"), "lørdag 7. mars 2026"); + assert_eq!(in_lang("ddd d. mmm", "nb"), "lør. 7. mar."); assert_eq!(date_language("PT-BR").map(|l| l.code), Some("pt-br")); assert!(date_language("xx").is_none()); for l in &DATE_LANGUAGES { @@ -718,6 +901,46 @@ mod tests { assert_eq!(in_lang("dddd, mmmm", "cs"), "sobota, březen"); } + /// Indonesian month and weekday names are the same with and after a day number. (Vietnamese + /// has its own test upstream: its numeric month forms are writable, weekday names are not.) + #[test] + fn indonesian_month_and_weekday_names() { + assert_eq!(in_lang("d mmmm yyyy", "id"), "7 Maret 2026"); + assert_eq!(in_lang("mmmm yyyy", "id"), "Maret 2026"); + assert_eq!(in_lang("dddd, d mmmm yyyy", "id"), "Sabtu, 7 Maret 2026"); + // Indonesian month names are Latin, so they reach a PDF. + let mut s = Session::new().with_clock(|| 1_700_000_000); + s.set_date_language(Some("id")).unwrap(); + assert_eq!(s.today_text_for_pdf(Some("d mmmm yyyy"), None).as_deref(), Ok("14 November 2023")); + } + + /// Hindi and Korean names are the same with and after a day number. Neither is Western + /// European, so the date-text path refuses them with what to use instead. + #[test] + fn hindi_and_korean_month_and_weekday_names() { + assert_eq!(in_lang("d mmmm yyyy", "hi"), "7 मार्च 2026"); + assert_eq!(in_lang("dddd, mmmm", "hi"), "शनिवार, मार्च"); + assert_eq!(in_lang("ddd, d mmm", "hi"), "शनि, 7 मार्च"); + assert_eq!(in_lang("yyyy\\년 mmmm d\\일 dddd", "ko"), "2026년 3월 7일 토요일"); + assert_eq!(in_lang("ddd", "ko"), "토"); + let mut s = Session::new().with_clock(|| 1_700_000_000); + for code in ["hi", "ko"] { + s.set_date_language(Some(code)).unwrap(); + assert!(s.today_text_for_pdf(Some("d mmmm yyyy"), None).unwrap_err().contains("dd.mm.yyyy"), "{code}"); + } + } + + #[test] + fn catalan_months_elide_the_preposition_after_a_day() { + assert_eq!(in_lang("dddd d mmmm \\de yyyy", "ca"), "dissabte 7 de març de 2026"); + assert_eq!(in_lang("mmmm yyyy", "ca"), "març 2026"); + let ca = date_language("ca").unwrap(); + assert_eq!(format_day("d mmmm \\de yyyy", (2026, 4, 23), ca), "23 d'abril de 2026"); + assert_eq!(format_day("d mmmm", (2026, 8, 1), ca), "1 d'agost"); + assert_eq!(format_day("d mmmm", (2026, 10, 8), ca), "8 d'octubre"); + assert_eq!(format_day("mmmm", (2026, 10, 8), ca), "octubre", "no preposition without a day"); + } + #[test] fn the_session_writes_names_in_its_date_language() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -741,6 +964,23 @@ mod tests { assert_eq!(s.date_language(), None); } + #[test] + fn romanian_month_and_weekday_names() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + s.set_date_language(Some("ro")).unwrap(); + assert_eq!(s.date_language(), Some("ro")); + s.set_date_format("d mmmm yyyy").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("14 noiembrie 2023")); + s.set_date_format("mmmm yyyy").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("noiembrie 2023")); + s.set_date_format("ddd, d mmm").unwrap(); + assert_eq!(s.today_text(None, None).as_deref(), Ok("mar., 14 nov.")); + // `14 noiembrie 2023` is WinAnsi; the weekday `marți` (ț, comma below) is not, so Fill & Sign refuses that format. + assert_eq!(s.today_text_for_pdf(Some("d mmmm yyyy"), Some("ro")).as_deref(), Ok("14 noiembrie 2023")); + let e = s.today_text_for_pdf(Some("dddd d mmmm yyyy"), Some("ro")).unwrap_err(); + assert!(e.contains("can't be written into the PDF yet"), "{e}"); + } + #[test] fn vietnamese_month_and_weekday_names() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -765,6 +1005,7 @@ mod tests { assert_eq!(unwritable("8. října 2026"), "ř"); assert_eq!(unwritable("2026年10月8日"), "年月日"); assert_eq!(unwritable("sábado, 7 de março – 2026?"), "", "Western European, dashes and ? are fine"); + assert_eq!(unwritable("lørdag 7. mars, søn. 8. mai (æøå ÆØÅ)"), "", "Norwegian letters are Western European"); let mut s = Session::new().with_clock(|| 1_700_000_000); s.set_date_format("d. mmmm yyyy").unwrap(); assert_eq!(s.today_text_for_pdf(None, Some("es")).as_deref(), Ok("14. noviembre 2023")); diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs new file mode 100644 index 0000000000..d28429de6f --- /dev/null +++ b/crates/engine/src/doc_state.rs @@ -0,0 +1,118 @@ +use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize}; + +/// A change to an existing page image. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageEdit { + /// Move and resize it to this box (user space). + Move([f64; 4]), + /// Quarter turns clockwise about its centre. + Rotate(i32), + /// Any angle in degrees clockwise about its centre (the unit is degrees, not quarter turns). + RotateBy(f64), + Flip { + horizontal: bool, + }, + /// Show only this box of it (user space), the pixels staying where they are. + Crop([f64; 4]), + /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. + Replace { + name: String, + bytes: Arc>, + }, + Delete, +} + +/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a +/// filled-in PDF form), as CSV with one row per file. +pub fn merge_data_files(files: &[(String, Vec)]) -> Result { + let rows = + files.iter().map(|(name, bytes)| pdfcraft_xfdf::data_values(bytes).map_err(|e| format!("{name}: {e}"))).collect::, _>>()?; + Ok(pdfcraft_xfdf::merge_csv(&rows)) +} + +/// One file produced by a split: (1-based first page, last page, PDF bytes). +pub type SplitPart = (usize, usize, Arc>); + +/// Stable identifier of an open document within a session. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DocId(pub u64); + +/// The highest resolution a document that allows only low-resolution printing (Table 22, bit 12 +/// clear) is printed at. +pub(crate) const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; + +pub(crate) fn render_threads() -> usize { + std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(4).clamp(2, 8) - 1 +} + +/// A document's working file captured for crash recovery. +#[derive(Clone, Debug)] +pub struct RecoverySnapshot { + pub doc: DocId, + pub name: String, + pub path: Option, + pub bytes: Arc>, + /// The snapshot is encrypted (recovering it asks for the password again). + pub encrypted: bool, +} + +/// The recovered user password of an R2–R4 file as text for the renderer, which tries a password +/// as its UTF-8 bytes and then in PDFDocEncoding: UTF-8 bytes (as some writers store passwords +/// PDFDocEncoding can't hold, such as "şifre") stay UTF-8, and other bytes are read as +/// PDFDocEncoding, so either way the renderer gets back exactly these bytes. +pub(crate) fn renderer_password(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(text) => text.to_owned(), + Err(_) => bytes.iter().map(|b| pdfcraft_cos::pdfdoc_char(*b)).collect(), + } +} + +/// The document as it may be written to a file (#3926, #3944): a copy without the values of text +/// fields with the Password flag (or XFA `` fields in the datasets), or `cos` +/// itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 (a note to the Password flag): +/// "it is imperative that PDF processors never store the value of the text field in the PDF file +/// if this flag is set". The open document keeps them in memory. +pub(crate) fn writable(cos: &pdfcraft_cos::Document) -> Result, EditError> { + let has_form_pw = pdfcraft_forms::has_password_values(cos); + let has_xfa_pw = pdfcraft_xfa::has_password_values(cos); + if !has_form_pw && !has_xfa_pw { + return Ok(std::borrow::Cow::Borrowed(cos)); + } + let mut copy = cos.clone(); + if has_form_pw { + pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + } + if has_xfa_pw { + pdfcraft_xfa::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + } + Ok(std::borrow::Cow::Owned(copy)) +} + +/// The last-resort guard (AGENTS.md §4): run `f`, turning a panic that escapes it into an error +/// message, so one bad file or edit can't take the app and its other documents down. It is a +/// safety net for bugs, not a substitute for returning errors. +pub fn guard(f: impl FnOnce() -> T) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|p| { + p.downcast_ref::<&str>().map(|s| (*s).to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "unknown error".into()) + }) +} + +/// Validate the signature fields of `cos` (written as `bytes`). +pub(crate) fn signatures_of( + cos: &pdfcraft_cos::Document, + bytes: &[u8], + trust: &TrustStore, + cache: &pdfcraft_sign::DigestCache, +) -> Vec { + pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) +} + +/// What Optimize PDF did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OptimizeReport { + pub optimize: optimize::Report, + /// Identical objects merged. + pub merged: usize, + /// Remove Hidden Information categories discarded, with counts. + pub discarded: Vec<(Hidden, usize)>, +} diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs new file mode 100644 index 0000000000..fa98a18417 --- /dev/null +++ b/crates/engine/src/document.rs @@ -0,0 +1,792 @@ +//! An open document: its working file, view data and edit state. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, RenderConfig, RenderPool}; + +use crate::history::Editor; +use crate::{ + CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary, + SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted, +}; +#[cfg(test)] +use crate::{record_link_scan, record_marker_scan}; + +pub struct Document { + pub id: DocId, + pub name: String, + pub path: Option, + /// The working file: what Save writes. + pub bytes: Arc>, + /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances + /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. + pub display: Arc>, + pub info: DocInfo, + pub renderer: RenderPool, + /// The password the document was opened with (needed to read attachments, etc.). + pub password: Option, + /// `true` when there are edits that have not been saved. + pub dirty: bool, + /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. + pub(crate) generation: u64, + /// The generation last handed out by `autosave_snapshots`. + pub(crate) snapshot_generation: u64, + /// Why the document cannot be edited (e.g. encryption), if so. + pub read_only_reason: Option, + /// Interactive form fields of the current state (empty without a form). + pub form: Arc>, + pub(crate) marker_scan: OnceLock, + pub(crate) links: OnceLock>, + /// Signature fields, validated against the session's trust store (Signatures panel). + pub signatures: Arc>, + pub(crate) trust: Arc, + pub(crate) sig_cache: Arc, + pub(crate) editor: Option, + pub(crate) config: RenderConfig, + /// What field scripts printed or asked for (see [`Session::take_js_output`]). + pub(crate) js_output: js::JsOutput, + /// Dynamic XFA forms: what laying the template out produced (pages and fields are + /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). + pub xfa: Option, + /// The parsed template of a laid-out XFA form, for its scripts. + pub(crate) xfa_template: Option>, + /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). + pub(crate) xfa_scripts_off: bool, + /// A content-only refresh keeps `info` from before the edit; an edit may have added or + /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). + pub(crate) fonts_stale: bool, + /// XFA forms: what was approximated, rewritten or could not be written to the XFA data + /// (also in `info.warnings`, kept there when the document is re-read). + pub xfa_warnings: Vec, + /// What the last successful [`Session::apply`](crate::Session::apply) wants said (see + /// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off. The + /// interface shows it; tools return it. + pub edit_notices: Vec, +} + +/// Something the last successful edit did that the user should hear about, never left silent +/// (AGENTS.md §4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditNotice { + /// Text stored as typed but drawn with `?`. + Undrawn(TextNotice), + /// Remove background couldn't turn back on the white backdrop these pages (0-based, + /// ascending) paint themselves: their content changed after the background replaced it, so + /// turning it on could paint over something (`pdfcraft_edit::backdrops_left_off`). They look + /// the same on white paper. + BackdropsLeftOff { pages: Vec }, +} + +impl std::fmt::Display for EditNotice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EditNotice::Undrawn(n) => n.fmt(f), + EditNotice::BackdropsLeftOff { pages } => { + let (which, own) = if pages.len() == 1 { ("page", "its") } else { ("pages", "their") }; + write!( + f, + "{which} {} changed after the background was added, so {own} own white backdrop was not turned back on (it looks the same on white paper)", + page_runs(pages) + ) + } + } + } +} + +/// 0-based pages as 1-based runs: `[0, 1, 2, 5]` is "1-3, 6". +pub(crate) fn page_runs(pages: &[usize]) -> String { + let mut runs: Vec<(usize, usize)> = Vec::new(); + for &p in pages { + match runs.last_mut() { + Some((_, end)) if end.checked_add(1) == Some(p) => *end = p, + _ => runs.push((p, p)), + } + } + let one = |p: usize| p.saturating_add(1); + runs.iter().map(|&(a, b)| if a == b { one(a).to_string() } else { format!("{}-{}", one(a), one(b)) }).collect::>().join(", ") +} + +/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn +/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat +/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextNotice { + /// The form field's full name, or `None` for a comment. + pub field: Option, + /// The page (0-based) of the comment or of the field's widget. + pub page: usize, + pub ch: char, + /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` + /// set); otherwise the `?` shows in every viewer. + pub redrawn_elsewhere: bool, +} + +impl std::fmt::Display for TextNotice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); + let page = self.page + 1; + match (&self.field, self.redrawn_elsewhere) { + (Some(name), true) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" + ), + (Some(name), false) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" + ), + (None, _) => write!( + f, + "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" + ), + } + } +} + +/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying +/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. +pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { + let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; + let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); + let mut names: Option> = None; + let mut out: Vec = Vec::new(); + for (page, p) in pages.iter().enumerate() { + let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); + for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { + let now = next.get(r); + let Some(d) = now.as_dict() else { continue }; + let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; + // Only what this edit drew: a new annotation, or a new appearance. + let was = prev.get(r); + if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { + continue; + } + let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { + let names = names.get_or_insert_with(|| { + pdfcraft_forms::fields(next) + .into_iter() + .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) + .collect() + }); + names.get(&r).cloned().unwrap_or_default() + }); + if field.is_some() && out.iter().any(|n| n.field == field) { + continue; + } + out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); + } + } + out +} + +impl Document { + /// How the document opens (Document Properties ▸ Initial View). + /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be + /// read for editing). + pub fn accessibility_check(&self, options: &a11y::Options) -> Option { + self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) + } + + /// A script of this XFA form ran past its time limit and was abandoned, so the form's + /// scripts no longer run in this document (reopening it runs them again). The script's + /// error is in the JavaScript output; this is the state, for a viewer to show. + pub fn xfa_scripts_off(&self) -> bool { + self.xfa_scripts_off + } + + /// PDF Optimizer ▸ Audit space usage. + pub fn audit_space(&self) -> Vec { + self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() + } + + /// The backdrop `page` (0-based) paints itself, which hides a background (Add Background). + pub fn page_backdrop(&self, page: usize) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_edit::page_backdrop(&e.cos, page).ok().flatten()) + } + + /// [`Self::page_backdrop`] for each of `pages`, in order, walking the page tree once: the + /// way to check many pages. A page out of range has none. + pub fn page_backdrops(&self, pages: &[usize]) -> Vec> { + match &self.editor { + Some(e) => pdfcraft_edit::page_backdrops(&e.cos, pages), + None => vec![None; pages.len()], + } + } + + /// Edit a PDF: the images `page` draws (0-based). + pub fn page_images(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() + } + + /// Mixed editable objects on one page. Source inventories are zero-based and added items + /// appear once. Rectangles are user-space points, independent of the viewer's zoom. + pub fn editable_objects(&self, page: usize) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) + } + + /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, + /// so even a large crop-box origin cannot erase a small drag by cancellation. + pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { + let p = self.info.pages.get(page).ok_or("the page no longer exists")?; + let [x0, y0, x1, y1] = p.crop.map(f64::from); + let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); + let (u, v) = match p.rotation { + 90 => (b, -a), + 180 => (-a, -b), + 270 => (-b, a), + _ => (a, b), + }; + let offset = [u * (x1 - x0), -v * (y1 - y0)]; + if !offset.iter().all(|v| v.is_finite()) { + return Err("the move must be finite and non-zero".into()); + } + Ok(offset) + } + + /// Save image as: image `index` on `page` as a file (extension, bytes). + pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { + self.check_copying().map_err(|e| e.to_string())?; + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; + if img.is_form { + return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); + } + pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) + } + + /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). + pub fn text_blocks(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() + } + + /// Saved measurement annotations, calculated from their geometry and PDF scales, plus + /// the ones that couldn't be read (unsupported formats are skipped, not fatal). + pub fn measurements(&self) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + Ok(measure::list(&e.cos)) + } + pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) + } + /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a + /// point) where the page sets no scale. + pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) + } + pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) + } + pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) + } + pub fn measurement_paths(&self, page: usize) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) + } + + /// A counter that changes with every edit (for caches of derived data). + pub fn edit_generation(&self) -> u64 { + self.generation + } + + /// The styles the document's text is set in, the largest first, for bookmarks from text + /// styles (#1693). + /// Empty when the document doesn't allow copying: the samples are its text. + pub fn text_styles(&self) -> Vec { + if !self.allows_copying() { + return Vec::new(); + } + self.editor.as_ref().map(|e| crate::text_style_bookmarks::styles(&e.cos)).unwrap_or_default() + } + + /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). + pub fn text_lines(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() + } + + /// The form's calculation order: its calculated fields in the order they calculate, what each + /// formula reads, what is wrong with the order, and the order Fix order writes. + pub fn calculation_order(&self) -> CalculationOrder { + self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() + } + + /// Page properties ▸ Tab order: each page's tab order, in page order. + pub fn tab_orders(&self) -> Vec { + self.editor.as_ref().map(|e| pdfcraft_forms::tab_orders(&e.cos)).unwrap_or_default() + } + + /// Add alternate text: the figures, in document order. + pub fn figures(&self) -> Vec { + self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() + } + + /// Reachable indirect structure tags, in document order. + pub fn tags(&self) -> Vec { + self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() + } + + /// Marked-content items directly owned by one reachable indirect structure tag. + pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { + self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) + } + + /// The edit that fixes `rule`, for the rules with an automatic fix: the document language + /// (`value` is the language), the title (`value` replaces it; else the current title or the + /// file name) and the tab order. + pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { + let value = value.map(str::trim).filter(|v| !v.is_empty()); + match rule { + a11y::Rule::PrimaryLanguage => { + let mut v = self.initial_view(); + v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); + Ok(Edit::SetInitialView(Box::new(v))) + } + a11y::Rule::Title => { + let title = value + .map(str::to_owned) + .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) + .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); + let mut v = self.initial_view(); + v.display_title = true; + Ok(Edit::Batch { + label: "Set document title".into(), + edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], + }) + } + a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), + other => Err(format!("\"{}\" has no automatic fix", other.name())), + } + } + + pub fn initial_view(&self) -> InitialView { + self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() + } + + /// Signed: at least one signature field holds a signature. + pub fn is_signed(&self) -> bool { + self.signatures.iter().any(|s| s.signed) + } + + /// Whether comments are hidden (Comments ▸ Hide all comments). + pub fn comments_hidden(&self) -> bool { + self.config.hide_comments + } + + /// Whether `Edit::DeleteAnnotation` would delete the annotation at `(page, index)`: the + /// engine's own check (it, and every reply that would go with it, unlocked), for a Delete + /// command to ask before it is offered. + pub fn annotation_deletable(&self, page: usize, index: usize) -> bool { + self.editor.as_ref().is_some_and(|e| pdfcraft_annot::deletable(&e.cos, page, index).is_ok()) + } + + pub fn can_undo(&self) -> Option<&str> { + self.undo_label().map(Label::as_str) + } + + pub fn can_redo(&self) -> Option<&str> { + self.redo_label().map(Label::as_str) + } + + /// What Undo would undo, in parts, for an interface that says it in another language. + pub fn undo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) + } + + /// What Redo would redo, in parts. + pub fn redo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) + } + + pub fn editable(&self) -> bool { + self.editor.is_some() + } + + /// What the opening password allows; `None` when the document is not encrypted. + pub fn permissions(&self) -> Option { + self.editor.as_ref().and_then(|e| e.cos.permissions()) + } + + /// Printing is allowed (Table 22, bit 3). + pub fn allows_printing(&self) -> bool { + self.permissions().is_none_or(|p| p.print()) + } + + /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying + /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the + /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. + pub fn allows_copying(&self) -> bool { + self.permissions().is_none_or(|p| p.copy()) + } + + /// [`Self::allows_copying`], as the error to report when it isn't. + pub fn check_copying(&self) -> Result<(), EditError> { + if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } + } + + /// Page changes (insert, delete, rotate, move, extract) are allowed. + pub fn allows_assembly(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.assemble()) + } + + /// Redaction marks waiting to be applied. + pub fn redaction_marks(&self) -> usize { + self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() + } + + /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. + pub fn marks(&self) -> &[MarkKind] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .marks + } + + /// Text and images added with Edit a PDF ▸ Add content (still editable). + pub fn added(&self) -> &[pdfcraft_edit::Added] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .added + } + + /// Link annotations, for Edit a PDF ▸ Link. + pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { + self.links.get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_link_scan(); + pdfcraft_annot::links::list(&e.cos) + }) + .unwrap_or_default() + }) + } + + /// Changes to content and document information are allowed. + pub fn allows_modification(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.modify()) + } + + /// How widget `widget` of text field `name` lays its value out, for asking whether a typed + /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no + /// more than fits. `None` without such a field or widget, or when the document can't be + /// edited. + pub fn field_text_box(&self, name: &str, widget: usize) -> Option { + let cos = &self.editor.as_ref()?.cos; + let f = self.form.iter().find(|f| f.name == name)?; + let w = f.widgets.get(widget)?; + Some(form_appearance::text_box(cos, f, w)) + } + + /// Filling in form fields is allowed (Table 22, bits 6 and 9). + pub fn allows_form_filling(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) + } + + /// Adding and changing comments is allowed (Table 22, bit 6). + pub fn allows_annotation(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.annotate()) + } + + /// The document's security may be changed (Protect, Remove security): it is editable and, + /// if encrypted, was opened with the owner password. + pub fn allows_security_change(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) + } + + /// A password restricts the document and it wasn't opened with the owner password: entering + /// that password ([`Session::unlock`](crate::Session::unlock)) lifts the restrictions. + pub fn can_unlock(&self) -> bool { + self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) + } + + /// A summary of the document's security for Document Properties ▸ Security, including + /// protection applied in this session (written by the next save). + pub fn security_summary(&self) -> Option { + let editor = self.editor.as_ref()?; + let h = editor.cos.output_handler()?; + let d = h.dict(); + let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); + let method = match (d.v, stream) { + (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", + (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", + (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", + (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", + _ => "Attachments only", + }; + let pending = editor.cos.encryption_changed(); + let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; + Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) + } + + /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). + pub fn comment_props(&self, page: usize, index: usize) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) + } + + /// A form field's Appearance-tab look (borders, colours, font). + pub fn field_look(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + Some(pdfcraft_forms::look(&e.cos, f)) + } + + /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. + pub fn field_check_style(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) + } + + /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): + /// the page (0-based) of the first widget that cuts it, and how. + pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) + } + + /// Remove Hidden Information: what each category would remove. + pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { + self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() + } + + /// Verification: faux redactions (opaque covers over extractable text) and redaction + /// marks that were never applied. Fails closed: any read error is returned, never an + /// empty list. + pub fn audit_redactions(&self) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) + } + + /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. + pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { + self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() + } + + /// Current value of a document-information entry (Title, Author, …). + pub fn info_value(&self, key: &str) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) + } + + /// Whether the document information has an entry `key`, of any type, listed or not. + pub fn has_info(&self, key: &str) -> bool { + self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key)) + } + + /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. + pub fn custom_info(&self) -> Vec<(String, String)> { + self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() + } + + /// The name shown on the tab and window: the document title when the document asks for it + /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. + pub fn display_name(&self) -> String { + self.editor + .as_ref() + .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) + .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) + .map(|t| t.trim().to_owned()) + .filter(|t| !t.is_empty() && !is_placeholder_title(t)) + .unwrap_or_else(|| self.name.clone()) + } + + /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that + /// revision). Unsaved edits aren't a revision yet. + pub fn revision_ends(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() + } + + /// The file as last opened or saved, without unsaved edits: what an incremental save appends + /// to. This stays that file when undo has restored an older snapshot. + pub fn saved_bytes(&self) -> Arc> { + self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) + } + + /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). + pub fn repair_log(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() + } +} + +/// A title that names no document: what a web browser writes when it saves a blank page or a +/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a +/// title says less than the file name, so the file name is shown instead. An address is one +/// word: "About: our company" is a real title. +pub(crate) fn is_placeholder_title(title: &str) -> bool { + let t = title.trim().to_lowercase(); + let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); + address || t == "untitled" +} + +/// The working file plus appearances for comments that have none, and for form fields the file +/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the +/// viewer and never enter these bytes, which image/text exports and Print as Image also consume. +pub(crate) fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { + let Some(e) = editor else { return bytes.clone() }; + let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances); + let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances); + let Some(display) = fields.or(comments) else { + return bytes.clone(); + }; + match write_incremental(&display, &SaveOptions::default()) { + Ok(written) => Arc::new(written), + Err(err) => { + e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})")); + bytes.clone() + } + } +} + +/// `comments` (the display copy with comment appearances, if it needed any; else the document +/// itself) with its form fields drawn from their values where the file leaves that to the viewer. +fn with_field_appearances(comments: Option, doc: &pdfcraft_cos::Document) -> Option { + pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments) +} + +/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. +pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { + a.len() == b.len() + && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) +} + +/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read +/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. +pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { + let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); + [ + (&mut info.title, read("Title")), + (&mut info.author, read("Author")), + (&mut info.subject, read("Subject")), + (&mut info.keywords, read("Keywords")), + (&mut info.creator, read("Creator")), + (&mut info.producer, read("Producer")), + ] +} + +/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for +/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page +/// images agree with the app and independent viewers. It never changes the source document, and an +/// unparseable file, a write failure or a parser panic falls back to the original bytes. +pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc> { + guard(|| { + pdfcraft_cos::Document::open_with_password(bytes.clone(), password) + .ok() + .and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) + .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) + .map(Arc::new) + }) + .ok() + .flatten() + .unwrap_or(bytes) +} + +/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with +/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their +/// owner password this is the user password that owner authentication recovers; otherwise it is +/// `password` itself. Render paths outside a [`Session`](crate::Session), such as the CLI, use this so the owner +/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps +/// it, so the renderer reports the same error it always did. +pub fn render_password_for(bytes: Arc>, password: Option<&str>) -> Option { + let password = password?; + let recovered = + guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten(); + Some(recovered.unwrap_or_else(|| password.to_owned())) +} + +/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it. +pub(crate) fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option { + cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user)) +} + +/// Fails when the renderer still draws text under an applied redaction mark. +/// +/// Redaction decides what lies under a mark from the positions its own interpreter computes, and +/// its verify pass reuses them. Where they differ from the renderer's (a standard font without +/// /Widths, whose advances the interpreter approximates; a CMap +/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the +/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is +/// what the user saw when placing the marks, so it is asked. `after` is written as a save would +/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its +/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps +/// the document as it was. +pub(crate) fn shown_under_redactions( + after: &pdfcraft_cos::Document, + marks: &[pdfcraft_redact::Mark], + overlays: &[pdfcraft_cos::ObjRef], + pages: &[pdfcraft_render::PageInfo], + password: Option<&str>, +) -> Result<(), EditError> { + use pdfcraft_cos::Object; + use pdfcraft_redact::RedactError; + let mut marked: Vec = marks.iter().map(|m| m.page).collect(); + marked.sort_unstable(); + marked.dedup(); + if marked.is_empty() { + return Ok(()); + } + let mut shown = after.clone(); + let model = pdfcraft_model::pages(&shown); + for &pi in &marked { + let Some(page) = model.get(pi) else { continue }; + let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; + let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); + shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; + } + let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; + let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); + let mut residue = 0; + for &pi in &marked { + let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; + // A page that cannot be read back cannot be confirmed clean. + let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; + let rects: Vec<[f64; 4]> = marks + .iter() + .filter(|m| m.page == pi) + .flat_map(|m| m.rects.iter()) + .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) + .collect(); + for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { + let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); + let area = (x1 - x0) * (y1 - y0); + if area.is_nan() || area <= 0.0 { + continue; + } + // Redaction itself takes any glyph a fifth under a mark; one that is half under it + // and still drawn was not where redaction thought it was. + let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; + if rects.iter().any(under) { + residue += 1; + } + } + } + if residue > 0 { + return Err(RedactError::Residue(residue).into()); + } + Ok(()) +} diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs new file mode 100644 index 0000000000..8f43689aa9 --- /dev/null +++ b/crates/engine/src/edit.rs @@ -0,0 +1,565 @@ +//! The edits a document takes: what each is called, whether its permissions allow it, and running it. + +use std::sync::Arc; + +use crate::{ + AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, + LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, + Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, + image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, +}; + +mod labels; +mod run; + +pub use labels::{BATCH_LABELS, EDIT_LABELS}; +pub(crate) use run::{EditCtx, check_permission, run_edit}; + +/// Edits that can be applied to a document. Page indices are 0-based. +#[derive(Clone, Debug, PartialEq)] +pub enum Edit { + RotatePages { + pages: Vec, + degrees: i64, + }, + DeletePages { + pages: Vec, + }, + MovePages { + pages: Vec, + to: usize, + }, + /// `pages` reversed among the positions they occupy. + ReversePages { + pages: Vec, + }, + InsertBlankPage { + at: usize, + width: f64, + height: f64, + }, + /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after + /// the last of them when `at` is `None`. + DuplicatePages { + pages: Vec, + at: Option, + }, + /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the + /// pages' links, comments and bookmarks stay. + ReplacePages { + pages: Vec, + name: String, + bytes: Arc>, + src_pages: Vec, + }, + /// Set Page Boxes / Crop: set (or reset) one box on `pages`. + SetPageBox { + pages: Vec, + which: PageBox, + spec: BoxSpec, + }, + /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), + /// Organize ▸ Page Transitions. + SetTransition { + pages: Vec, + spec: TransitionSpec, + }, + SetInfo { + key: String, + value: String, + }, + /// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`. + InsertPagesFrom { + name: String, + bytes: Arc>, + pages: Option>, + at: usize, + /// How a form field whose name the document already uses is kept separate. + field_conflicts: pdfcraft_organize::FieldConflicts, + }, + /// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level). + /// It goes to the user-space point (`left`, `top`) of the page at the reader's zoom, and + /// either may be `None` to keep the reader's position that way (both `None` is the page top). + /// A coordinate that is not finite fails the edit. + AddBookmark { + parent: Vec, + index: usize, + title: String, + page: usize, + left: Option, + top: Option, + }, + RenameBookmark { + path: Vec, + title: String, + }, + /// Delete a bookmark and the bookmarks under it. + DeleteBookmark { + path: Vec, + }, + /// Move a bookmark to child `index` of `to_parent` (indices after removing it). + MoveBookmark { + from: Vec, + to_parent: Vec, + index: usize, + }, + /// Point a bookmark at another page. + SetBookmarkPage { + path: Vec, + page: usize, + }, + /// Colour a bookmark's title (`/C`); `None` or black is the default black. + SetBookmarkColor { + path: Vec, + color: Option<[f64; 3]>, + }, + /// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one. + SetBookmarkStyle { + path: Vec, + bold: Option, + italic: Option, + }, + /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first + /// "Untitled" bookmark (New Bookmarks from Structure). + BookmarksFromStructure, + /// Bookmarks for the lines set in `levels`' styles (the first is level 1, the next nests + /// under it, …), under a new first "Untitled" bookmark (#1693; see [`crate::text_style_bookmarks`]). + BookmarksFromTextStyles { + levels: Vec, + }, + /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. + NumberPages { + from: usize, + to: usize, + style: pdfcraft_organize::LabelStyle, + prefix: String, + first: u32, + }, + /// Add a calibrated distance, perimeter or area annotation. + AddMeasurement(measure::NewMeasurement), + /// Store a drawing scale for a rectangular viewport (PDF user space). + SetMeasurementScale { + page: usize, + bbox: [f64; 4], + name: String, + scale: measure::Scale, + }, + /// Add a comment (sticky note, highlight, shape, drawing, text box…). + AddAnnotation(NewAnnotation), + /// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size + /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). + /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, + /// and centred in it (#4077). + AddCustomStamp { + page: usize, + rect: [f64; 4], + name: String, + file: MarkFile, + author: String, + }, + /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. + DeleteAnnotation { + page: usize, + index: usize, + }, + SetAnnotationContents { + page: usize, + index: usize, + text: String, + }, + ReplyToAnnotation { + page: usize, + index: usize, + text: String, + author: String, + }, + /// Acrobat's "Set status" (a state reply by `author`). + SetAnnotationStatus { + page: usize, + index: usize, + state: ReviewState, + author: String, + }, + /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). + EraseInk { + page: usize, + index: usize, + path: Vec<[f64; 2]>, + radius: f64, + }, + /// Replace Text: strike out `quads` and add a grouped caret holding `text`. + ReplaceText { + page: usize, + quads: Vec<[f64; 8]>, + text: String, + author: String, + strike: Style, + caret: Style, + }, + /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). + MarkAnnotation { + page: usize, + index: usize, + marked: bool, + author: String, + }, + /// Properties ▸ Locked. + LockAnnotation { + page: usize, + index: usize, + locked: bool, + }, + MoveAnnotation { + page: usize, + index: usize, + dx: f64, + dy: f64, + }, + /// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's + /// two ends, the others' corners). + ReshapeAnnotation { + page: usize, + index: usize, + points: Vec<[f64; 2]>, + }, + /// Resize a rectangle, oval or text box. + ResizeAnnotation { + page: usize, + index: usize, + rect: [f64; 4], + }, + StyleAnnotation { + page: usize, + index: usize, + color: Option, + opacity: Option, + width: Option, + /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. + endings: Option>, + }, + /// Fill a rectangle, oval or polygon comment, or remove its fill (`None`). + FillAnnotation { + page: usize, + index: usize, + fill: Option, + }, + /// Change the font size (pt) of a text box, typewriter text or callout. + FontSizeAnnotation { + page: usize, + index: usize, + size: f64, + }, + /// Comment properties ▸ General / note icon. + SetAnnotationInfo { + page: usize, + index: usize, + author: Option, + subject: Option, + icon: Option, + }, + /// Fill in a form field. + SetFieldValue { + name: String, + value: FieldValue, + }, + /// Clear form: the named fields (all when `None`) back to their defaults. + ResetForm { + names: Option>, + }, + /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to + /// Acrobat's next free name ("Text1", "Check Box2", …). + AddField { + page: usize, + rect: [f64; 4], + kind: NewField, + name: Option, + }, + /// Field Properties ▸ General / Options. + SetFieldProps { + name: String, + props: Box, + }, + /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). + SetFieldImage { + name: String, + image: Arc>, + }, + /// Duplicate a field onto other pages (same field, shared value). + DuplicateField { + name: String, + pages: Vec, + }, + DeleteField { + name: String, + }, + /// Delete one widget of a field (`widget`, 0-based in the field's widgets): one radio button + /// of a group, or one copy of a duplicated field. The last widget takes the field with it. + DeleteWidget { + name: String, + widget: usize, + }, + /// Page properties ▸ Tab order, for pages (0-based). + SetTabOrder { + pages: Vec, + order: TabOrder, + }, + /// Document Properties ▸ Initial View (and the language and binding). + SetInitialView(Box), + /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under + /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. + AddAttachment { + name: String, + bytes: Vec, + description: Option, + mime: Option, + }, + /// Remove the document-level attachment whose name-tree key is `key`. + DeleteAttachment { + key: String, + }, + /// Build a tagged structure tree for an untagged, text-only document. + AutoTagText, + /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is + /// the figure element's object number (from `Document::figures`). + SetAltText { + figure: u32, + alt: Option, + }, + /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. + AutoTagFormFields, + /// Change one property on a reachable indirect structure tag. + SetTagProperty { + object: pdfcraft_cos::ObjRef, + property: a11y::TagProperty, + value: Option, + }, + /// Move a structure tag before or after a direct sibling in reading order. + MoveTag { + object: pdfcraft_cos::ObjRef, + anchor: pdfcraft_cos::ObjRef, + placement: a11y::TagPlacement, + }, + /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. + MarkDecorative { + figure: u32, + }, + /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. + MarkTagArtifact { + object: pdfcraft_cos::ObjRef, + }, + /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level + /// script `name`. + SetDocumentScript { + name: String, + script: Option, + }, + /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` + /// (keystroke, format, validate, calculate or mouse_up). + SetFieldScript { + name: String, + event: String, + script: Option, + }, + /// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent, + /// forbidden actions, annotation flags, …). + ConvertPdfA { + level: pdfcraft_preflight::Level, + }, + /// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X). + RemoveStandardId { + standard: Standard, + }, + /// What a script (button or console) changed in form fields: values, read-only, required + /// and visibility. + ApplyScriptChanges { + changes: Vec, + }, + /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the + /// script changes (values, rows, visibility) is applied and the form laid out again. + XfaEvent { + som: String, + activity: String, + }, + /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the + /// image (from [`ocr::OcrJob::run`](crate::ocr::OcrJob::run)). + AddOcrText { + page: usize, + words: Vec, + }, + /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on + /// `page`, in its own font when it can show it, else in Helvetica. + EditTextLine { + page: usize, + line: usize, + text: String, + }, + /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). + EditPageImage { + page: usize, + index: usize, + change: ImageEdit, + }, + /// Move mixed existing and added objects together. References resolve before any change; + /// offset is a user-space vector. No retyping, reflow or font substitution is involved. + MoveObjects { + page: usize, + objects: Vec, + offset: [f64; 2], + }, + /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), + /// rewrapped to the box's width. + EditTextBlock { + page: usize, + block: usize, + text: String, + /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. + style: pdfcraft_edit::BlockStyle, + }, + /// An explicit open-licensed full font program, completing subsets or selected by a user. + RegisterTextFont { + page: usize, + bytes: Arc>, + }, + /// Order tabs manually: move a field one place earlier or later on its page. + MoveInTabOrder { + name: String, + earlier: bool, + }, + /// Set the calculation order: these calculated fields first, in this order, then the others + /// in their current order (see [`Document::calculation_order`](crate::Document::calculation_order)). The form recalculates. + SetCalculationOrder { + names: Vec, + }, + /// Add a header and footer (with `replace`, existing ones on those pages go first). + AddHeaderFooter { + pages: Vec, + settings: HeaderFooter, + replace: bool, + }, + /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. + AddWatermark { + pages: Vec, + settings: Watermark, + replace: bool, + file: Option, + }, + /// `file`: a picture from a file instead of a colour. + AddBackground { + pages: Vec, + settings: Background, + replace: bool, + file: Option, + }, + /// Remove every mark of a kind from every page. + RemoveMarks { + kind: MarkKind, + }, + /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). + AddText { + page: usize, + text: AddedText, + }, + /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at + /// its natural size centred on the page (shrunk to fit). + AddImage { + page: usize, + rect: Option<[f64; 4]>, + name: String, + bytes: Arc>, + }, + /// Move, resize, retype or reformat an added item (`index` among the page's added items). + UpdateContent { + page: usize, + index: usize, + content: AddedContent, + }, + DeleteContent { + page: usize, + index: usize, + }, + /// Replace an added image with another file (keeping its box and transforms). + ReplaceImage { + page: usize, + index: usize, + name: String, + bytes: Arc>, + }, + /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its + /// style, an image in its box (display space) with its turns, flips and crop. An image's + /// picture is `file` (from [`Document::added_image_file`](crate::Document::added_image_file)): the copied image object is reused + /// while it is still that picture in this document, else one an added image here already draws + /// it from, else the file is embedded. + PasteContent { + page: usize, + content: AddedContent, + file: Option<(String, Arc>)>, + }, + /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. + ApplyRedactions { + pages: Option>, + }, + /// Remove redaction marks without applying them. + ClearRedactions, + /// Edit a PDF ▸ Link: a new link over `rect` (user space). + AddLink { + page: usize, + rect: [f64; 4], + action: LinkAction, + style: LinkStyle, + }, + /// Link Properties (`index` in the page's `/Annots`). + SetLink { + page: usize, + index: usize, + rect: Option<[f64; 4]>, + action: Option, + style: Option, + }, + DeleteLink { + page: usize, + index: usize, + }, + /// Remove all links (on `pages`, or everywhere). + RemoveLinks { + pages: Option>, + }, + /// Create links from URLs in the text: (page, line rects in user space, URI). + AddLinks { + links: Vec<(usize, Vec<[f64; 4]>, String)>, + style: LinkStyle, + }, + /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text), or the + /// comments of another PDF onto the pages with the same numbers. + ImportData { + name: String, + bytes: Arc>, + }, + /// Remove Hidden Information: the chosen categories. + RemoveHidden { + which: Vec, + }, + /// Sanitize Document: every category, then a full rewrite on save. + Sanitize, + /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and + /// PdfCraft's private keys go, the pages as laid out and the fields with their values + /// stay, so every viewer shows the same form and none lays it out again. Its scripts no + /// longer run. + XfaToStatic, + /// Flatten comments and/or form fields on every page into page content. + Flatten { + comments: bool, + fields: bool, + }, + /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. + /// A no-op (no undo step) when the document has none. + FlattenFillSign, + /// Protect with passwords and permissions (written by the next save, which is a full rewrite). + Protect(Protection), + /// Remove password security (needs the owner password). + RemoveProtection, + /// Several edits applied as one undoable step (all or nothing). + Batch { + label: String, + edits: Vec, + }, +} diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs new file mode 100644 index 0000000000..c3339453af --- /dev/null +++ b/crates/engine/src/edit/labels.rs @@ -0,0 +1,338 @@ +use super::{Edit, FillMark, ImageEdit, Label, LineEnding, MarkKind, Markup, PageBox, ReviewState, Shape, field_table}; + +/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not +/// here: its wording is only the hole, because a batch carries its name as text (see +/// [`BATCH_LABELS`]). +pub const EDIT_LABELS: &[&str] = &[ + "Rotate page", + "Change tag properties", + "Rotate pages", + "Delete page", + "Delete pages", + "Move page", + "Move pages", + "Reverse pages", + "Insert blank page", + "Duplicate page", + "Duplicate pages", + "Replace page", + "Replace pages", + "Crop page", + "Crop pages", + "Set page boxes", + "Set page transitions", + "Change Title", + "Change Author", + "Change Subject", + "Change Keywords", + "Change {key}", + "Insert pages from {name}", + "Insert pages from {name} — 1 field renamed", + "Insert pages from {name} — fields renamed", + "Attach {name}", + "Delete attachment {key}", + "Add bookmark", + "Rename bookmark", + "Delete bookmark", + "Move bookmark", + "Set bookmark destination", + "Colour", + "Style", + "New bookmarks from structure", + "New bookmarks from text styles", + "Number pages", + "Measure distance", + "Measure perimeter", + "Measure area", + "Set measurement scale", + "Add sticky note", + "Add highlight", + "Add underline", + "Add strikethrough", + "Add squiggly underline", + "Add rectangle", + "Add oval", + "Add arrow", + "Add line", + "Add drawing", + "Add text box", + "Add checkmark", + "Add cross", + "Add dot", + "Add signature", + "Add redaction mark", + "Add cloud", + "Add polygon", + "Add connected lines", + "Add callout", + "Add inserted text", + "Add file attachment", + "Add stamp", + "Delete comment", + "Edit comment", + "Reply", + "Set status None", + "Set status Accepted", + "Set status Rejected", + "Set status Cancelled", + "Set status Completed", + "Replace text", + "Erase", + "Mark with checkmark", + "Remove checkmark", + "Lock comment", + "Unlock comment", + "Move comment", + "Reshape comment", + "Resize comment", + "Change comment properties", + "Fill in {name}", + "Set the image of {name}", + "Clear form", + "Add field", + "Change field properties", + "Delete field", + "Duplicate field", + "Set tab order", + "Set calculation order", + "Change initial view", + "Set alternate text", + "Mark figure as decorative", + "Recognize text", + "Run JavaScript", + "Run form script", + "Save as {level}", + "Remove {standard} identification", + "Edit script of {name}", + "Delete document JavaScript", + "Edit document JavaScript", + "Edit text", + "Load document font", + "Move object", + "Move objects", + "Move image", + "Rotate image", + "Flip image", + "Replace image", + "Delete image", + "Add header & footer", + "Update header & footer", + "Add watermark", + "Update watermark", + "Add background", + "Update background", + "Remove header & footer", + "Remove watermark", + "Remove background", + "Add text", + "Add image", + "Edit content", + "Delete content", + "Paste", + "Apply redactions", + "Remove redaction marks", + "Remove hidden information", + "Import {name}", + "Add link", + "Change link properties", + "Delete link", + "Remove all links", + "Create link", + "Create links", + "Sanitize document", + "Flatten comments", + "Flatten form fields", + "Flatten", + "Flatten Fill & Sign", + "Protect with password", + "Remove security", +]; + +/// The names of the batches built below L7, which an interface shows in the same places as +/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives +/// already worded in the interface's language. +pub const BATCH_LABELS: &[&str] = + &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; + +impl Edit { + /// Label for the Edit menu and history ("Undo Rotate pages"). + pub fn label(&self) -> Label { + match self { + Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), + Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), + Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), + Edit::ReversePages { .. } => Label::of("Reverse pages"), + Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), + Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), + Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), + Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), + Edit::SetPageBox { .. } => Label::of("Set page boxes"), + Edit::SetTransition { .. } => Label::of("Set page transitions"), + // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can + // be said in another language; any other key is the document's and goes in as it stands. + Edit::SetInfo { key, .. } => match key.as_str() { + "Title" => Label::of("Change Title"), + "Author" => Label::of("Change Author"), + "Subject" => Label::of("Change Subject"), + "Keywords" => Label::of("Change Keywords"), + _ => Label::with("Change {key}", key.as_str()), + }, + Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), + Edit::AddBookmark { .. } => Label::of("Add bookmark"), + Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), + Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), + Edit::MoveBookmark { .. } => Label::of("Move bookmark"), + Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), + // The bookmark Style and Colour menus' own names, already said in every language. + Edit::SetBookmarkColor { .. } => Label::of("Colour"), + Edit::SetBookmarkStyle { .. } => Label::of("Style"), + Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"), + Edit::NumberPages { .. } => Label::of("Number pages"), + // What was measured is part of the wording too, for the same reason. + Edit::AddMeasurement(m) => Label::of(match m.kind { + pdfcraft_measure::Kind::Distance => "Measure distance", + pdfcraft_measure::Kind::Perimeter => "Measure perimeter", + pdfcraft_measure::Kind::Area => "Measure area", + }), + Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), + Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), + Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), + Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), + Edit::ReplyToAnnotation { .. } => Label::of("Reply"), + // The state's name is part of the wording: a language may need to agree it with the rest. + Edit::SetAnnotationStatus { state, .. } => Label::of(match state { + ReviewState::None => "Set status None", + ReviewState::Accepted => "Set status Accepted", + ReviewState::Rejected => "Set status Rejected", + ReviewState::Cancelled => "Set status Cancelled", + ReviewState::Completed => "Set status Completed", + }), + Edit::ReplaceText { .. } => Label::of("Replace text"), + Edit::EraseInk { .. } => Label::of("Erase"), + Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), + Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), + Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), + Edit::LockAnnotation { .. } => Label::of("Unlock comment"), + Edit::MoveAnnotation { .. } => Label::of("Move comment"), + Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"), + Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), + Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { + Label::of("Change comment properties") + } + Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), + Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), + Edit::ResetForm { .. } => Label::of("Clear form"), + Edit::AddField { .. } => Label::of("Add field"), + Edit::SetFieldProps { .. } => Label::of("Change field properties"), + Edit::DeleteField { .. } | Edit::DeleteWidget { .. } => Label::of("Delete field"), + Edit::DuplicateField { .. } => Label::of("Duplicate field"), + Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), + Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), + Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), + Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::AutoTagText => Label::of("Automatically tag text"), + Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::AutoTagFormFields => Label::of("Tag form fields"), + Edit::SetTagProperty { .. } => Label::of("Change tag properties"), + Edit::MoveTag { .. } => Label::of("Move object"), + Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), + Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), + Edit::AddOcrText { .. } => Label::of("Recognize text"), + Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), + Edit::XfaEvent { .. } => Label::of("Run form script"), + // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. + Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), + Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()), + Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), + Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), + Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), + Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::RegisterTextFont { .. } => Label::of("Load document font"), + Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), + Edit::EditPageImage { change, .. } => Label::of(match change { + ImageEdit::Move(_) => "Move image", + ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", + ImageEdit::Crop(_) => "Crop image", + ImageEdit::Flip { .. } => "Flip image", + ImageEdit::Replace { .. } => "Replace image", + ImageEdit::Delete => "Delete image", + }), + Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), + Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), + Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), + Edit::AddWatermark { .. } => Label::of("Update watermark"), + Edit::AddBackground { replace: false, .. } => Label::of("Add background"), + Edit::AddBackground { .. } => Label::of("Update background"), + Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), + Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), + Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::AddText { .. } => Label::of("Add text"), + Edit::AddImage { .. } => Label::of("Add image"), + Edit::UpdateContent { .. } => Label::of("Edit content"), + Edit::DeleteContent { .. } => Label::of("Delete content"), + Edit::ReplaceImage { .. } => Label::of("Replace image"), + Edit::PasteContent { .. } => Label::of("Paste"), + Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), + Edit::ClearRedactions => Label::of("Remove redaction marks"), + Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), + Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), + Edit::AddLink { .. } => Label::of("Add link"), + Edit::SetLink { .. } => Label::of("Change link properties"), + Edit::DeleteLink { .. } => Label::of("Delete link"), + Edit::RemoveLinks { .. } => Label::of("Remove all links"), + Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), + Edit::Sanitize => Label::of("Sanitize document"), + Edit::XfaToStatic => Label::of("Convert to a static form"), + Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), + Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), + Edit::Flatten { .. } => Label::of("Flatten"), + Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), + Edit::Protect(_) => Label::of("Protect with password"), + Edit::RemoveProtection => Label::of("Remove security"), + // A batch carries its own name, already worded by whoever built it. + // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). + Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), + } + } +} + +/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, +/// so a language can give the noun the article and the gender its own grammar asks for. +fn add_annotation(s: &Shape) -> &'static str { + match s { + Shape::Note { .. } => "Add sticky note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", + Shape::Rectangle { .. } => "Add rectangle", + Shape::Oval { .. } => "Add oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", + Shape::Line { .. } => "Add line", + Shape::Ink { .. } => "Add drawing", + Shape::TextBox { .. } => "Add text box", + Shape::Typewriter { .. } => "Add text", + Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", + Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", + Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", + Shape::Mark { mark: FillMark::Line, .. } => "Add line", + Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", + Shape::Redact { .. } => "Add redaction mark", + Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", + Shape::Polygon { cloud: true, .. } => "Add cloud", + Shape::Polygon { .. } => "Add polygon", + Shape::PolyLine { .. } => "Add connected lines", + Shape::Callout { .. } => "Add callout", + Shape::Caret { .. } => "Add inserted text", + Shape::Attachment { .. } => "Add file attachment", + } +} + +/// The singular or the plural wording, by count. Both are written out: a language forms its plural +/// its own way, and `one` and `many` may differ in more than an "s". +fn plural(one: &'static str, many: &'static str, n: usize) -> Label { + Label::of(if n == 1 { one } else { many }) +} diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs new file mode 100644 index 0000000000..c6b1a7438b --- /dev/null +++ b/crates/engine/src/edit/run.rs @@ -0,0 +1,770 @@ +use super::{ + AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, + image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, +}; +use crate::SourceProblem; +use crate::sources::{comment_source, import_pdf_comments}; + +/// Whether the opening password allows an edit (§7.6.4.2, Table 22). +pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { + match edit { + Edit::RotatePages { .. } + | Edit::DeletePages { .. } + | Edit::MovePages { .. } + | Edit::ReversePages { .. } + | Edit::InsertBlankPage { .. } + | Edit::DuplicatePages { .. } + | Edit::ReplacePages { .. } + | Edit::SetPageBox { .. } + | Edit::SetTransition { .. } + | Edit::InsertPagesFrom { .. } + // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). + | Edit::AddBookmark { .. } + | Edit::RenameBookmark { .. } + | Edit::DeleteBookmark { .. } + | Edit::MoveBookmark { .. } + | Edit::SetBookmarkPage { .. } + | Edit::SetBookmarkColor { .. } + | Edit::SetBookmarkStyle { .. } + | Edit::BookmarksFromStructure + | Edit::NumberPages { .. } => { + if p.assemble() { + Ok(()) + } else { + Err(EditError::NotPermitted("page changes")) + } + } + // Creates bookmarks (assembly), and their titles are the document's text, copied out of it + // (Table 22, bit 5), as an export of it would be. + Edit::BookmarksFromTextStyles { .. } => { + if !p.assemble() { + Err(EditError::NotPermitted("page changes")) + } else if !p.copy() { + Err(EditError::NotPermitted("copying text and images")) + } else { + Ok(()) + } + } + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::LockAnnotation { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } + | Edit::SetMeasurementScale { .. } => { + if p.annotate() { + Ok(()) + } else { + Err(EditError::NotPermitted("comments")) + } + } + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { + if p.fill_forms() { + Ok(()) + } else { + Err(EditError::NotPermitted("filling in form fields")) + } + } + Edit::Protect(_) | Edit::RemoveProtection => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("changing security")) + } + } + // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted + // document it is a change of security (#3921). + Edit::ConvertPdfA { .. } => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) + } + } + Edit::SetInfo { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } + | Edit::AddField { .. } + | Edit::ApplyRedactions { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::MoveObjects { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::ClearRedactions + | Edit::RemoveHidden { .. } + | Edit::ImportData { .. } + | Edit::AddLink { .. } + | Edit::SetLink { .. } + | Edit::DeleteLink { .. } + | Edit::RemoveLinks { .. } + | Edit::AddLinks { .. } + | Edit::Sanitize + | Edit::XfaToStatic + | Edit::SetFieldProps { .. } + | Edit::DeleteField { .. } + | Edit::DeleteWidget { .. } + | Edit::DuplicateField { .. } + | Edit::SetTabOrder { .. } + | Edit::MoveInTabOrder { .. } + | Edit::SetCalculationOrder { .. } + | Edit::SetInitialView(_) + | Edit::AddAttachment { .. } + | Edit::DeleteAttachment { .. } + | Edit::AutoTagText + | Edit::SetAltText { .. } + | Edit::AutoTagFormFields + | Edit::SetTagProperty { .. } + | Edit::MoveTag { .. } + | Edit::MarkDecorative { .. } + | Edit::MarkTagArtifact { .. } + | Edit::AddOcrText { .. } + | Edit::SetDocumentScript { .. } + | Edit::SetFieldScript { .. } + | Edit::RemoveStandardId { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::RegisterTextFont { .. } + | Edit::EditPageImage { .. } + | Edit::Flatten { .. } + | Edit::FlattenFillSign => { + if p.modify() { + Ok(()) + } else { + Err(EditError::NotPermitted("changes to the document")) + } + } + Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), + } +} + +/// Dates and unique ids stamped onto what an edit creates. +pub(crate) struct EditCtx { + /// Field scripts run (`None`: JavaScript is off) and what they produced. + pub(crate) js: Option, + /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what + /// its scripts produced. + pub(crate) xfa: Option>, + pub(crate) xfa_out: js::JsOutput, + /// The datasets stream this edit wrote (later writes in the edit replace it in place). + pub(crate) xfa_datasets: Option, + /// A form script ran too long and was abandoned: the document's scripts go off. + pub(crate) xfa_ran_away: bool, + /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). + pub(crate) xfa_time_scale: u32, + /// The edit took the XFA out of the form: what went with it, for the document's warnings. + pub(crate) xfa_removed: Vec, + /// Pages in the document, for `xfa.layout.pageCount()`. + pub(crate) pages: usize, + pub(crate) date: Option, + /// Today in local time, for date tokens. + pub(crate) today: (i64, u32, u32), + pub(crate) seed: u64, + pub(crate) count: u64, + /// Platform CSPRNG (`getrandom::fill`). Tests replace it so Protect can fail closed. + pub(crate) rng: fn(&mut [u8]) -> Result<(), getrandom::Error>, + /// Fields the edit renamed to keep imported names separate (old → new), for the repair + /// log and the undo label. + pub(crate) renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + pub(crate) redaction_overlays: Vec, +} + +impl EditCtx { + pub(crate) fn new(now: Option, salt: u64) -> Self { + let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; + if let Some(t) = now { + seed ^= (t as u64).rotate_left(17); + } + #[cfg(not(target_arch = "wasm32"))] + if now.is_some() { + // Real clock: mix in sub-second time so ids from two sessions don't collide. + seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; + } + Self { + js: None, + xfa: None, + xfa_out: Default::default(), + xfa_datasets: None, + xfa_ran_away: false, + xfa_time_scale: 1, + xfa_removed: Vec::new(), + pages: 0, + date: now.map(pdfcraft_cos::pdf_date), + today: (1970, 1, 1), + seed, + count: 0, + rng: getrandom::fill, + renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), + } + } + + /// 32 bytes of entropy for new encryption keys, salts and generated owner passwords from the + /// platform CSPRNG. Fails closed when secure randomness is unavailable. + fn entropy(&mut self) -> Result<[u8; 32], EditError> { + let mut out = [0u8; 32]; + (self.rng)(&mut out).map_err(|e| EditError::Protection(format!("no secure randomness available: {e}")))?; + if out == [0u8; 32] { + return Err(EditError::Protection("no secure randomness available".into())); + } + Ok(out) + } + + /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. + fn meta(&mut self) -> pdfcraft_annot::Meta { + let mut next = || { + self.count += 1; + let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let (a, b) = (next(), next()); + let id = format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + a >> 32, + (a >> 16) & 0xffff, + a & 0xfff, + 0x8000 | (b >> 48) & 0x3fff, + b & 0xffff_ffff_ffff + ); + pdfcraft_annot::Meta { date: self.date.clone(), id } + } +} + +/// Perform an edit on a working copy (the caller discards it on error). +pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { + match edit { + Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, + Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, + Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, + Edit::InsertBlankPage { at, width, height } => { + pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; + } + Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, + Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, + Edit::ReplacePages { pages, name, bytes, src_pages } => { + let src = open_source(name, bytes)?; + pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; + } + Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, + Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, + Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { + let src = open_source(name, bytes)?; + let pages = match pages { + Some(p) => p.clone(), + None => (0..pdfcraft_organize::page_count(&src)?).collect(), + }; + // The file's bookmarks come along under one named after it, as in Combine Files. + let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); + let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; + cx.renamed_fields = outcome.renamed_fields; + } + Edit::AddBookmark { parent, index, title, page, left, top } => { + pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?; + } + Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, + Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, + Edit::MoveBookmark { from, to_parent, index } => { + pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; + } + Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, + Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, + Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, + Edit::BookmarksFromStructure => { + let entries: Vec<_> = pdfcraft_a11y::headings(doc) + .into_iter() + .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj, top: None }) + .collect(); + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } + Edit::BookmarksFromTextStyles { levels } => { + if levels.is_empty() { + return Err(EditError::Invalid("choose a text style for at least one bookmark level".into())); + } + // Each line goes to the first level whose style it is in: a style given again, or one + // within the size tolerance of an earlier level's, would leave its level empty. + for (i, a) in levels.iter().enumerate() { + if let Some(j) = levels.iter().skip(i.saturating_add(1)).position(|b| a.matches(b)) { + let (first, second) = (i.saturating_add(1), i.saturating_add(j).saturating_add(2)); + return Err(EditError::Invalid(format!( + "bookmark levels {first} and {second} have the same text style: choose a different one for each level" + ))); + } + } + let entries = crate::text_style_bookmarks::headings(doc, levels); + if entries.is_empty() { + return Err(EditError::Invalid("no text is set in the chosen styles".into())); + } + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } + Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, + Edit::AddMeasurement(m) => { + measure::add(doc, m, &cx.meta())?; + } + Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, + Edit::AddAnnotation(a) => { + pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; + } + Edit::AddCustomStamp { page, rect, name, file, author } => { + let src = mark_source(doc, file)?; + let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let natural = rect[2] == rect[0] && rect[3] == rect[1]; + let rotation = pdfcraft_organize::page_rotation(doc, *page)?; + // The picture as displayed: a quarter turn swaps its sides in user space. + let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; + let rect = if natural { + let k = (200.0 / sw.max(sh)).min(1.0); + let (w, h) = (dw * k, dh * k); + [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] + } else { + fit_picture(*rect, dw, dh) + }; + let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; + let style = pdfcraft_annot::Style::default_for(&shape); + let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; + let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; + pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; + } + Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, + Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, + Edit::ReplyToAnnotation { page, index, text, author } => { + pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; + } + Edit::SetAnnotationStatus { page, index, state, author } => { + pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; + } + Edit::EraseInk { page, index, path, radius } => { + pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; + } + Edit::AddAttachment { name, bytes, description, mime } => { + pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; + } + Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ReplaceText { page, quads, text, author, strike, caret } => { + // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by + // the strikeout's `/NM` (#2666). + let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); + pdfcraft_annot::add_text_replacement( + doc, + pdfcraft_annot::TextReplacement { + page: *page, + quads, + replacement: text, + author, + strike, + caret, + strike_meta: &strike_meta, + caret_meta: &caret_meta, + }, + )?; + } + Edit::MarkAnnotation { page, index, marked, author } => { + pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; + } + Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, + Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, + Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, + Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, + Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { + pdfcraft_annot::set_style( + doc, + *page, + *index, + pdfcraft_annot::StyleChange { color: *color, opacity: *opacity, width: *width, endings: endings.clone() }, + &cx.meta(), + )?; + } + Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, + Edit::SetAnnotationInfo { page, index, author, subject, icon } => { + pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; + } + Edit::SetFieldValue { name, value } => { + let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { + Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), + None => pdfcraft_forms::set_value(doc, name, value), + }; + match set(doc, value) { + Ok(()) => {} + // A date the field's format doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. Only a format refusal is tried + // again: it comes before any of the field's scripts run, so they run once. + Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { + Some(typed) => set(doc, &typed)?, + None => return Err(refused.into()), + }, + Err(refused) => return Err(refused.into()), + } + } + Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { + Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, + None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, + }, + Edit::XfaEvent { som, activity } => { + let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + ran.map_err(EditError::Invalid)?; + } + Edit::SetFieldImage { name, image } => { + let (img, _) = embed_image(doc, name, image)?; + let px = match &*doc.get(img) { + pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), + _ => (1, 1), + }; + // An XFA image field keeps the picture in the data too (as Designer saves it), and a + // laid-out form shows it from there: so only pictures the layout can show, and only + // for image fields (a laid-out one carries its SOM path; a static form's field name + // is its path). + let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); + let image_field = is_xfa + && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); + if image_field { + let Some(content_type) = image_content_type(image) else { + return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those"))); + }; + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); + let path = match cx.xfa.as_deref() { + Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), + None => pdfcraft_xfa::som_to_path(&som), + }; + let r = + pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; + if !r.written && r.warnings.is_empty() { + return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); + } + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + xfa_after_data_change(doc, cx, false)?; + } else { + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + } + } + Edit::ResetForm { names } => { + pdfcraft_forms::reset(doc, names.as_deref())?; + } + Edit::AddField { page, rect, kind, name } => { + pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + } + Edit::SetFieldProps { name, props } => { + pdfcraft_forms::set_props(doc, name, props)?; + } + Edit::DuplicateField { name, pages } => { + if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { + return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); + } + } + Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, + Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?, + Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, + Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, + // As after a script edit: the values the new order gives show at once. + Edit::SetCalculationOrder { names } => { + pdfcraft_forms::set_calculation_order(doc, names)?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, + Edit::AutoTagText => { + a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::SetAltText { figure, alt } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AutoTagFormFields => { + a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; + } + Edit::SetTagProperty { object, property, value } => { + a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MoveTag { object, anchor, placement } => { + a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::EditTextLine { page, line, text } => { + pdfcraft_edit::replace_line(doc, *page, *line, text)?; + } + Edit::SetFieldScript { name, event, script } => { + pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + Edit::ConvertPdfA { level } => { + pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; + } + Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?, + Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, + Edit::AddOcrText { page, words } => { + pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; + } + Edit::EditPageImage { page, index, change } => { + let img = pdfcraft_edit::page_images(doc, *page)? + .into_iter() + .nth(*index) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), + ImageEdit::Rotate(q) => { + pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) + } + ImageEdit::RotateBy(degrees) => { + let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; + pdfcraft_edit::ImageChange::Transform(t) + } + ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( + img.visible_rect.unwrap_or(img.rect), + 0, + *horizontal, + !*horizontal, + )), + ImageEdit::Replace { name, bytes } => { + if img.is_form { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "grouped Form artwork cannot be replaced with a bitmap".into(), + ))); + } + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) + } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), + ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + }; + pdfcraft_edit::change_image(doc, *page, *index, &c)?; + } + Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, + Edit::EditTextBlock { page, block, text, style } => { + pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; + } + Edit::RegisterTextFont { page, bytes } => { + pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; + } + Edit::MarkDecorative { figure } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MarkTagArtifact { object } => { + a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AddHeaderFooter { pages, settings, replace } => { + let date = cx.today; + pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; + } + Edit::AddWatermark { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? + } + Edit::AddBackground { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_background(doc, pages, &s, *replace)? + } + Edit::RemoveMarks { kind } => { + let n = pdfcraft_model::pages(doc).len(); + if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); + } + } + Edit::AddText { page, text } => { + pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; + } + Edit::AddImage { page, rect, name, bytes } => { + let (image, natural) = embed_image(doc, name, bytes)?; + let rect = match rect { + Some(r) => *r, + None => { + let p = pdfcraft_model::pages(doc).swap_remove(*page); + let (pw, ph) = p.display_size(doc); + let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); + let (w, h) = (natural.0 * k, natural.1 * k); + [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] + } + }; + pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; + } + Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, + Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, + Edit::ReplaceImage { page, index, name, bytes } => { + let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); + let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); + }; + let (image, _) = embed_image(doc, name, bytes)?; + pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; + } + Edit::PasteContent { page, content, file } => { + let content = match content { + AddedContent::Text(_) => content.clone(), + AddedContent::Image(img) => { + let Some((name, bytes)) = file else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); + }; + // The same picture in this document (copied here, pasted here before, or an + // identical file): drawn again from the one image object rather than stored twice. + let image = if copy_image::is_picture(doc, img.image, bytes) { + img.image + } else { + match copy_image::added_picture(doc, bytes) { + Some(image) => image, + None => embed_image(doc, name, bytes)?.0, + } + }; + AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) + } + }; + pdfcraft_edit::add_content(doc, *page, &content)?; + } + Edit::ApplyRedactions { pages } => { + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + } + Edit::ClearRedactions => { + pdfcraft_redact::clear_marks(doc, None)?; + } + Edit::AddLink { page, rect, action, style } => { + pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; + } + Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, + Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, + Edit::RemoveLinks { pages } => { + if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); + } + } + Edit::AddLinks { links, style } => { + if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); + } + } + Edit::ImportData { name, bytes } if bytes.starts_with(b"%PDF-") => { + let src = comment_source(bytes).map_err(|p| EditError::Source(format!("{name}: {p}")))?; + import_pdf_comments(doc, &src)?; + } + Edit::ImportData { name, bytes } => { + // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; + // XFDF, FDF, Acrobat's XML and tables go the usual way. + let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); + match text { + Some(Ok(text)) if is_xfa_data_file(&text) => { + let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); + let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) + .map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); + xfa_after_data_change(doc, cx, true)?; + } + Some(Err(why)) if !bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => return Err(EditError::Invalid(why)), + _ => { + // A PDF with bytes before its header, within the 1,024 the reader allows, is read + // as a PDF when it is not a data file. A file the PDF reader cannot open keeps + // the data importer's error. + let mut data = doc.clone(); + match pdfcraft_xfdf::import(&mut data, bytes) { + Ok(_) => *doc = data, + Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) { + Ok(src) => import_pdf_comments(doc, &src)?, + Err(SourceProblem::Unreadable(_)) => return Err(e.into()), + Err(p) => return Err(EditError::Source(format!("{name}: {p}"))), + }, + Err(e) => return Err(e.into()), + } + } + } + } + Edit::RemoveHidden { which } => { + pdfcraft_redact::sanitize::remove_hidden(doc, which)?; + } + Edit::Sanitize => { + pdfcraft_redact::sanitize::sanitize(doc)?; + } + Edit::Flatten { comments, fields } => { + // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers + // would lay the form out again from its template over the baked pages. A dynamic + // form that was never laid out keeps its XFA: without it only placeholders remain. + let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; + match shape { + Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, + Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), + None => {} + } + } + Edit::XfaToStatic => { + let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { + return Err(EditError::Invalid("this document is not an XFA form".into())); + }; + if !shape.strippable() { + return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); + } + strip_xfa(doc, cx)?; + } + Edit::FlattenFillSign => { + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; + } + Edit::Protect(p) => { + p.validate()?; + let seed = cx.entropy()?; + // Without a permissions password nothing is restricted, so the owner password is a + // random one nobody needs. + let random_owner = match p.permissions_password { + Some(_) => String::new(), + None => cx.entropy()?.iter().map(|b| format!("{b:02x}")).collect(), + }; + let params = pdfcraft_cos::NewEncryption { + algorithm: p.algorithm, + user_password: p.open_password.as_deref().unwrap_or(""), + owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), + permissions: p.permission_bits(), + encrypt_metadata: p.encrypt_metadata, + seed, + }; + doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; + } + Edit::RemoveProtection => { + if doc.output_handler().is_none() { + return Err(EditError::Protection("the document isn't protected".into())); + } + doc.remove_encryption(); + } + Edit::Batch { edits, .. } => { + for e in edits { + run_edit(doc, e, cx)?; + } + } + } + Ok(()) +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs new file mode 100644 index 0000000000..5ee915fcdf --- /dev/null +++ b/crates/engine/src/error.rs @@ -0,0 +1,80 @@ +//! The errors editing and combining report. + +use crate::measure; + +/// Why a file can't be a source for Combine Files (or Insert / Replace pages). +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SourceProblem { + #[error("it is password-protected")] + Password, + #[error("the password is wrong")] + WrongPassword, + #[error("its security settings don't allow copying pages")] + NotPermitted, + #[error("{0}")] + Unreadable(String), +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum EditError { + #[error("no such document")] + NoDocument, + #[error("this document can't be edited: {0}")] + ReadOnly(String), + #[error("the document's security settings don't allow {0}; open it with the owner password to make this change")] + NotPermitted(&'static str), + #[error("{0}")] + Organize(#[from] pdfcraft_organize::OrganizeError), + #[error("{0}")] + Bookmark(#[from] pdfcraft_organize::OutlineError), + #[error("{0}")] + Comment(#[from] pdfcraft_annot::AnnotError), + #[error(transparent)] + Measure(#[from] measure::MeasureError), + #[error("{0}")] + Protection(String), + #[error("the password is incorrect")] + WrongPassword, + #[error("{0}")] + Form(#[from] pdfcraft_forms::FormError), + #[error(transparent)] + Redact(#[from] pdfcraft_redact::RedactError), + #[error("{0}")] + Print(String), + #[error("{0}")] + Accessibility(String), + #[error(transparent)] + Data(#[from] pdfcraft_xfdf::DataError), + #[error("{0}")] + Edit(#[from] pdfcraft_edit::EditError), + #[error("{0}")] + Create(#[from] pdfcraft_create::CreateError), + #[error("the edited document could not be written: {0}")] + Write(String), + #[error("the edited document could not be reopened: {0}")] + Reopen(String), + #[error("couldn't use {0}")] + Source(String), + #[error("nothing to undo")] + NothingToUndo, + #[error("nothing to redo")] + NothingToRedo, + #[error("{0}")] + Sign(String), + #[error("{0}")] + Optimize(String), + #[error("cancelled")] + Cancelled, + #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] + SignedRewrite(String), + #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] + Signed, + #[error("{0}")] + Invalid(String), +} + +impl From for EditError { + fn from(e: pdfcraft_sign::SignError) -> Self { + EditError::Sign(e.to_string()) + } +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 0bf4e2f79d..1330faf272 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -4,13 +4,52 @@ //! text, from a //! document's working file (so unsaved edits and hidden layers are respected, as on screen). +use std::collections::BTreeMap; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Condvar, Mutex, PoisonError, mpsc}; + use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use crate::Document; +/// At most this many pages are drawn at once by [`Exporter::images`]. +const MAX_IMAGE_WORKERS: usize = 8; +/// Bytes of rasters [`Exporter::images`] may hold at once: 1 GiB, about 16 Letter pages at 300 dpi. +/// Each worker holds one page (the raster, its RGB copy and the encoded file), so large pages run +/// on fewer workers; a page over the cap still exports, on one worker, as it did before. +const IMAGE_MEMORY: f64 = 1024.0 * 1024.0 * 1024.0; + +/// Page `page` drawn at `dpi` and encoded as `format`, saying it was drawn at `used` dpi. +fn draw_image(renderer: &mut PageRenderer, page: usize, dpi: f64, format: ImageFormat, used: f64) -> Result, String> { + let r = renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); + if let Some(e) = r.error { + return Err(format!("page {}: {e}", page + 1)); + } + encode_image(r.width, r.height, &r.rgba, format, used) +} + +/// Tells the workers of [`Exporter::images`] to stop when dropped. +struct StopWorkers<'a> { + stop: &'a AtomicBool, + handed_over: &'a (Mutex, Condvar), +} + +impl Drop for StopWorkers<'_> { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + // Taken under the lock, so a worker between its check and its wait still sees it. + drop(self.handed_over.0.lock().unwrap_or_else(PoisonError::into_inner)); + self.handed_over.1.notify_all(); + } +} + /// Renders and extracts pages of one document state. pub struct Exporter { renderer: PageRenderer, + /// What the renderer draws, for the extra renderers [`Exporter::images`] starts. + bytes: std::sync::Arc>, + config: pdfcraft_render::RenderConfig, pages: usize, /// Each page's displayed size in points, for [`Exporter::dpi_used`]. sizes: Vec<(f32, f32)>, @@ -65,7 +104,13 @@ impl Exporter { /// resolution they allow, as the interactive Export does; [`Exporter::dpi_used`] says at what. /// A source whose `config.reject_oversize` is set refuses such pages instead. pub fn from_source(src: ExportSource) -> Self { - Self { renderer: PageRenderer::new(src.bytes, src.config), pages: src.pages, sizes: src.sizes } + Self { + renderer: PageRenderer::new(src.bytes.clone(), src.config.clone()), + bytes: src.bytes, + config: src.config, + pages: src.pages, + sizes: src.sizes, + } } /// The resolution page `page` is actually exported at when asked for `dpi`: lower than asked @@ -96,11 +141,146 @@ impl Exporter { /// Page `page` as an image file of `format`, which says the resolution it was drawn at. pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result, String> { self.check(page)?; - let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); - if let Some(e) = r.error { - return Err(format!("page {}: {e}", page + 1)); + let used = self.dpi_used(page, dpi); + draw_image(&mut self.renderer, page, dpi, format, used) + } + + /// Pages `pages` as image files of `format`, handed to `sink` in order with their page + /// number: the same files as [`Exporter::image`] for each page in turn. Encoding a page costs + /// several times what drawing it does, so several pages are drawn and encoded at once on + /// worker threads, each with its own renderer, while `sink` runs on the calling thread. + /// Workers draw at most one page each ahead of `sink`, and fewer run when the pages are + /// large, so memory stays bounded. Stops at the first page that fails or the first error `sink` + /// returns, and returns it; every page before it has been handed to `sink`. + pub fn images( + &mut self, + pages: &[usize], + dpi: f64, + format: ImageFormat, + sink: impl FnMut(usize, Vec) -> Result<(), String>, + ) -> Result<(), String> { + let workers = self.image_workers(pages, dpi); + self.images_on(workers, pages, dpi, format, sink) + } + + /// [`Exporter::images`] with `workers` pages drawn at once (one: in turn, on this thread). + pub(crate) fn images_on( + &mut self, + workers: usize, + pages: &[usize], + dpi: f64, + format: ImageFormat, + mut sink: impl FnMut(usize, Vec) -> Result<(), String>, + ) -> Result<(), String> { + if workers <= 1 { + for &p in pages { + let img = self.image(p, dpi, format)?; + sink(p, img)?; + } + return Ok(()); } - encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi)) + let used: Vec = pages.iter().map(|p| self.dpi_used(*p, dpi)).collect(); + let count = self.pages; + let (bytes, config) = (&self.bytes, &self.config); + let next = AtomicUsize::new(0); + let stop = AtomicBool::new(false); + // How many pages `sink` has taken; a worker waits while its page is `workers` past it. + let handed_over = (Mutex::new(0usize), Condvar::new()); + let (tx, rx) = mpsc::channel::<(usize, Result, String>)>(); + let mut first = Some(&mut self.renderer); + std::thread::scope(|s| { + let handles: Vec<_> = (0..workers) + .map(|_| { + let tx = tx.clone(); + let (first, used, next, stop, handed_over) = (first.take(), &used, &next, &stop, &handed_over); + s.spawn(move || { + let mut own = None; + let renderer = match first { + Some(r) => r, + None => own.insert(PageRenderer::new(bytes.clone(), config.clone())), + }; + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + let (Some(&page), Some(&used)) = (pages.get(i), used.get(i)) else { break }; + let mut taken = handed_over.0.lock().unwrap_or_else(PoisonError::into_inner); + while i >= taken.saturating_add(workers) && !stop.load(Ordering::Relaxed) { + taken = handed_over.1.wait(taken).unwrap_or_else(PoisonError::into_inner); + } + drop(taken); + if stop.load(Ordering::Relaxed) { + break; + } + let img = if page < count { + catch_unwind(AssertUnwindSafe(|| draw_image(renderer, page, dpi, format, used))) + .unwrap_or_else(|_| Err(format!("page {}: the export crashed", page + 1))) + } else { + Err(format!("page {} does not exist", page + 1)) + }; + if tx.send((i, img)).is_err() { + break; + } + } + }) + }) + .collect(); + drop(tx); + // Stops the workers however this ends, a panicking `sink` included: waiting ones + // would otherwise keep the scope from returning. + let stopper = StopWorkers { stop: &stop, handed_over: &handed_over }; + let mut ready = BTreeMap::new(); + let mut result = Ok(()); + let mut taken = 0; + while let Some(&page) = pages.get(taken) { + let Some(img) = ready.remove(&taken) else { + match rx.recv() { + Ok((i, img)) => { + ready.insert(i, img); + } + Err(_) => { + result = Err(format!("page {}: the export stopped unexpectedly", page + 1)); + break; + } + } + continue; + }; + if let Err(e) = img.and_then(|img| sink(page, img)) { + result = Err(e); + break; + } + taken += 1; + *handed_over.0.lock().unwrap_or_else(PoisonError::into_inner) = taken; + handed_over.1.notify_all(); + } + drop(stopper); + // Joined here, so a worker that panicked outside a page is not re-raised by the scope. + for h in handles { + let _ = h.join(); + } + result + }) + } + + /// How many pages [`Exporter::images`] draws at once: one per core but one, up to + /// [`MAX_IMAGE_WORKERS`], as many as [`IMAGE_MEMORY`] holds of the largest page, and one + /// when there is nothing to share or no threads (wasm). + fn image_workers(&self, pages: &[usize], dpi: f64) -> usize { + if cfg!(target_arch = "wasm32") || pages.len() < 2 { + return 1; + } + // One core is left for the interface, which keeps drawing while an export runs. + let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).saturating_sub(1).min(MAX_IMAGE_WORKERS); + // The raster (RGBA), the RGB copy the encoders take, and room for the encoded file. + let largest = pages + .iter() + .filter_map(|p| { + let (w, h) = *self.sizes.get(*p)?; + let k = self.dpi_used(*p, dpi) / 72.0; + Some((f64::from(w) * k).ceil() * (f64::from(h) * k).ceil() * 8.0) + }) + .fold(0.0, f64::max); + // A float-to-integer cast saturates (and NaN is 0). + let by_memory = if largest > 0.0 { (IMAGE_MEMORY / largest) as usize } else { cores }; + cores.min(by_memory).min(pages.len()).max(1) } /// The reading-order text of a page. @@ -138,23 +318,40 @@ pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: Image } } -/// Premultiplied RGBA → PNG (straight alpha). +/// Premultiplied RGBA → PNG (straight alpha). A raster with no translucent pixel (a rendered +/// page always is one: it is drawn on white) is written as RGB. +/// +/// Deflate level 3 with adaptive filtering: on rendered pages it writes files the size of the +/// `png` crate's default (level 6) in a third to a fifth of the time. Measured with RGB at +/// level 3 against RGBA at the default: a 300-dpi scan drawn at 220 dpi took 805 → 167 ms +/// (4.46 → 4.47 MB), a text page at 220 dpi 70 → 40 ms (0.85 → 0.89 MB), and an annual +/// report page at 150 dpi 179 → 64 ms (1.41 → 1.38 MB). pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { - let mut rgba = premultiplied.to_vec(); - for px in rgba.as_chunks_mut::<4>().0 { - let a = u32::from(px[3]); - if a != 0 && a != 255 { - for c in &mut px[..3] { - *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + check_raster(width, height, premultiplied)?; + let pixels = premultiplied.as_chunks::<4>().0; + let opaque = pixels.iter().all(|px| px[3] == 255); + let (color, data) = if opaque { + (png::ColorType::Rgb, pixels.iter().flat_map(|px| [px[0], px[1], px[2]]).collect::>()) + } else { + let mut rgba = premultiplied.to_vec(); + for px in rgba.as_chunks_mut::<4>().0 { + let a = u32::from(px[3]); + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + } } } - } + (png::ColorType::Rgba, rgba) + }; let mut out = Vec::new(); let mut enc = png::Encoder::new(&mut out, width, height); - enc.set_color(png::ColorType::Rgba); + enc.set_color(color); enc.set_depth(png::BitDepth::Eight); + enc.set_deflate_compression(png::DeflateCompression::Level(3)); + enc.set_filter(png::Filter::Adaptive); let mut w = enc.write_header().map_err(|e| e.to_string())?; - w.write_image_data(&rgba).map_err(|e| e.to_string())?; + w.write_image_data(&data).map_err(|e| e.to_string())?; w.finish().map_err(|e| e.to_string())?; Ok(out) } @@ -188,6 +385,22 @@ impl ImageFormat { } } +/// Checks that `premultiplied` is exactly `width × height` RGBA pixels, and at least one, +/// before any encoder sees it: `as_chunks` drops a partial trailing pixel, so the rest would be +/// written as a valid image, and the JPEG and TIFF encoders panic on a buffer that doesn't fit +/// the size. +fn check_raster(width: u32, height: u32, premultiplied: &[u8]) -> Result<(), String> { + if width == 0 || height == 0 { + return Err(format!("a {width}×{height} raster has no pixels")); + } + let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4)); + match expected { + Some(n) if n == premultiplied.len() => Ok(()), + Some(n) => Err(format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len())), + None => Err(format!("a {width}×{height} raster is too large")), + } +} + /// Premultiplied RGBA composited over white → RGB. fn over_white(premultiplied: &[u8]) -> Vec { premultiplied @@ -205,6 +418,7 @@ fn over_white(premultiplied: &[u8]) -> Vec { pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result, String> { use image::ImageEncoder; use image::codecs::jpeg::{JpegEncoder, PixelDensity}; + check_raster(width, height, premultiplied)?; let rgb = over_white(premultiplied); let mut out = Vec::new(); let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); @@ -221,6 +435,7 @@ pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> R use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8}; use tiff::tags::{ResolutionUnit, Tag}; let err = |e: tiff::TiffError| e.to_string(); + check_raster(width, height, premultiplied)?; let rgb = over_white(premultiplied); let mut out = std::io::Cursor::new(Vec::new()); let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw); @@ -260,3 +475,71 @@ mod image_name_tests { assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png"); } } + +#[cfg(test)] +mod raster_tests { + use super::{ImageFormat, encode_image, encode_png}; + + const FORMATS: [ImageFormat; 3] = [ImageFormat::Png, ImageFormat::Jpeg { quality: 90 }, ImageFormat::Tiff]; + + /// The colour type and pixels of a PNG. + fn decode(png: &[u8]) -> (png::ColorType, Vec) { + let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut pixels).unwrap(); + pixels.truncate(frame.buffer_size()); + (frame.color_type, pixels) + } + + /// A rendered page has no translucent pixel: it is written as RGB, each pixel exactly as + /// rendered (the alpha channel would only make the file bigger and slower to write). + #[test] + fn opaque_rasters_are_written_as_rgb_with_their_exact_pixels() { + let rgba = [[255, 255, 255, 255], [0, 0, 0, 255], [200, 30, 90, 255], [1, 2, 3, 255], [17, 250, 128, 255], [90, 90, 90, 255]]; + let (color, pixels) = decode(&encode_png(3, 2, rgba.as_flattened()).unwrap()); + assert_eq!(color, png::ColorType::Rgb); + assert_eq!(pixels, rgba.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::>()); + } + + /// A raster with translucent pixels keeps its alpha, with the colours un-premultiplied. + #[test] + fn translucent_rasters_keep_straight_alpha() { + let rgba = [[64, 0, 32, 128], [0, 0, 0, 0], [10, 20, 30, 255]]; + let (color, pixels) = decode(&encode_png(3, 1, rgba.as_flattened()).unwrap()); + assert_eq!(color, png::ColorType::Rgba); + assert_eq!(pixels, [128, 0, 64, 128, 0, 0, 0, 0, 10, 20, 30, 255]); + } + + /// A buffer that is not exactly `width × height` RGBA pixels is an error in every format, + /// opaque or not. A partial trailing pixel used to be dropped and the rest written as a + /// valid image, and a buffer too short for its size panicked in the JPEG and TIFF encoders. + #[test] + fn rasters_of_the_wrong_length_are_rejected() { + let cases: [(&str, u32, u32, &[u8]); 7] = [ + ("opaque, a partial pixel after", 1, 1, &[10, 20, 30, 255, 0]), + ("opaque, three bytes after", 1, 1, &[10, 20, 30, 255, 0, 0, 0]), + ("translucent, a partial pixel after", 1, 1, &[10, 20, 30, 128, 0]), + ("opaque, a pixel short", 2, 1, &[10, 20, 30, 255]), + ("translucent, a pixel too many", 1, 1, &[10, 20, 30, 128, 1, 2, 3, 4]), + ("empty", 1, 1, &[]), + ("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]), + ]; + for format in FORMATS { + for (what, width, height, data) in cases { + assert!(encode_image(width, height, data, format, 72.0).is_err(), "{}, {what}", format.label()); + } + } + } + + /// A raster of no pixels is the right length (no bytes), and refused before any encoder sees + /// it, in every format. + #[test] + fn rasters_of_no_pixels_are_an_error() { + for format in FORMATS { + for (width, height) in [(0, 0), (0, 3), (3, 0)] { + let refused = encode_image(width, height, &[], format, 72.0).err(); + assert_eq!(refused, Some(format!("a {width}×{height} raster has no pixels")), "{}", format.label()); + } + } + } +} diff --git a/crates/engine/src/field_table.rs b/crates/engine/src/field_table.rs new file mode 100644 index 0000000000..f3fd96551e --- /dev/null +++ b/crates/engine/src/field_table.rs @@ -0,0 +1,131 @@ +//! Prepare a form ▸ Text field table: one undoable edit adding a [`FieldTable`]'s cells as text +//! fields. The table is laid out and named in `pdfcraft_forms::table`; this maps its cells from +//! view space onto the page and refuses, before anything is added, what `add_field` would refuse. + +pub use pdfcraft_forms::table::*; + +use pdfcraft_forms::MIN_FIELD_SIDE; +use pdfcraft_render::PageInfo; + +use crate::{Edit, FormField, NewField}; + +/// The edit's name in the history ("Undo Add text field table"), one of [`crate::BATCH_LABELS`]. +pub const LABEL: &str = "Add text field table"; + +/// The edit adding `table`'s cells to `page` (0-based, shown as `info`) of a document whose +/// fields are `form`. Refused, with nothing added, when a cell would be too small on the page or +/// its name can't be used. +pub fn edit(table: &FieldTable, page: usize, info: &PageInfo, form: &[FormField]) -> Result { + let cells = table.cells_for(form.iter().map(|f| f.name.as_str()))?; + // A page turned a quarter runs down the screen along user space's x. + let sideways = matches!(info.rotation, 90 | 270); + let mut edits = Vec::with_capacity(cells.len()); + for cell in cells { + let [x0, y0, x1, y1] = cell.rect; + let rect = info.view_rect_to_user([x0 as f32, y0 as f32, x1 as f32, y1 as f32]).map(f64::from); + // A corner past `f32`'s range comes back infinite. + if !rect.iter().all(|v| v.is_finite()) { + return Err(TableError::Numbers); + } + // `add_field`'s own test, after the page's scale (`/UserUnit`) and rounding to `f32`. + let (across, down) = if sideways { (rect[3] - rect[1], rect[2] - rect[0]) } else { (rect[2] - rect[0], rect[3] - rect[1]) }; + if down < MIN_FIELD_SIDE { + return Err(TableError::RowsTooShort); + } + if across < MIN_FIELD_SIDE { + return Err(TableError::ColumnTooNarrow); + } + edits.push(Edit::AddField { page, rect, kind: NewField::Text { multiline: false }, name: Some(cell.name) }); + } + Ok(Edit::Batch { label: LABEL.to_owned(), edits }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Session; + + fn page(width: f32, height: f32, crop: [f32; 4], rotation: u16) -> PageInfo { + PageInfo { width, height, label: "1".into(), crop, rotation } + } + + fn grid(rect: [f64; 4], rows: usize, patterns: &[&str]) -> FieldTable { + let mut t = FieldTable::new(rect, "unused"); + t.rows = rows; + t.columns = patterns.iter().map(|p| TableColumn { pattern: (*p).to_owned(), width: 1.0 }).collect(); + t + } + + #[test] + fn a_table_is_added_and_undone_as_one_step() { + let mut s = Session::new(); + let bytes = s.create_blank(300.0, 400.0, 1).unwrap(); + let id = s.open("t.pdf", None, bytes, None).unwrap(); + // 200 × 100 points from (50, 40) down the page as displayed: two rows of two. + let table = grid([50.0, 40.0, 250.0, 140.0], 2, &["desc_N", "price_N"]); + let add = |s: &Session| { + let doc = s.get(id).unwrap(); + edit(&table, 0, &doc.info.pages[0], &doc.form) + }; + s.apply(id, add(&s).unwrap()).unwrap(); + let doc = s.get(id).unwrap(); + let rect = |name: &str| doc.form.iter().find(|f| f.name == name).map(|f| f.widgets[0].rect); + // User space has y up: the first row is the higher one. + assert_eq!(rect("desc_1"), Some([50.0, 310.0, 150.0, 360.0])); + assert_eq!(rect("price_1"), Some([150.0, 310.0, 250.0, 360.0])); + assert_eq!(rect("desc_2"), Some([50.0, 260.0, 150.0, 310.0])); + assert_eq!(rect("price_2"), Some([150.0, 260.0, 250.0, 310.0])); + // Laid out again over the same names, it is refused before anything is added. + assert_eq!(add(&s).err(), Some(TableError::Taken("desc_1".into()))); + assert_eq!(s.undo(id).unwrap().to_string(), LABEL); + assert!(s.get(id).unwrap().form.is_empty(), "one undo takes the whole table back"); + } + + #[test] + fn rows_run_down_a_turned_page_as_it_is_shown() { + let mut s = Session::new(); + let bytes = s.create_blank(300.0, 400.0, 1).unwrap(); + let id = s.open("t.pdf", None, bytes, None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let doc = s.get(id).unwrap(); + let info = doc.info.pages[0].clone(); + assert_eq!((info.rotation, info.width, info.height), (90, 400.0, 300.0)); + let table = grid([40.0, 30.0, 240.0, 130.0], 2, &["a_N", "b_N"]); + s.apply(id, edit(&table, 0, &info, &doc.form).unwrap()).unwrap(); + // Each field, taken back to the page as shown, is where its cell was laid out. + let doc = s.get(id).unwrap(); + for cell in table.cells().unwrap() { + let r = doc.form.iter().find(|f| f.name == cell.name).unwrap().widgets[0].rect; + let (a, b) = (info.user_to_view(r[0] as f32, r[1] as f32), info.user_to_view(r[2] as f32, r[3] as f32)); + let shown = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(f64::from); + assert!(shown.iter().zip(cell.rect).all(|(s, c)| (s - c).abs() < 1e-3), "{}: {shown:?} vs {:?}", cell.name, cell.rect); + } + } + + #[test] + fn a_cell_too_small_on_the_page_is_refused_along_the_right_side() { + // Half a point of user space for each point shown across, a whole one down: a quarter + // turn puts "across" on user space's y. + let info = page(200.0, 100.0, [0.0, 0.0, 100.0, 100.0], 90); + let narrow = grid([0.0, 0.0, 12.0, 12.0], 2, &["a_N", "b_N"]); + assert!(narrow.cells().is_ok(), "6 × 6 shown"); + assert_eq!(edit(&narrow, 0, &info, &[]).err(), Some(TableError::ColumnTooNarrow)); + let unturned = page(100.0, 200.0, [0.0, 0.0, 100.0, 100.0], 0); + assert_eq!(edit(&narrow, 0, &unturned, &[]).err(), Some(TableError::RowsTooShort)); + let roomy = page(100.0, 100.0, [0.0, 0.0, 100.0, 100.0], 90); + assert!(edit(&narrow, 0, &roomy, &[]).is_ok()); + } + + /// A corner finite as an `f64` but past `f32`'s range lays out, then comes back from the page's + /// `f32` mapping infinite: one such corner leaves a side infinite, not NaN, and the cell used to + /// pass, for `add_field` to refuse it once the edit was applied. + #[test] + fn a_table_past_the_pages_number_range_is_refused_before_anything_is_added() { + let huge = grid([0.0, 0.0, 1e39, 100.0], 2, &["a_N"]); + assert!(huge.cells().is_ok(), "finite as laid out"); + for rotation in [0, 90] { + let info = page(300.0, 400.0, [0.0, 0.0, 300.0, 400.0], rotation); + assert_eq!(edit(&huge, 0, &info, &[]).err(), Some(TableError::Numbers), "/Rotate {rotation}"); + } + } +} diff --git a/crates/engine/src/fill_sign.rs b/crates/engine/src/fill_sign.rs new file mode 100644 index 0000000000..1bd8c68d05 --- /dev/null +++ b/crates/engine/src/fill_sign.rs @@ -0,0 +1,47 @@ +//! Fill & Sign geometry: typed signatures, and boxes placed as a turned page displays them. + +use crate::{Shape, script_outline, signature_image}; + +/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user +/// space, vertically centred) and `height` points tall. `None` for text with no outlines. +/// +/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): +/// the box runs along the displayed axes, like an image signature's +/// ([`SignatureImage::rect`](crate::SignatureImage::rect)), and the outlines are turned back so the name reads across. +pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { + let o = script_outline(text); + let [left, bottom, right, top] = o.bounds(); + let span = (top - bottom).max(0.1); + let width = right - left; + if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { + return None; + } + let k = height / span; + let rect = signature_image::upright_box(rotation, at, width * k, height)?; + // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box + // is at this fraction of the user-space box. Exact for an unturned page. + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; + let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); + Some(Shape::TypedSignature { rect, contours }) +} + +/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). +pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + [at[0] + a * right + c * up, at[1] + b * right + d * up] +} + +/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, +/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a +/// non-finite point. +pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { + signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) +} + +/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types +/// it. `None` for text with no outlines. +pub fn typed_signature_graphic(text: &str) -> Option { + let o = script_outline(text); + o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) +} diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs new file mode 100644 index 0000000000..fafa65b4c2 --- /dev/null +++ b/crates/engine/src/history.rs @@ -0,0 +1,233 @@ +//! Undo/redo: the editing state of a document, its snapshots and their labels. + +use std::sync::Arc; + +use crate::{AppearanceCache, Edit, EditError, Shape}; + +/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. +pub(crate) const MAX_UNDO: usize = 100; + +/// The passwords that go with one state of a document (protection can change them). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Keys { + /// What the renderer and inspector open the working file with (the user password). + pub(crate) render: Option, + /// What the editor re-opens a saved file with: the strongest password known (owner if any). + pub(crate) reopen: Option, +} + +/// What an edit can change, and so what the view data must be rebuilt from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Scope { + /// Anything: re-inspect the whole document. + Full, + /// Document-information entries; page content and appearances are unchanged. + Metadata, + /// Only comments: re-read the comment list from the object graph. + Comments, + /// Only form field values (and their widget appearances). + Form, + /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. + Links, + /// Only page content streams, resources and added content/marks: the page tree, + /// annotations, form fields, links, outline and fonts are unchanged, so the full + /// re-inspection is skipped (seconds on very large files). + Content, +} + +/// Edits during which field scripts run. +pub(crate) fn uses_scripts(edit: &Edit) -> bool { + match edit { + Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, + Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), + _ => false, + } +} + +pub(crate) fn scope_of(edit: &Edit) -> Scope { + match edit { + Edit::SetInfo { .. } => Scope::Metadata, + // The Attachments list is read from the whole document. + Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, + // A file attachment also changes the Attachments list. + Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::LockAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } => Scope::Comments, + Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, + // Page content, resources and added content/marks only: the page tree, annotations, + // fields, links and document metadata are untouched (see `refresh_content`). + Edit::MoveObjects { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::EditPageImage { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } => Scope::Content, + Edit::Batch { edits, .. } => { + let mut scopes = edits.iter().map(scope_of); + let first = scopes.next().unwrap_or(Scope::Full); + if scopes.all(|s| s == first) { first } else { Scope::Full } + } + _ => Scope::Full, + } +} + +/// One undo/redo step: its label, the document state, its passwords, and what it changed. +pub(crate) type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); + +/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). +#[derive(Clone)] +pub(crate) struct Editor { + pub(crate) cos: pdfcraft_cos::Document, + /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. + /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. + pub(crate) saved: Arc>, + /// Passwords that open [`Self::saved`]. + pub(crate) saved_keys: Keys, + /// [`Self::saved`] was never written where the document belongs: a new document (combine, + /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever + /// undo brings back, or closing it would lose the whole document without asking. + pub(crate) unsaved_file: bool, + pub(crate) undo: Vec, + pub(crate) redo: Vec, + pub(crate) keys: Keys, + /// Display appearances built for annotations without any, reused while no annotation edit + /// runs (content edits leave every annotation dictionary alone). Cleared on any refresh + /// that may change annotations; see [`AppearanceCache`]. + pub(crate) appearances: AppearanceCache, + /// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused + /// for each widget while the objects it was drawn from are unchanged; see + /// [`pdfcraft_forms::FieldAppearanceCache`]. + pub(crate) field_appearances: pdfcraft_forms::FieldAppearanceCache, + /// The text blocks last computed for one page, reused while the document is unchanged. + pub(crate) text_blocks: TextBlocksCache, +} + +/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read +/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not +/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns +/// with undo, so a stale entry never matches, also after the document has been edited. +#[derive(Default)] +pub(crate) struct TextBlocksCache(std::sync::Mutex>); + +/// The document state, the page (0-based) and its text blocks. +type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); + +impl Clone for TextBlocksCache { + fn clone(&self) -> Self { + // A poisoned cache only loses the cached blocks. + Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone()))) + } +} + +impl Editor { + /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. + pub(crate) fn text_blocks(&self, page: usize) -> Result>, EditError> { + if let Ok(cache) = self.text_blocks.0.lock() + && let Some((state, p, blocks)) = cache.as_ref() + && *p == page + && state.matches(&self.cos) + { + return Ok(blocks.clone()); + } + let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); + if let Ok(mut cache) = self.text_blocks.0.lock() { + *cache = Some((self.cos.read_state(), page, blocks.clone())); + } + Ok(blocks) + } +} + +/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: +/// a whole English wording, and the one value the document itself supplies for the wording's hole +/// (a file name, a field name). An interface in another language looks the wording up and says the +/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so +/// the two cannot drift apart. +#[derive(Clone, Debug, PartialEq)] +pub struct Label { + /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`](crate::EDIT_LABELS). + pub wording: &'static str, + /// What the document supplies, when the wording has a hole for it. + pub value: Option, + text: String, +} + +impl Label { + /// A wording with nothing to fill in. + pub fn of(wording: &'static str) -> Self { + Self { wording, value: None, text: wording.to_string() } + } + + /// A wording whose hole the document fills in. + pub fn with(wording: &'static str, value: impl Into) -> Self { + let mut label = Self { wording, value: Some(value.into()), text: String::new() }; + label.text = label.said(wording); + label + } + + /// The label read with this wording: the interface's translation of [`wording`](Self::wording), + /// or the English wording itself. What the document supplies goes in as it stands and is not + /// read again, so a name with braces in it stays whole. + pub fn said(&self, wording: &str) -> String { + let Some(value) = &self.value else { return wording.to_owned() }; + // `{` and `}` are ASCII, so these are char boundaries. + match (wording.find('{'), wording.find('}')) { + (Some(open), Some(close)) if open < close => { + let mut said = String::with_capacity(wording.len() + value.len()); + said.push_str(&wording[..open]); + said.push_str(value); + said.push_str(&wording[close + 1..]); + said + } + // A translation that lost its hole is said as it stands rather than losing the value. + _ => wording.to_owned(), + } + } + + /// The label as English reads it. + pub fn as_str(&self) -> &str { + &self.text + } +} + +impl std::fmt::Display for Label { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} + +/// The passwords after `edit`, if it changes them. +pub(crate) fn keys_after(edit: &Edit) -> Option { + match edit { + Edit::Protect(p) => { + Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) + } + Edit::RemoveProtection => Some(Keys::default()), + Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), + _ => None, + } +} diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index b797bc8d83..d1ee5c15b0 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -7,37 +7,76 @@ //! off, scripts don't run and AF calls keep working natively. use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts}; -pub use pdfcraft_js::{Limits, Outcome, Request}; +pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Panel, Request}; use crate::{DocId, Edit, EditError, Session}; /// What scripts produced for the viewer since it last looked. #[derive(Clone, Debug, Default, PartialEq)] pub struct JsOutput { - pub alerts: Vec, + /// The messages scripts showed, in order, with their titles, icons and buttons. + pub alerts: Vec, pub console: Vec, pub requests: Vec, /// Script errors (as Acrobat's console shows them). pub errors: Vec, + /// Which of `errors` are document scripts' (#3940), told apart by where they came from and + /// not by their text, which a field script's error can share: each is reported once, however + /// often the document scripts run again. + pub doc_errors: Vec, } impl JsOutput { + /// Whether scripts produced nothing: no alerts, console lines, requests or errors. pub fn is_empty(&self) -> bool { self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty() } + /// Adds what a field's own script produced: its alerts, console lines, errors and requests, + /// except a request to show a side panel or open Save As, which is noted on the console + /// and dropped. fn absorb(&mut self, o: &Outcome) { self.alerts.extend(o.alerts.iter().cloned()); self.console.extend(o.console.iter().cloned()); - self.requests.extend(o.requests.iter().cloned()); + // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a + // button's may show or hide a side panel, or open Save As. + for r in &o.requests { + match r { + Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()), + Request::SaveAs => { + self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into()) + } + r => self.requests.push(r.clone()), + } + } self.errors.extend(o.error.iter().cloned()); + // The document scripts run before every field script, so a failing one would repeat + // once per calculation; it is reported once. + for e in &o.doc_errors { + if !self.doc_errors.contains(e) { + self.doc_errors.push(e.clone()); + self.errors.push(e.clone()); + } + } } pub(crate) fn append(&mut self, other: JsOutput) { self.alerts.extend(other.alerts); self.console.extend(other.console); self.requests.extend(other.requests); - self.errors.extend(other.errors); + // As in `absorb`: a document script's error, already reported, is not repeated; a field + // script's errors are each their own, even one worded like a document script's. + let mut docs = other.doc_errors; + for e in other.errors { + if let Some(k) = docs.iter().position(|d| *d == e) { + docs.swap_remove(k); + if self.doc_errors.contains(&e) { + continue; + } + self.doc_errors.push(e.clone()); + } + self.errors.push(e); + } } } @@ -70,11 +109,15 @@ fn display(doc: &pdfcraft_cos::Document, f: &Field) -> i32 { /// A form field as scripts see it. pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::FieldState { + field_state_with_display(f, display(doc, f)) +} + +fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState { let mut s = pdfcraft_js::FieldState::new(f.name.clone(), kind(f.kind), f.value.clone()); s.default = f.default.clone(); s.readonly = f.read_only(); s.required = f.has(pdfcraft_forms::flags::REQUIRED); - s.display = display(doc, f); + s.display = display; s.char_limit = f.max_len; s.options = match f.kind { FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(), @@ -85,10 +128,19 @@ pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::Fiel /// The changes a script made, against the fields as they were. fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]) -> Vec { + if after.is_empty() { + return Vec::new(); + } + // Outcomes contain only fields touched by the script. Index the original snapshot once, + // keeping the first match as the old linear lookup did for malformed duplicate names. + let mut by_name = std::collections::HashMap::with_capacity(before.len()); + for field in before { + by_name.entry(field.name.as_str()).or_insert(field); + } after .iter() .filter_map(|a| { - let b = before.iter().find(|b| b.name == a.name)?; + let b = by_name.get(a.name.as_str())?; let c = FieldChange { name: a.name.clone(), value: (a.value != b.value).then(|| a.value.clone()), @@ -106,14 +158,29 @@ pub struct JsRunner { pub doc: pdfcraft_js::DocInfo, pub doc_scripts: Vec, pub output: JsOutput, - /// The fields in the document as the forms code last read them (for `display`). + /// Immutable document snapshot from the start of the edit (for widget `display`). cos: pdfcraft_cos::Document, + // `cos` is the same immutable pre-edit snapshot for the runner's lifetime. Cache only + // its scalar display readings, not COS objects: large forms exceed the object cache and + // otherwise reparse every widget for every calculation. Values/flags still come from + // the live `fields` snapshot on each call. A new edit gets a new runner and cache. + displays: std::collections::HashMap, } impl JsRunner { + /// A runner for `cos`: what scripts read about the document (pages, info, whether it is + /// an XFA form) and its document-level scripts, read once. pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner { - let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos) }; - JsRunner { doc, doc_scripts: pdfcraft_forms::document_scripts(cos), output: JsOutput::default(), cos: cos.clone() } + let xfa = pdfcraft_xfa::xfa_shape(cos).is_some(); + let doc = + pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos), xfa }; + // Once XFA owns the form, the PDF container's scripts belong to its fallback view. + // Keep them in the file and in Document JavaScripts, but do not initialize an + // AcroForm context with them: a viewer-compatibility alert would be misleading. + // Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts. + let xfa_active = xfa && pdfcraft_xfa::existing_layout(cos).is_some(); + let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) }; + JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new() } } } @@ -129,21 +196,32 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { } impl Scripts for JsRunner { + /// Run field `target`'s script for `event` over the form's fields, keeping what it showed and + /// asked for; a refusal carries its last message. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { - let states: Vec<_> = fields.iter().map(|f| field_state(&self.cos, f)).collect(); + let states: Vec<_> = fields + .iter() + .map(|f| { + let display = match f.widgets.first() { + Some(w) => *self.displays.entry(w.obj).or_insert_with(|| display(&self.cos, f)), + None => pdfcraft_js::DISPLAY_VISIBLE, + }; + field_state_with_display(f, display) + }) + .collect(); + let states = std::sync::Arc::new(states); let ev = pdfcraft_js::Event::field(event.name(), &target.name, value); - let o = pdfcraft_js::run(script, &ev, &self.doc, &states, &self.doc_scripts, Limits::default()); + let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default()); self.output.absorb(&o); if o.error.is_some() { // As in Acrobat, a failing script leaves the value alone (the error goes to the console). return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None }; } - let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect(); ScriptResult { rc: o.rc, value: o.value.clone(), - changes: changes(&states, &after), - message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(), + changes: changes(&states, &o.changed), + message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), } } } @@ -159,6 +237,11 @@ impl crate::Document { self.editor.as_ref().and_then(|e| pdfcraft_forms::field_actions(&e.cos, name).ok()).unwrap_or_default() } + /// Field Properties ▸ Actions: how many actions are chained after each trigger's first one. + pub fn field_action_chains(&self, name: &str) -> Vec<(pdfcraft_forms::Trigger, usize)> { + self.editor.as_ref().and_then(|e| pdfcraft_forms::field_action_chains(&e.cos, name).ok()).unwrap_or_default() + } + /// Document JavaScripts (name, source), in name order. pub fn document_scripts(&self) -> Vec<(String, String)> { self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default() @@ -192,7 +275,8 @@ impl Session { // A button PdfCraft generated from an XFA template runs its XFA click script. if let Some(t) = target && let Some(tpl) = doc.xfa_template.clone() - && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, t) + && let Some(field) = doc.form.iter().find(|f| f.name == t) + && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field) { self.apply(id, Edit::XfaEvent { som, activity: "click".into() })?; let out = self.take_js_output(id); @@ -213,8 +297,7 @@ impl Session { None => pdfcraft_js::Event::doc("Console"), }; let mut o = pdfcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default()); - let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect(); - let changes = changes(&states, &after); + let changes = changes(&states, &o.changed); let resets: Vec> = o .requests .iter() @@ -256,6 +339,12 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende .collect() } +#[cfg(test)] +thread_local! { + /// Characters read to tell underscore runs from other words. + static UNDERSCORE_CHARS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A page's words in reading order, their boxes mapped from view space by `map` (and ordered /// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them). pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> { @@ -263,6 +352,8 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 let mut last_line = u32::MAX; // A space glyph ends the word before it. let mut gap = false; + // Whether the last word is an underscore run (a form's blank), which other glyphs don't join. + let mut last_under = false; for (i, g) in text.glyphs.iter().enumerate() { let line = text.line_of.get(i).copied().unwrap_or(0); if g.text.trim().is_empty() { @@ -270,10 +361,9 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 continue; } let under = g.text.chars().all(|c| c == '_'); - let breaks = match words.last() { - None => true, - Some((w, _)) => gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || w.chars().all(|c| c == '_') != under, - }; + #[cfg(test)] + UNDERSCORE_CHARS_READ.with(|n| n.set(n.get() + g.text.len())); + let breaks = words.is_empty() || gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || last_under != under; gap = false; last_line = line; match words.last_mut() { @@ -284,7 +374,10 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32 r[2] = r[2].max(g.rect[2]); r[3] = r[3].max(g.rect[3]); } - _ => words.push((g.text.clone(), g.rect)), + _ => { + words.push((g.text.clone(), g.rect)); + last_under = under; + } } } words @@ -349,7 +442,12 @@ impl Session { pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None), pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)), }; - let mut props = FieldProps::default(); + // The page already draws the line or box the field sits on, so the field gets no + // border or fill of its own: an opaque fill would hide the printed form (#294, #2831). + let mut props = FieldProps { + appearance: Some(pdfcraft_forms::LookPatch { border: Some(None), fill: Some(None), ..Default::default() }), + ..Default::default() + }; if turn != 0 { // A turned field keeps its place: the rectangle is already the one on the page. props.rotation = Some((0, turn)); @@ -360,11 +458,100 @@ impl Session { props.flags = vec![(pdfcraft_forms::flags::COMB, true)]; } edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) }); - if turn != 0 || comb.is_some() { - edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) }); - } + edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) }); } self.apply(id, Edit::Batch { label: "Detect form fields".into(), edits })?; Ok(names) } } + +#[cfg(test)] +mod review_tests { + use super::*; + + /// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with + /// the word so far without reading the word again. + #[test] + fn a_long_underscore_run_is_read_in_one_pass() { + let n = 10_000; + let glyph = |i: usize, t: &str| pdfcraft_render::TextGlyph { + text: t.into(), + rect: [i as f32, 0.0, i as f32 + 1.0, 10.0], + quad: [[0.0; 2]; 4], + direction: [1.0, 0.0], + }; + let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect(); + glyphs.push(glyph(n, "x")); + let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs }; + UNDERSCORE_CHARS_READ.with(|c| c.set(0)); + let words = words_with(&text, |x, y| [x, y]); + assert_eq!(words.iter().map(|(w, _)| w.len()).collect::>(), [n, 1]); + let read = UNDERSCORE_CHARS_READ.with(std::cell::Cell::get); + assert!(read <= 4 * n, "{read} characters read for {n} glyphs"); + } + + /// Review on #3985: merging two outputs reports a failing document script once, as one run + /// does, while each field script error stays: told apart by where an error came from, so a + /// field script that throws a document script's very words is still reported. + #[test] + fn merged_output_reports_a_document_script_error_once() { + let doc = "document script 2: TypeError: not a callable function"; + let field = "ReferenceError: x is not defined"; + let run = |error: &str| { + let mut out = JsOutput::default(); + out.absorb(&Outcome { error: Some(error.into()), doc_errors: vec![doc.into()], ..Outcome::default() }); + out + }; + let mut out = run(field); + out.append(run(field)); + assert_eq!(out.errors, [field, doc, field]); + // CodeRabbit on #3985: a field script that throws the document script's text. + out.append(run(doc)); + assert_eq!(out.errors, [field, doc, field, doc]); + let alone = run(doc); + assert_eq!(alone.errors, [doc, doc], "the field's error and the document script's"); + let count = |o: &JsOutput| o.errors.iter().filter(|e| *e == doc).count(); + let mut merged = run(field); + merged.append(alone); + assert_eq!(count(&merged), 2, "the field's copy stays; the document script's is listed once"); + } + + #[test] + fn duplicate_field_names_diff_against_first_original() { + let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]); + let mut second = first.clone(); + second.value = vec!["second".into()]; + second.display = pdfcraft_js::DISPLAY_HIDDEN; + let before = vec![first.clone(), second.clone()]; + assert!(changes(&before, std::slice::from_ref(&first)).is_empty()); + let actual = changes(&before, std::slice::from_ref(&second)); + assert_eq!( + actual, + vec![FieldChange { + name: "duplicate".into(), + value: Some(second.value), + display: Some(pdfcraft_js::DISPLAY_HIDDEN), + ..Default::default() + }] + ); + let outcome = pdfcraft_js::run_shared( + "event.value = getField('duplicate').value; getField('duplicate').value = 'updated';", + &pdfcraft_js::Event::field("Calculate", "duplicate", ""), + &pdfcraft_js::DocInfo::default(), + std::sync::Arc::new(before.clone()), + &[], + Limits::default(), + ); + assert_eq!(outcome.error, None); + assert_eq!(outcome.value, "first"); + assert_eq!(outcome.changed.len(), 1); + assert_eq!( + changes(&before, &outcome.changed), + vec![FieldChange { name: "duplicate".into(), value: Some(vec!["updated".into()]), ..Default::default() }] + ); + assert!(changes(&before, &[]).is_empty()); + let mut missing = first; + missing.name = "missing".into(); + assert!(changes(&before, &[missing]).is_empty()); + } +} diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs new file mode 100644 index 0000000000..f75967097c --- /dev/null +++ b/crates/engine/src/layers.rs @@ -0,0 +1,60 @@ +//! Layer (optional content) visibility: what the view shows and what printing writes. + +use std::sync::Arc; + +use pdfcraft_render::{Layer, LayerOp, RenderPool}; + +use crate::{Document, display_config, render_threads}; + +/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its +/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` +/// is left alone. +pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; + let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; + let Some(properties) = root.get(b"OCProperties").cloned() else { return }; + let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; + let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); + let refs = + |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); + config.set(b"BaseState".to_vec(), Object::name("ON")); + config.set(b"ON".to_vec(), refs(true)); + config.set(b"OFF".to_vec(), refs(false)); + properties.set(b"D".to_vec(), Object::Dict(config)); + root.set(b"OCProperties".to_vec(), Object::Dict(properties)); + cos.set(root_ref, Object::Dict(root)); +} + +/// Render `doc` with the layer visibility in its `info.layers`. +pub(crate) fn use_layer_choices(doc: &mut Document) { + let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); + doc.config.layers = Arc::new(overrides); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); +} + +/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the +/// state the changes before it left. Returns whether any layer ended up changed. +pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let before: Vec = layers.iter().map(|l| l.visible).collect(); + let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect(); + for &(op, ocg) in changes { + let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue }; + let on = match op { + LayerOp::On => true, + LayerOp::Off => false, + LayerOp::Toggle => !layer.visible, + }; + layer.visible = on; + // Turning a layer off leaves the rest of its groups alone. + if on && preserve_rb { + for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) { + if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) { + l.visible = false; + } + } + } + } + layers.iter().zip(before).any(|(l, was)| l.visible != was) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8f39248abd..0b49cfd4e1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,127 +14,94 @@ pub mod actions; pub mod autotag; +mod bookmarks; pub mod build_info; pub mod camera; pub mod camera_clean; pub mod camera_edges; pub mod catalog; pub mod commands; +mod comments; pub mod compare; mod copy_image; pub mod dates; +mod document; +mod edit; +mod error; pub mod export; +pub mod field_table; +mod fill_sign; +mod history; pub mod js; +mod layers; pub mod links; pub mod ocr; +mod open; pub mod optimizer; +mod page_filter; +mod pictures; pub mod print_raster; pub mod printcheck; +mod protection; mod scan_colours; mod scan_fields; +mod session; pub mod signature_image; pub mod signature_scan; +mod sources; +mod stamp_preview; +#[cfg(test)] +mod test_hooks; pub mod text_fonts; mod text_preview; +pub mod text_style_bookmarks; pub mod word_count; pub mod xfa; +mod xfa_form; +use std::sync::Arc; + +pub use links::{ + MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, +}; pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; -pub use text_preview::TextEditPreview; +pub use stamp_preview::{CommentLayer, StampPreview}; +pub use text_preview::{TextEditPreview, UnmarkedPages}; -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, split_ranges}; +pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; -/// One file produced by a split: (1-based first page, last page, PDF bytes). pub use pdfcraft_organize::LabelStyle; +pub use pdfcraft_organize::Standard; +pub use pdfcraft_organize::check_custom_key; pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; pub use pdfcraft_cos::Algorithm; pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; -pub use pdfcraft_edit::added::{text_rect as added_text_rect, text_width as added_text_width}; +pub use pdfcraft_edit::added::{ + CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, + text_width as added_text_width, +}; +pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use pdfcraft_forms::{ - BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, - Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, - appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags, + BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, + FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, + Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, + appearance::comb_auto_text_size as field_comb_auto_text_size, calculations_changed as form_calculations_changed, flags as field_flags, }; pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, first_undrawable}; +pub use pdfcraft_edit::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; -/// A change to an existing page image. -#[derive(Clone, Debug, PartialEq)] -pub enum ImageEdit { - /// Move and resize it to this box (user space). - Move([f64; 4]), - /// Quarter turns clockwise about its centre. - Rotate(i32), - Flip { - horizontal: bool, - }, - /// Show only this box of it (user space), the pixels staying where they are. - Crop([f64; 4]), - /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. - Replace { - name: String, - bytes: Arc>, - }, - Delete, -} pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; -/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user -/// space, vertically centred) and `height` points tall. `None` for text with no outlines. -/// -/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): -/// the box runs along the displayed axes, like an image signature's -/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. -pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { - let o = script_outline(text); - let [left, bottom, right, top] = o.bounds(); - let span = (top - bottom).max(0.1); - let width = right - left; - if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { - return None; - } - let k = height / span; - let rect = signature_image::upright_box(rotation, at, width * k, height)?; - // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box - // is at this fraction of the user-space box. Exact for an unturned page. - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); - let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; - let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); - Some(Shape::TypedSignature { rect, contours }) -} -/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). -pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); - [at[0] + a * right + c * up, at[1] + b * right + d * up] -} - -/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, -/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a -/// non-finite point. -pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { - signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) -} - -/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types -/// it. `None` for text with no outlines. -pub fn typed_signature_graphic(text: &str) -> Option { - let o = script_outline(text); - o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) -} - -pub use links::{ - MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, -}; /// Comment geometry helpers (text-box line breaking) for frontends. pub use pdfcraft_annot::appearance as annot_text; pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; @@ -155,5008 +122,49 @@ pub use pdfcraft_sign as sign; pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; pub use pdfcraft_xfdf::Format as DataFormat; -/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a -/// filled-in PDF form), as CSV with one row per file. -pub fn merge_data_files(files: &[(String, Vec)]) -> Result { - let rows = - files.iter().map(|(name, bytes)| pdfcraft_xfdf::data_values(bytes).map_err(|e| format!("{name}: {e}"))).collect::, _>>()?; - Ok(pdfcraft_xfdf::merge_csv(&rows)) -} - -pub type SplitPart = (usize, usize, Arc>); - -use std::sync::{Arc, OnceLock}; - +use bookmarks::outline_rows; +pub use bookmarks::{BookmarkPage, BookmarkRow}; +use comments::comment_list; +pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name}; #[cfg(test)] -thread_local! { - static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) }; -} - +use document::page_runs; +pub use document::{Document, EditNotice, TextNotice, display_bytes_for, render_password_for}; +use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; +pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; +pub use error::{EditError, SourceProblem}; +pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, typed_signature_shape}; +pub use history::Label; +use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; +pub use open::{OpenContext, PreparedDocument}; +pub use page_filter::{PageOrientation, PageParity, filter_pages}; +pub use pictures::MarkFile; +use pictures::{embed_image, fit_picture, mark_source}; +use protection::unrestricted; +pub use protection::{Changes, Printing, Protection, SecuritySummary}; +pub use session::Session; +pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; +use sources::{open_source, open_source_with}; #[cfg(test)] -fn reset_secondary_scan_counts() { - SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); -} - -#[cfg(test)] -fn secondary_scan_counts() -> (usize, usize) { - SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) -} - -#[cfg(test)] -fn record_marker_scan() { - SECONDARY_SCAN_COUNTS.with(|counts| { - let (markers, links) = counts.get(); - counts.set((markers + 1, links)); - }); -} - -#[cfg(test)] -fn record_link_scan() { - SECONDARY_SCAN_COUNTS.with(|counts| { - let (markers, links) = counts.get(); - counts.set((markers, links + 1)); - }); -} - -use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; -use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; - -/// Stable identifier of an open document within a session. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct DocId(pub u64); - -/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. -const MAX_UNDO: usize = 100; - -/// The passwords that go with one state of a document (protection can change them). -#[derive(Clone, Debug, Default, PartialEq)] -struct Keys { - /// What the renderer and inspector open the working file with (the user password). - render: Option, - /// What the editor re-opens a saved file with: the strongest password known (owner if any). - reopen: Option, -} - -/// What an edit can change, and so what the view data must be rebuilt from. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Scope { - /// Anything: re-inspect the whole document. - Full, - /// Document-information entries; page content and appearances are unchanged. - Metadata, - /// Only comments: re-read the comment list from the object graph. - Comments, - /// Only form field values (and their widget appearances). - Form, - /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. - Links, - /// Only page content streams, resources and added content/marks: the page tree, - /// annotations, form fields, links, outline and fonts are unchanged, so the full - /// re-inspection is skipped (seconds on very large files). - Content, -} - -/// Edits during which field scripts run. -fn uses_scripts(edit: &Edit) -> bool { - match edit { - Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } => true, - Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), - _ => false, - } -} - -fn scope_of(edit: &Edit) -> Scope { - match edit { - Edit::SetInfo { .. } => Scope::Metadata, - // A file attachment also changes the Attachments list. - Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::LockAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::MoveAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } => Scope::Comments, - Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, - // Page content, resources and added content/marks only: the page tree, annotations, - // fields, links and document metadata are untouched (see `refresh_content`). - Edit::MoveObjects { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::EditPageImage { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } => Scope::Content, - Edit::Batch { edits, .. } => { - let mut scopes = edits.iter().map(scope_of); - let first = scopes.next().unwrap_or(Scope::Full); - if scopes.all(|s| s == first) { first } else { Scope::Full } - } - _ => Scope::Full, - } -} - -/// One undo/redo step: its label, the document state, its passwords, and what it changed. -type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); - -/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). -#[derive(Clone)] -struct Editor { - cos: pdfcraft_cos::Document, - /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. - /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. - saved: Arc>, - /// Passwords that open [`Self::saved`]. - saved_keys: Keys, - /// [`Self::saved`] was never written where the document belongs: a new document (combine, - /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever - /// undo brings back, or closing it would lose the whole document without asking. - unsaved_file: bool, - undo: Vec, - redo: Vec, - keys: Keys, - /// Display appearances built for annotations without any, reused while no annotation edit - /// runs (content edits leave every annotation dictionary alone). Cleared on any refresh - /// that may change annotations; see [`AppearanceCache`]. - appearances: AppearanceCache, - /// The text blocks last computed for one page, reused while the document is unchanged. - text_blocks: TextBlocksCache, -} - -/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read -/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not -/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns -/// with undo, so a stale entry never matches, also after the document has been edited. -#[derive(Default)] -struct TextBlocksCache(std::sync::Mutex>); - -/// The document state, the page (0-based) and its text blocks. -type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); - -impl Clone for TextBlocksCache { - fn clone(&self) -> Self { - // A poisoned cache only loses the cached blocks. - Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone()))) - } -} - -impl Editor { - /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. - fn text_blocks(&self, page: usize) -> Result>, EditError> { - if let Ok(cache) = self.text_blocks.0.lock() - && let Some((state, p, blocks)) = cache.as_ref() - && *p == page - && state.matches(&self.cos) - { - return Ok(blocks.clone()); - } - let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); - if let Ok(mut cache) = self.text_blocks.0.lock() { - *cache = Some((self.cos.read_state(), page, blocks.clone())); - } - Ok(blocks) - } -} - -pub struct Document { - pub id: DocId, - pub name: String, - pub path: Option, - /// The working file: what Save writes. - pub bytes: Arc>, - /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances - /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. - pub display: Arc>, - pub info: DocInfo, - pub renderer: RenderPool, - /// The password the document was opened with (needed to read attachments, etc.). - pub password: Option, - /// `true` when there are edits that have not been saved. - pub dirty: bool, - /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. - generation: u64, - /// The generation last handed out by `autosave_snapshots`. - snapshot_generation: u64, - /// Why the document cannot be edited (e.g. encryption), if so. - pub read_only_reason: Option, - /// Interactive form fields of the current state (empty without a form). - pub form: Arc>, - marker_scan: OnceLock, - links: OnceLock>, - /// Signature fields, validated against the session's trust store (Signatures panel). - pub signatures: Arc>, - trust: Arc, - sig_cache: Arc, - editor: Option, - config: RenderConfig, - /// What field scripts printed or asked for (see [`Session::take_js_output`]). - js_output: js::JsOutput, - /// Dynamic XFA forms: what laying the template out produced (pages and fields are - /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). - pub xfa: Option, - /// The parsed template of a laid-out XFA form, for its scripts. - xfa_template: Option>, - /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). - xfa_scripts_off: bool, - /// A content-only refresh keeps `info` from before the edit; an edit may have added or - /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). - fonts_stale: bool, - /// XFA forms: what was approximated, rewritten or could not be written to the XFA data - /// (also in `info.warnings`, kept there when the document is re-read). - pub xfa_warnings: Vec, - /// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed, - /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. - pub edit_notices: Vec, -} - -/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn -/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat -/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TextNotice { - /// The form field's full name, or `None` for a comment. - pub field: Option, - /// The page (0-based) of the comment or of the field's widget. - pub page: usize, - pub ch: char, - /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` - /// set); otherwise the `?` shows in every viewer. - pub redrawn_elsewhere: bool, -} - -impl std::fmt::Display for TextNotice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); - let page = self.page + 1; - match (&self.field, self.redrawn_elsewhere) { - (Some(name), true) => write!( - f, - "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" - ), - (Some(name), false) => write!( - f, - "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" - ), - (None, _) => write!( - f, - "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" - ), - } - } -} - -/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying -/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. -fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { - let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; - let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); - let mut names: Option> = None; - let mut out: Vec = Vec::new(); - for (page, p) in pages.iter().enumerate() { - let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); - for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { - let now = next.get(r); - let Some(d) = now.as_dict() else { continue }; - let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; - // Only what this edit drew: a new annotation, or a new appearance. - let was = prev.get(r); - if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { - continue; - } - let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { - let names = names.get_or_insert_with(|| { - pdfcraft_forms::fields(next) - .into_iter() - .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) - .collect() - }); - names.get(&r).cloned().unwrap_or_default() - }); - if field.is_some() && out.iter().any(|n| n.field == field) { - continue; - } - out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); - } - } - out -} - -impl Document { - /// How the document opens (Document Properties ▸ Initial View). - /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be - /// read for editing). - pub fn accessibility_check(&self, options: &a11y::Options) -> Option { - self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) - } - - /// A script of this XFA form ran past its time limit and was abandoned, so the form's - /// scripts no longer run in this document (reopening it runs them again). The script's - /// error is in the JavaScript output; this is the state, for a viewer to show. - pub fn xfa_scripts_off(&self) -> bool { - self.xfa_scripts_off - } - - /// PDF Optimizer ▸ Audit space usage. - pub fn audit_space(&self) -> Vec { - self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() - } - - /// Edit a PDF: the images `page` draws (0-based). - pub fn page_images(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() - } - - /// Mixed editable objects on one page. Source inventories are zero-based and added items - /// appear once. Rectangles are user-space points, independent of the viewer's zoom. - pub fn editable_objects(&self, page: usize) -> Result, String> { - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) - } - - /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, - /// so even a large crop-box origin cannot erase a small drag by cancellation. - pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { - let p = self.info.pages.get(page).ok_or("the page no longer exists")?; - let [x0, y0, x1, y1] = p.crop.map(f64::from); - let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); - let (u, v) = match p.rotation { - 90 => (b, -a), - 180 => (-a, -b), - 270 => (-b, a), - _ => (a, b), - }; - let offset = [u * (x1 - x0), -v * (y1 - y0)]; - if !offset.iter().all(|v| v.is_finite()) { - return Err("the move must be finite and non-zero".into()); - } - Ok(offset) - } - - /// Save image as: image `index` on `page` as a file (extension, bytes). - pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { - self.check_copying().map_err(|e| e.to_string())?; - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; - if img.is_form { - return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); - } - pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) - } - - /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). - pub fn text_blocks(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() - } - - /// Saved measurement annotations, calculated from their geometry and PDF scales, plus - /// the ones that couldn't be read (unsupported formats are skipped, not fatal). - pub fn measurements(&self) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - Ok(measure::list(&e.cos)) - } - pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) - } - /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a - /// point) where the page sets no scale. - pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) - } - pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) - } - pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) - } - pub fn measurement_paths(&self, page: usize) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) - } - - /// A counter that changes with every edit (for caches of derived data). - pub fn edit_generation(&self) -> u64 { - self.generation - } - - /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). - pub fn text_lines(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() - } - - /// Add alternate text: the figures, in document order. - pub fn figures(&self) -> Vec { - self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() - } - - /// Reachable indirect structure tags, in document order. - pub fn tags(&self) -> Vec { - self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() - } - - /// Marked-content items directly owned by one reachable indirect structure tag. - pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { - self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) - } - - /// The edit that fixes `rule`, for the rules with an automatic fix: the document language - /// (`value` is the language), the title (`value` replaces it; else the current title or the - /// file name) and the tab order. - pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { - let value = value.map(str::trim).filter(|v| !v.is_empty()); - match rule { - a11y::Rule::PrimaryLanguage => { - let mut v = self.initial_view(); - v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); - Ok(Edit::SetInitialView(Box::new(v))) - } - a11y::Rule::Title => { - let title = value - .map(str::to_owned) - .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) - .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); - let mut v = self.initial_view(); - v.display_title = true; - Ok(Edit::Batch { - label: "Set document title".into(), - edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], - }) - } - a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), - other => Err(format!("\"{}\" has no automatic fix", other.name())), - } - } - - pub fn initial_view(&self) -> InitialView { - self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() - } - - /// Signed: at least one signature field holds a signature. - pub fn is_signed(&self) -> bool { - self.signatures.iter().any(|s| s.signed) - } - - /// Whether comments are hidden (Comments ▸ Hide all comments). - pub fn comments_hidden(&self) -> bool { - self.config.hide_comments - } - - pub fn can_undo(&self) -> Option<&str> { - self.undo_label().map(Label::as_str) - } - - pub fn can_redo(&self) -> Option<&str> { - self.redo_label().map(Label::as_str) - } - - /// What Undo would undo, in parts, for an interface that says it in another language. - pub fn undo_label(&self) -> Option<&Label> { - self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) - } - - /// What Redo would redo, in parts. - pub fn redo_label(&self) -> Option<&Label> { - self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) - } - - pub fn editable(&self) -> bool { - self.editor.is_some() - } - - /// What the opening password allows; `None` when the document is not encrypted. - pub fn permissions(&self) -> Option { - self.editor.as_ref().and_then(|e| e.cos.permissions()) - } - - /// Printing is allowed (Table 22, bit 3). - pub fn allows_printing(&self) -> bool { - self.permissions().is_none_or(|p| p.print()) - } - - /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying - /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the - /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. - pub fn allows_copying(&self) -> bool { - self.permissions().is_none_or(|p| p.copy()) - } - - /// [`Self::allows_copying`], as the error to report when it isn't. - pub fn check_copying(&self) -> Result<(), EditError> { - if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } - } - - /// Page changes (insert, delete, rotate, move, extract) are allowed. - pub fn allows_assembly(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.assemble()) - } - - /// Redaction marks waiting to be applied. - pub fn redaction_marks(&self) -> usize { - self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() - } - - /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. - pub fn marks(&self) -> &[MarkKind] { - &self - .marker_scan - .get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_marker_scan(); - pdfcraft_edit::scan_markers(&e.cos) - }) - .unwrap_or_default() - }) - .marks - } - - /// Text and images added with Edit a PDF ▸ Add content (still editable). - pub fn added(&self) -> &[pdfcraft_edit::Added] { - &self - .marker_scan - .get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_marker_scan(); - pdfcraft_edit::scan_markers(&e.cos) - }) - .unwrap_or_default() - }) - .added - } - - /// Link annotations, for Edit a PDF ▸ Link. - pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { - self.links.get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_link_scan(); - pdfcraft_annot::links::list(&e.cos) - }) - .unwrap_or_default() - }) - } - - /// Changes to content and document information are allowed. - pub fn allows_modification(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.modify()) - } - - /// Filling in form fields is allowed (Table 22, bits 6 and 9). - pub fn allows_form_filling(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) - } - - /// Adding and changing comments is allowed (Table 22, bit 6). - pub fn allows_annotation(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.annotate()) - } - - /// The document's security may be changed (Protect, Remove security): it is editable and, - /// if encrypted, was opened with the owner password. - pub fn allows_security_change(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) - } - - /// A password restricts the document and it wasn't opened with the owner password: entering - /// that password ([`Session::unlock`]) lifts the restrictions. - pub fn can_unlock(&self) -> bool { - self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) - } - - /// A summary of the document's security for Document Properties ▸ Security, including - /// protection applied in this session (written by the next save). - pub fn security_summary(&self) -> Option { - let editor = self.editor.as_ref()?; - let h = editor.cos.output_handler()?; - let d = h.dict(); - let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); - let method = match (d.v, stream) { - (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", - (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", - (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", - (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", - _ => "Attachments only", - }; - let pending = editor.cos.encryption_changed(); - let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; - Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) - } - - /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). - pub fn comment_props(&self, page: usize, index: usize) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) - } - - /// A form field's Appearance-tab look (borders, colours, font). - pub fn field_look(&self, name: &str) -> Option { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - Some(pdfcraft_forms::look(&e.cos, f)) - } - - /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. - pub fn field_check_style(&self, name: &str) -> Option { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) - } - - /// Remove Hidden Information: what each category would remove. - pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { - self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() - } - - /// Verification: faux redactions (opaque covers over extractable text) and redaction - /// marks that were never applied. Fails closed: any read error is returned, never an - /// empty list. - pub fn audit_redactions(&self) -> Result, String> { - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) - } - - /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. - pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { - self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() - } - - /// Current value of a document-information entry (Title, Author, …). - pub fn info_value(&self, key: &str) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) - } - - /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. - pub fn custom_info(&self) -> Vec<(String, String)> { - self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() - } - - /// The name shown on the tab and window: the document title when the document asks for it - /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. - pub fn display_name(&self) -> String { - self.editor - .as_ref() - .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) - .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) - .map(|t| t.trim().to_owned()) - .filter(|t| !t.is_empty() && !is_placeholder_title(t)) - .unwrap_or_else(|| self.name.clone()) - } - - /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that - /// revision). Unsaved edits aren't a revision yet. - pub fn revision_ends(&self) -> Vec { - self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() - } - - /// The file as last opened or saved, without unsaved edits: what an incremental save appends - /// to. This stays that file when undo has restored an older snapshot. - pub fn saved_bytes(&self) -> Arc> { - self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) - } - - /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). - pub fn repair_log(&self) -> Vec { - self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() - } -} - -/// A title that names no document: what a web browser writes when it saves a blank page or a -/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a -/// title says less than the file name, so the file name is shown instead. An address is one -/// word: "About: our company" is a real title. -fn is_placeholder_title(title: &str) -> bool { - let t = title.trim().to_lowercase(); - let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); - address || t == "untitled" -} - -/// The working file plus appearances for comments that have none. Signature artwork -/// is preserved; validation badges belong to the viewer and never enter these bytes, -/// which image/text exports and Print as Image also consume. -fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { - let Some(e) = editor else { return bytes.clone() }; - let Some(display) = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances) else { - return bytes.clone(); - }; - match write_incremental(&display, &SaveOptions::default()) { - Ok(written) => Arc::new(written), - Err(err) => { - e.cos.note_repair(format!("missing comment appearances could not be drawn ({err})")); - bytes.clone() - } - } -} - -/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation. -fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool { - a.len() == b.len() - && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) -} - -/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for -/// comments that have none. Render paths outside the app, such as the CLI, use this so their page -/// images agree with the app and independent viewers. It never changes the source document, and an -/// unparseable file, a write failure or a parser panic falls back to the original bytes. -pub fn display_bytes_for(bytes: Arc>, password: Option<&str>) -> Arc> { - guard(|| { - pdfcraft_cos::Document::open_with_password(bytes.clone(), password) - .ok() - .and_then(|cos| pdfcraft_annot::with_missing_appearances(&cos)) - .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) - .map(Arc::new) - }) - .ok() - .flatten() - .unwrap_or(bytes) -} - -/// Fails when the renderer still draws text under an applied redaction mark. -/// -/// Redaction decides what lies under a mark from the positions its own interpreter computes, and -/// its verify pass reuses them. Where they differ from the renderer's (a standard font without -/// /Widths, whose advances the interpreter approximates; a CMap, or a font set outside `Tf`, -/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the -/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is -/// what the user saw when placing the marks, so it is asked. `after` is written as a save would -/// write it, without the marks' own overlay (the box and its label are drawn on purpose), its -/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps -/// the document as it was. -fn shown_under_redactions( - after: &pdfcraft_cos::Document, - marks: &[pdfcraft_redact::Mark], - pages: &[pdfcraft_render::PageInfo], - password: Option<&str>, -) -> Result<(), EditError> { - use pdfcraft_cos::Object; - use pdfcraft_redact::RedactError; - let mut marked: Vec = marks.iter().map(|m| m.page).collect(); - marked.sort_unstable(); - marked.dedup(); - if marked.is_empty() { - return Ok(()); - } - let mut shown = after.clone(); - let model = pdfcraft_model::pages(&shown); - for &pi in &marked { - let Some(page) = model.get(pi) else { continue }; - let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; - let kept: Vec = - list.into_iter().filter(|o| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect(); - shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; - } - let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; - let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; - let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); - let mut residue = 0; - for &pi in &marked { - let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; - // A page that cannot be read back cannot be confirmed clean. - let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; - let rects: Vec<[f64; 4]> = marks - .iter() - .filter(|m| m.page == pi) - .flat_map(|m| m.rects.iter()) - .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) - .collect(); - for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { - let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); - let area = (x1 - x0) * (y1 - y0); - if area.is_nan() || area <= 0.0 { - continue; - } - // Redaction itself takes any glyph a fifth under a mark; one that is half under it - // and still drawn was not where redaction thought it was. - let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; - if rects.iter().any(under) { - residue += 1; - } - } - } - if residue > 0 { - return Err(RedactError::Residue(residue).into()); - } - Ok(()) -} - -fn render_threads() -> usize { - std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1 -} - -/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy -/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its -/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` -/// is left alone. -fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { - use pdfcraft_cos::{Dict, ObjRef, Object}; - let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; - let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; - let Some(properties) = root.get(b"OCProperties").cloned() else { return }; - let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; - let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); - let refs = - |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); - config.set(b"BaseState".to_vec(), Object::name("ON")); - config.set(b"ON".to_vec(), refs(true)); - config.set(b"OFF".to_vec(), refs(false)); - properties.set(b"D".to_vec(), Object::Dict(config)); - root.set(b"OCProperties".to_vec(), Object::Dict(properties)); - cos.set(root_ref, Object::Dict(root)); -} - -/// Render `doc` with the layer visibility in its `info.layers`. -fn use_layer_choices(doc: &mut Document) { - let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); - doc.config.layers = Arc::new(overrides); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); -} - -/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the -/// state the changes before it left. Returns whether any layer ended up changed. -fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let before: Vec = layers.iter().map(|l| l.visible).collect(); - let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect(); - for &(op, ocg) in changes { - let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue }; - let on = match op { - LayerOp::On => true, - LayerOp::Off => false, - LayerOp::Toggle => !layer.visible, - }; - layer.visible = on; - // Turning a layer off leaves the rest of its groups alone. - if on && preserve_rb { - for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) { - if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) { - l.visible = false; - } - } - } - } - layers.iter().zip(before).any(|(l, was)| l.visible != was) -} - -/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). -pub type CombineSource = (String, Arc>, Option); - -/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names -/// separate (old fully-qualified name → new one), in order across the sources. -#[derive(Debug)] -pub struct Combined { - pub bytes: Arc>, - pub renamed_fields: Vec<(String, String)>, -} - -/// A bookmark as the bookmark list shows it. -#[derive(Clone, Debug, PartialEq)] -pub struct BookmarkRow { - /// Child indices from the top level, 0-based, as the edit functions take them. - pub path: Vec, - pub title: String, - /// Shown expanded (a positive `/Count`). - pub open: bool, - /// The 0-based page it goes to. - pub page: Option, - /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). - pub view: pdfcraft_render::DestView, -} - -/// One page of the bookmark list, in outline order. -#[derive(Clone, Debug, PartialEq)] -pub struct BookmarkPage { - pub rows: Vec, - /// Where the next page starts, or `None` at the end. - pub next: Option, - /// Set only when the document's structure could not be read for editing: its listing then - /// comes from the viewer's copy of the outline, which is shorter than the file's. - pub truncated: bool, -} - -/// The rows of a viewer outline, in outline order, with their paths. -fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { - let mut rows = Vec::new(); - // One entry per open level: its items, and how many of them have been listed. - let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; - while let Some(&(list, next)) = levels.last() { - let Some(item) = list.get(next) else { - levels.pop(); - continue; - }; - if let Some(top) = levels.last_mut() { - top.1 += 1; - } - let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); - rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page, view: item.view }); - if !item.children.is_empty() { - levels.push((&item.children, 0)); - } - } - rows -} - -/// A document's working file captured for crash recovery. -#[derive(Clone, Debug)] -pub struct RecoverySnapshot { - pub doc: DocId, - pub name: String, - pub path: Option, - pub bytes: Arc>, - /// The snapshot is encrypted (recovering it asks for the password again). - pub encrypted: bool, -} - -/// Document Properties ▸ Security. -#[derive(Clone, Debug, PartialEq)] -pub struct SecuritySummary { - /// "AES, 256-bit" etc. - pub method: String, - /// Opened with the owner password (no restrictions apply). - pub owner: bool, - /// For pending protection: the restrictions as they will apply to others. - pub permissions: pdfcraft_cos::Permissions, - /// Set in this session; written by the next save. - pub pending: bool, -} - -/// What printing a protected document allows (Acrobat: Printing allowed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Printing { - None, - /// "Low Resolution (150 dpi)". - Low, - High, -} - -/// What changes a protected document allows (Acrobat: Changes allowed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Changes { - None, - /// "Inserting, deleting, and rotating pages". - Pages, - /// "Filling in form fields and signing existing signature fields". - FillSign, - /// "Commenting, filling in form fields, and signing existing signature fields". - CommentFillSign, - /// "Any except extracting pages". - AnyExceptExtract, -} - -/// Password protection to apply (Protect Using Password and its Advanced options). -#[derive(Clone, PartialEq)] -pub struct Protection { - /// Required to open the document (the user password). - pub open_password: Option, - /// Required to change security and lift the restrictions below (the owner password). - pub permissions_password: Option, - pub printing: Printing, - pub changes: Changes, - /// "Enable copying of text, images, and other content". - pub copy: bool, - /// "Enable text access for screen reader devices". - pub accessibility: bool, - /// Compatibility level; AES-256 (Acrobat X and later) by default. - pub algorithm: pdfcraft_cos::Algorithm, - /// `false`: "Encrypt all document contents except metadata". - pub encrypt_metadata: bool, -} - -impl std::fmt::Debug for Protection { - // Never print passwords (edits end up in logs and error messages). - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Protection") - .field("open_password", &self.open_password.as_ref().map(|_| "…")) - .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) - .field("printing", &self.printing) - .field("changes", &self.changes) - .field("copy", &self.copy) - .field("accessibility", &self.accessibility) - .field("algorithm", &self.algorithm) - .field("encrypt_metadata", &self.encrypt_metadata) - .finish() - } -} - -impl Default for Protection { - /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. - fn default() -> Self { - Self { - open_password: None, - permissions_password: None, - printing: Printing::High, - changes: Changes::None, - copy: false, - accessibility: true, - algorithm: pdfcraft_cos::Algorithm::Aes256, - encrypt_metadata: true, - } - } -} - -impl Protection { - /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. - pub fn permission_bits(&self) -> i32 { - let bit = |n: u32| 1i32 << (n - 1); - if self.permissions_password.is_none() { - return -1; // nothing restricted without a permissions password - } - let mut p = 0; - match self.printing { - Printing::None => {} - Printing::Low => p |= bit(3), - Printing::High => p |= bit(3) | bit(12), - } - p |= match self.changes { - Changes::None => 0, - Changes::Pages => bit(11), - Changes::FillSign => bit(9), - Changes::CommentFillSign => bit(6) | bit(9), - Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), - }; - if self.copy { - p |= bit(5) | bit(10); - } - if self.accessibility { - p |= bit(10); - } - p - } - - fn validate(&self) -> Result<(), EditError> { - let bad = |m: &str| Err(EditError::Protection(m.to_string())); - match (&self.open_password, &self.permissions_password) { - (None, None) => return bad("enter a password to open the document, a permissions password, or both"), - (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), - _ => {} - } - if [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.is_empty()) { - return bad("passwords can't be empty"); - } - if self.algorithm != pdfcraft_cos::Algorithm::Aes256 - && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) - { - return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); - } - // Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep, - // which drops soft hyphens and folds compatibility forms; older levels: the first 32 - // bytes, padded). One that comes out as the empty password opens the file without asking, - // and an open password that comes out as the permissions password lifts the restrictions. - let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p); - let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective)); - let empty = effective(""); - if open.iter().chain(&permissions).any(|p| *p == empty) { - return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"); - } - if open.is_some() && open == permissions { - return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)"); - } - Ok(()) - } -} - -/// Edits that can be applied to a document. Page indices are 0-based. -#[derive(Clone, Debug, PartialEq)] -pub enum Edit { - RotatePages { - pages: Vec, - degrees: i64, - }, - DeletePages { - pages: Vec, - }, - MovePages { - pages: Vec, - to: usize, - }, - /// `pages` reversed among the positions they occupy. - ReversePages { - pages: Vec, - }, - InsertBlankPage { - at: usize, - width: f64, - height: f64, - }, - /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after - /// the last of them when `at` is `None`. - DuplicatePages { - pages: Vec, - at: Option, - }, - /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the - /// pages' links, comments and bookmarks stay. - ReplacePages { - pages: Vec, - name: String, - bytes: Arc>, - src_pages: Vec, - }, - /// Set Page Boxes / Crop: set (or reset) one box on `pages`. - SetPageBox { - pages: Vec, - which: PageBox, - spec: BoxSpec, - }, - /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), - /// Organize ▸ Page Transitions. - SetTransition { - pages: Vec, - spec: TransitionSpec, - }, - SetInfo { - key: String, - value: String, - }, - /// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`. - InsertPagesFrom { - name: String, - bytes: Arc>, - pages: Option>, - at: usize, - /// How a form field whose name the document already uses is kept separate. - field_conflicts: pdfcraft_organize::FieldConflicts, - }, - /// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level). - AddBookmark { - parent: Vec, - index: usize, - title: String, - page: usize, - }, - RenameBookmark { - path: Vec, - title: String, - }, - /// Delete a bookmark and the bookmarks under it. - DeleteBookmark { - path: Vec, - }, - /// Move a bookmark to child `index` of `to_parent` (indices after removing it). - MoveBookmark { - from: Vec, - to_parent: Vec, - index: usize, - }, - /// Point a bookmark at another page. - SetBookmarkPage { - path: Vec, - page: usize, - }, - /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first - /// "Untitled" bookmark (New Bookmarks from Structure). - BookmarksFromStructure, - /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. - NumberPages { - from: usize, - to: usize, - style: pdfcraft_organize::LabelStyle, - prefix: String, - first: u32, - }, - /// Add a calibrated distance, perimeter or area annotation. - AddMeasurement(measure::NewMeasurement), - /// Store a drawing scale for a rectangular viewport (PDF user space). - SetMeasurementScale { - page: usize, - bbox: [f64; 4], - name: String, - scale: measure::Scale, - }, - /// Add a comment (sticky note, highlight, shape, drawing, text box…). - AddAnnotation(NewAnnotation), - /// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size - /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). - AddCustomStamp { - page: usize, - rect: [f64; 4], - name: String, - file: MarkFile, - author: String, - }, - /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. - DeleteAnnotation { - page: usize, - index: usize, - }, - SetAnnotationContents { - page: usize, - index: usize, - text: String, - }, - ReplyToAnnotation { - page: usize, - index: usize, - text: String, - author: String, - }, - /// Acrobat's "Set status" (a state reply by `author`). - SetAnnotationStatus { - page: usize, - index: usize, - state: ReviewState, - author: String, - }, - /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). - EraseInk { - page: usize, - index: usize, - path: Vec<[f64; 2]>, - radius: f64, - }, - /// Replace Text: strike out `quads` and add a grouped caret holding `text`. - ReplaceText { - page: usize, - quads: Vec<[f64; 8]>, - text: String, - author: String, - strike: Style, - caret: Style, - }, - /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). - MarkAnnotation { - page: usize, - index: usize, - marked: bool, - author: String, - }, - /// Properties ▸ Locked. - LockAnnotation { - page: usize, - index: usize, - locked: bool, - }, - MoveAnnotation { - page: usize, - index: usize, - dx: f64, - dy: f64, - }, - /// Resize a rectangle, oval or text box. - ResizeAnnotation { - page: usize, - index: usize, - rect: [f64; 4], - }, - StyleAnnotation { - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. - endings: Option>, - }, - /// Fill a rectangle, oval or polygon comment, or remove its fill (`None`). - FillAnnotation { - page: usize, - index: usize, - fill: Option, - }, - /// Change the font size (pt) of a text box, typewriter text or callout. - FontSizeAnnotation { - page: usize, - index: usize, - size: f64, - }, - /// Comment properties ▸ General / note icon. - SetAnnotationInfo { - page: usize, - index: usize, - author: Option, - subject: Option, - icon: Option, - }, - /// Fill in a form field. - SetFieldValue { - name: String, - value: FieldValue, - }, - /// Clear form: the named fields (all when `None`) back to their defaults. - ResetForm { - names: Option>, - }, - /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to - /// Acrobat's next free name ("Text1", "Check Box2", …). - AddField { - page: usize, - rect: [f64; 4], - kind: NewField, - name: Option, - }, - /// Field Properties ▸ General / Options. - SetFieldProps { - name: String, - props: Box, - }, - /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). - SetFieldImage { - name: String, - image: Arc>, - }, - /// Duplicate a field onto other pages (same field, shared value). - DuplicateField { - name: String, - pages: Vec, - }, - DeleteField { - name: String, - }, - /// Page properties ▸ Tab order, for pages (0-based). - SetTabOrder { - pages: Vec, - order: TabOrder, - }, - /// Document Properties ▸ Initial View (and the language and binding). - SetInitialView(Box), - /// Build a tagged structure tree for an untagged, text-only document. - AutoTagText, - /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is - /// the figure element's object number (from `Document::figures`). - SetAltText { - figure: u32, - alt: Option, - }, - /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. - AutoTagFormFields, - /// Change one property on a reachable indirect structure tag. - SetTagProperty { - object: pdfcraft_cos::ObjRef, - property: a11y::TagProperty, - value: Option, - }, - /// Move a structure tag before or after a direct sibling in reading order. - MoveTag { - object: pdfcraft_cos::ObjRef, - anchor: pdfcraft_cos::ObjRef, - placement: a11y::TagPlacement, - }, - /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. - MarkDecorative { - figure: u32, - }, - /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. - MarkTagArtifact { - object: pdfcraft_cos::ObjRef, - }, - /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level - /// script `name`. - SetDocumentScript { - name: String, - script: Option, - }, - /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` - /// (keystroke, format, validate, calculate or mouse_up). - SetFieldScript { - name: String, - event: String, - script: Option, - }, - /// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent, - /// forbidden actions, annotation flags, …). - ConvertPdfA { - level: pdfcraft_preflight::Level, - }, - /// What a script (button or console) changed in form fields: values, read-only, required - /// and visibility. - ApplyScriptChanges { - changes: Vec, - }, - /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the - /// script changes (values, rows, visibility) is applied and the form laid out again. - XfaEvent { - som: String, - activity: String, - }, - /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the - /// image (from [`ocr::OcrJob::run`]). - AddOcrText { - page: usize, - words: Vec, - }, - /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on - /// `page`, in its own font when it can show it, else in Helvetica. - EditTextLine { - page: usize, - line: usize, - text: String, - }, - /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). - EditPageImage { - page: usize, - index: usize, - change: ImageEdit, - }, - /// Move mixed existing and added objects together. References resolve before any change; - /// offset is a user-space vector. No retyping, reflow or font substitution is involved. - MoveObjects { - page: usize, - objects: Vec, - offset: [f64; 2], - }, - /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), - /// rewrapped to the box's width. - EditTextBlock { - page: usize, - block: usize, - text: String, - /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. - style: pdfcraft_edit::BlockStyle, - }, - /// An explicit open-licensed full font program, completing subsets or selected by a user. - RegisterTextFont { - page: usize, - bytes: Arc>, - }, - /// Order tabs manually: move a field one place earlier or later on its page. - MoveInTabOrder { - name: String, - earlier: bool, - }, - /// Add a header and footer (with `replace`, existing ones on those pages go first). - AddHeaderFooter { - pages: Vec, - settings: HeaderFooter, - replace: bool, - }, - /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. - AddWatermark { - pages: Vec, - settings: Watermark, - replace: bool, - file: Option, - }, - /// `file`: a picture from a file instead of a colour. - AddBackground { - pages: Vec, - settings: Background, - replace: bool, - file: Option, - }, - /// Remove every mark of a kind from every page. - RemoveMarks { - kind: MarkKind, - }, - /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). - AddText { - page: usize, - text: AddedText, - }, - /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at - /// its natural size centred on the page (shrunk to fit). - AddImage { - page: usize, - rect: Option<[f64; 4]>, - name: String, - bytes: Arc>, - }, - /// Move, resize, retype or reformat an added item (`index` among the page's added items). - UpdateContent { - page: usize, - index: usize, - content: AddedContent, - }, - DeleteContent { - page: usize, - index: usize, - }, - /// Replace an added image with another file (keeping its box and transforms). - ReplaceImage { - page: usize, - index: usize, - name: String, - bytes: Arc>, - }, - /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. - ApplyRedactions { - pages: Option>, - }, - /// Remove redaction marks without applying them. - ClearRedactions, - /// Edit a PDF ▸ Link: a new link over `rect` (user space). - AddLink { - page: usize, - rect: [f64; 4], - action: LinkAction, - style: LinkStyle, - }, - /// Link Properties (`index` in the page's `/Annots`). - SetLink { - page: usize, - index: usize, - rect: Option<[f64; 4]>, - action: Option, - style: Option, - }, - DeleteLink { - page: usize, - index: usize, - }, - /// Remove all links (on `pages`, or everywhere). - RemoveLinks { - pages: Option>, - }, - /// Create links from URLs in the text: (page, line rects in user space, URI). - AddLinks { - links: Vec<(usize, Vec<[f64; 4]>, String)>, - style: LinkStyle, - }, - /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). - ImportData { - name: String, - bytes: Arc>, - }, - /// Remove Hidden Information: the chosen categories. - RemoveHidden { - which: Vec, - }, - /// Sanitize Document: every category, then a full rewrite on save. - Sanitize, - /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and - /// PdfCraft's private keys go, the pages as laid out and the fields with their values - /// stay, so every viewer shows the same form and none lays it out again. Its scripts no - /// longer run. - XfaToStatic, - /// Flatten comments and/or form fields on every page into page content. - Flatten { - comments: bool, - fields: bool, - }, - /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. - /// A no-op (no undo step) when the document has none. - FlattenFillSign, - /// Protect with passwords and permissions (written by the next save, which is a full rewrite). - Protect(Protection), - /// Remove password security (needs the owner password). - RemoveProtection, - /// Several edits applied as one undoable step (all or nothing). - Batch { - label: String, - edits: Vec, - }, -} - -/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: -/// a whole English wording, and the one value the document itself supplies for the wording's hole -/// (a file name, a field name). An interface in another language looks the wording up and says the -/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so -/// the two cannot drift apart. -#[derive(Clone, Debug, PartialEq)] -pub struct Label { - /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. - pub wording: &'static str, - /// What the document supplies, when the wording has a hole for it. - pub value: Option, - text: String, -} - -impl Label { - /// A wording with nothing to fill in. - pub fn of(wording: &'static str) -> Self { - Self { wording, value: None, text: wording.to_string() } - } - - /// A wording whose hole the document fills in. - pub fn with(wording: &'static str, value: impl Into) -> Self { - let mut label = Self { wording, value: Some(value.into()), text: String::new() }; - label.text = label.said(wording); - label - } - - /// The label read with this wording: the interface's translation of [`wording`](Self::wording), - /// or the English wording itself. What the document supplies goes in as it stands and is not - /// read again, so a name with braces in it stays whole. - pub fn said(&self, wording: &str) -> String { - let Some(value) = &self.value else { return wording.to_owned() }; - // `{` and `}` are ASCII, so these are char boundaries. - match (wording.find('{'), wording.find('}')) { - (Some(open), Some(close)) if open < close => { - let mut said = String::with_capacity(wording.len() + value.len()); - said.push_str(&wording[..open]); - said.push_str(value); - said.push_str(&wording[close + 1..]); - said - } - // A translation that lost its hole is said as it stands rather than losing the value. - _ => wording.to_owned(), - } - } - - /// The label as English reads it. - pub fn as_str(&self) -> &str { - &self.text - } -} - -impl std::fmt::Display for Label { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.text) - } -} - -/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not -/// here: its wording is only the hole, because a batch carries its name as text (see -/// [`BATCH_LABELS`]). -pub const EDIT_LABELS: &[&str] = &[ - "Rotate page", - "Change tag properties", - "Rotate pages", - "Delete page", - "Delete pages", - "Move page", - "Move pages", - "Reverse pages", - "Insert blank page", - "Duplicate page", - "Duplicate pages", - "Replace page", - "Replace pages", - "Crop page", - "Crop pages", - "Set page boxes", - "Set page transitions", - "Change Title", - "Change Author", - "Change Subject", - "Change Keywords", - "Change {key}", - "Insert pages from {name}", - "Insert pages from {name} — 1 field renamed", - "Insert pages from {name} — fields renamed", - "Add bookmark", - "Rename bookmark", - "Delete bookmark", - "Move bookmark", - "Set bookmark destination", - "New bookmarks from structure", - "Number pages", - "Measure distance", - "Measure perimeter", - "Measure area", - "Set measurement scale", - "Add sticky note", - "Add highlight", - "Add underline", - "Add strikethrough", - "Add squiggly underline", - "Add rectangle", - "Add oval", - "Add arrow", - "Add line", - "Add drawing", - "Add text box", - "Add checkmark", - "Add cross", - "Add dot", - "Add signature", - "Add redaction mark", - "Add cloud", - "Add polygon", - "Add connected lines", - "Add callout", - "Add inserted text", - "Add file attachment", - "Add stamp", - "Delete comment", - "Edit comment", - "Reply", - "Set status None", - "Set status Accepted", - "Set status Rejected", - "Set status Cancelled", - "Set status Completed", - "Replace text", - "Erase", - "Mark with checkmark", - "Remove checkmark", - "Lock comment", - "Unlock comment", - "Move comment", - "Resize comment", - "Change comment properties", - "Fill in {name}", - "Set the image of {name}", - "Clear form", - "Add field", - "Change field properties", - "Delete field", - "Duplicate field", - "Set tab order", - "Change initial view", - "Set alternate text", - "Mark figure as decorative", - "Recognize text", - "Run JavaScript", - "Run form script", - "Save as {level}", - "Edit script of {name}", - "Delete document JavaScript", - "Edit document JavaScript", - "Edit text", - "Load document font", - "Move object", - "Move objects", - "Move image", - "Rotate image", - "Flip image", - "Replace image", - "Delete image", - "Add header & footer", - "Update header & footer", - "Add watermark", - "Update watermark", - "Add background", - "Update background", - "Remove header & footer", - "Remove watermark", - "Remove background", - "Add text", - "Add image", - "Edit content", - "Delete content", - "Apply redactions", - "Remove redaction marks", - "Remove hidden information", - "Import {name}", - "Add link", - "Change link properties", - "Delete link", - "Remove all links", - "Create link", - "Create links", - "Sanitize document", - "Flatten comments", - "Flatten form fields", - "Flatten", - "Flatten Fill & Sign", - "Protect with password", - "Remove security", -]; - -/// The names of the batches built below L7, which an interface shows in the same places as -/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives -/// already worded in the interface's language. -pub const BATCH_LABELS: &[&str] = &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text"]; - -impl Edit { - /// Label for the Edit menu and history ("Undo Rotate pages"). - pub fn label(&self) -> Label { - match self { - Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), - Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), - Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), - Edit::ReversePages { .. } => Label::of("Reverse pages"), - Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), - Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), - Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), - Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), - Edit::SetPageBox { .. } => Label::of("Set page boxes"), - Edit::SetTransition { .. } => Label::of("Set page transitions"), - // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can - // be said in another language; any other key is the document's and goes in as it stands. - Edit::SetInfo { key, .. } => match key.as_str() { - "Title" => Label::of("Change Title"), - "Author" => Label::of("Change Author"), - "Subject" => Label::of("Change Subject"), - "Keywords" => Label::of("Change Keywords"), - _ => Label::with("Change {key}", key.as_str()), - }, - Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), - Edit::AddBookmark { .. } => Label::of("Add bookmark"), - Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), - Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), - Edit::MoveBookmark { .. } => Label::of("Move bookmark"), - Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), - Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), - Edit::NumberPages { .. } => Label::of("Number pages"), - // What was measured is part of the wording too, for the same reason. - Edit::AddMeasurement(m) => Label::of(match m.kind { - pdfcraft_measure::Kind::Distance => "Measure distance", - pdfcraft_measure::Kind::Perimeter => "Measure perimeter", - pdfcraft_measure::Kind::Area => "Measure area", - }), - Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), - Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), - Edit::AddCustomStamp { .. } => Label::of("Add stamp"), - Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), - Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), - Edit::ReplyToAnnotation { .. } => Label::of("Reply"), - // The state's name is part of the wording: a language may need to agree it with the rest. - Edit::SetAnnotationStatus { state, .. } => Label::of(match state { - ReviewState::None => "Set status None", - ReviewState::Accepted => "Set status Accepted", - ReviewState::Rejected => "Set status Rejected", - ReviewState::Cancelled => "Set status Cancelled", - ReviewState::Completed => "Set status Completed", - }), - Edit::ReplaceText { .. } => Label::of("Replace text"), - Edit::EraseInk { .. } => Label::of("Erase"), - Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), - Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), - Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"), - Edit::LockAnnotation { .. } => Label::of("Unlock comment"), - Edit::MoveAnnotation { .. } => Label::of("Move comment"), - Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { - Label::of("Change comment properties") - } - Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), - Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), - Edit::ResetForm { .. } => Label::of("Clear form"), - Edit::AddField { .. } => Label::of("Add field"), - Edit::SetFieldProps { .. } => Label::of("Change field properties"), - Edit::DeleteField { .. } => Label::of("Delete field"), - Edit::DuplicateField { .. } => Label::of("Duplicate field"), - Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), - Edit::SetInitialView(_) => Label::of("Change initial view"), - Edit::AutoTagText => Label::of("Automatically tag text"), - Edit::SetAltText { .. } => Label::of("Set alternate text"), - Edit::AutoTagFormFields => Label::of("Tag form fields"), - Edit::SetTagProperty { .. } => Label::of("Change tag properties"), - Edit::MoveTag { .. } => Label::of("Move object"), - Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), - Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), - Edit::AddOcrText { .. } => Label::of("Recognize text"), - Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), - Edit::XfaEvent { .. } => Label::of("Run form script"), - // The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands. - Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()), - Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()), - Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"), - Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"), - Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), - Edit::RegisterTextFont { .. } => Label::of("Load document font"), - Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), - Edit::EditPageImage { change, .. } => Label::of(match change { - ImageEdit::Move(_) => "Move image", - ImageEdit::Rotate(_) => "Rotate image", - ImageEdit::Crop(_) => "Crop image", - ImageEdit::Flip { .. } => "Flip image", - ImageEdit::Replace { .. } => "Replace image", - ImageEdit::Delete => "Delete image", - }), - Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), - Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), - Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), - Edit::AddWatermark { .. } => Label::of("Update watermark"), - Edit::AddBackground { replace: false, .. } => Label::of("Add background"), - Edit::AddBackground { .. } => Label::of("Update background"), - Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), - Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), - Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), - Edit::AddText { .. } => Label::of("Add text"), - Edit::AddImage { .. } => Label::of("Add image"), - Edit::UpdateContent { .. } => Label::of("Edit content"), - Edit::DeleteContent { .. } => Label::of("Delete content"), - Edit::ReplaceImage { .. } => Label::of("Replace image"), - Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), - Edit::ClearRedactions => Label::of("Remove redaction marks"), - Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), - Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), - Edit::AddLink { .. } => Label::of("Add link"), - Edit::SetLink { .. } => Label::of("Change link properties"), - Edit::DeleteLink { .. } => Label::of("Delete link"), - Edit::RemoveLinks { .. } => Label::of("Remove all links"), - Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), - Edit::Sanitize => Label::of("Sanitize document"), - Edit::XfaToStatic => Label::of("Convert to a static form"), - Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), - Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), - Edit::Flatten { .. } => Label::of("Flatten"), - Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), - Edit::Protect(_) => Label::of("Protect with password"), - Edit::RemoveProtection => Label::of("Remove security"), - // A batch carries its own name, already worded by whoever built it. - // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). - Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), - } - } -} - -/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, -/// so a language can give the noun the article and the gender its own grammar asks for. -fn add_annotation(s: &Shape) -> &'static str { - match s { - Shape::Note { .. } => "Add sticky note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", - Shape::Rectangle { .. } => "Add rectangle", - Shape::Oval { .. } => "Add oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", - Shape::Line { .. } => "Add line", - Shape::Ink { .. } => "Add drawing", - Shape::TextBox { .. } => "Add text box", - Shape::Typewriter { .. } => "Add text", - Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", - Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", - Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", - Shape::Mark { mark: FillMark::Line, .. } => "Add line", - Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", - Shape::Redact { .. } => "Add redaction mark", - Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", - Shape::Polygon { cloud: true, .. } => "Add cloud", - Shape::Polygon { .. } => "Add polygon", - Shape::PolyLine { .. } => "Add connected lines", - Shape::Callout { .. } => "Add callout", - Shape::Caret { .. } => "Add inserted text", - Shape::Attachment { .. } => "Add file attachment", - } -} - -/// Opened as owner, or nothing is restricted (no permissions password was set): security may -/// be changed, as in Acrobat. -fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { - const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 - p.owner || p.bits & ALL == ALL -} - -/// Whether the opening password allows an edit (§7.6.4.2, Table 22). -fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { - match edit { - Edit::RotatePages { .. } - | Edit::DeletePages { .. } - | Edit::MovePages { .. } - | Edit::ReversePages { .. } - | Edit::InsertBlankPage { .. } - | Edit::DuplicatePages { .. } - | Edit::ReplacePages { .. } - | Edit::SetPageBox { .. } - | Edit::SetTransition { .. } - | Edit::InsertPagesFrom { .. } - // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). - | Edit::AddBookmark { .. } - | Edit::RenameBookmark { .. } - | Edit::DeleteBookmark { .. } - | Edit::MoveBookmark { .. } - | Edit::SetBookmarkPage { .. } - | Edit::BookmarksFromStructure - | Edit::NumberPages { .. } => { - if p.assemble() { - Ok(()) - } else { - Err(EditError::NotPermitted("page changes")) - } - } - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::LockAnnotation { .. } - | Edit::MoveAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } - | Edit::SetMeasurementScale { .. } => { - if p.annotate() { - Ok(()) - } else { - Err(EditError::NotPermitted("comments")) - } - } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { - if p.fill_forms() { - Ok(()) - } else { - Err(EditError::NotPermitted("filling in form fields")) - } - } - Edit::Protect(_) | Edit::RemoveProtection => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("changing security")) - } - } - Edit::SetInfo { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } - | Edit::AddField { .. } - | Edit::ApplyRedactions { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::MoveObjects { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::ClearRedactions - | Edit::RemoveHidden { .. } - | Edit::ImportData { .. } - | Edit::AddLink { .. } - | Edit::SetLink { .. } - | Edit::DeleteLink { .. } - | Edit::RemoveLinks { .. } - | Edit::AddLinks { .. } - | Edit::Sanitize - | Edit::XfaToStatic - | Edit::SetFieldProps { .. } - | Edit::DeleteField { .. } - | Edit::DuplicateField { .. } - | Edit::SetTabOrder { .. } - | Edit::MoveInTabOrder { .. } - | Edit::SetInitialView(_) - | Edit::AutoTagText - | Edit::SetAltText { .. } - | Edit::AutoTagFormFields - | Edit::SetTagProperty { .. } - | Edit::MoveTag { .. } - | Edit::MarkDecorative { .. } - | Edit::MarkTagArtifact { .. } - | Edit::AddOcrText { .. } - | Edit::SetDocumentScript { .. } - | Edit::SetFieldScript { .. } - | Edit::ConvertPdfA { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::RegisterTextFont { .. } - | Edit::EditPageImage { .. } - | Edit::Flatten { .. } - | Edit::FlattenFillSign => { - if p.modify() { - Ok(()) - } else { - Err(EditError::NotPermitted("changes to the document")) - } - } - Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), - } -} - -/// Dates and unique ids stamped onto what an edit creates. -struct EditCtx { - /// Field scripts run (`None`: JavaScript is off) and what they produced. - js: Option, - /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what - /// its scripts produced. - xfa: Option>, - xfa_out: js::JsOutput, - /// The datasets stream this edit wrote (later writes in the edit replace it in place). - xfa_datasets: Option, - /// A form script ran too long and was abandoned: the document's scripts go off. - xfa_ran_away: bool, - /// The edit took the XFA out of the form: what went with it, for the document's warnings. - xfa_removed: Vec, - /// Pages in the document, for `xfa.layout.pageCount()`. - pages: usize, - date: Option, - /// Today in local time, for date tokens. - today: (i64, u32, u32), - seed: u64, - count: u64, - /// Fields the edit renamed to keep imported names separate (old → new), for the repair - /// log and the undo label. - renamed_fields: Vec<(String, String)>, -} - -impl EditCtx { - fn new(now: Option, salt: u64) -> Self { - let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; - if let Some(t) = now { - seed ^= (t as u64).rotate_left(17); - } - #[cfg(not(target_arch = "wasm32"))] - if now.is_some() { - // Real clock: mix in sub-second time so ids from two sessions don't collide. - seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; - } - Self { - js: None, - xfa: None, - xfa_out: Default::default(), - xfa_datasets: None, - xfa_ran_away: false, - xfa_removed: Vec::new(), - pages: 0, - date: now.map(pdfcraft_cos::pdf_date), - today: (1970, 1, 1), - seed, - count: 0, - renamed_fields: Vec::new(), - } - } - - /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the - /// operating system, so no extra dependency is needed. - fn entropy(&mut self) -> [u8; 32] { - use std::hash::{BuildHasher, Hasher}; - let mut out = [0u8; 32]; - for (i, chunk) in out.chunks_mut(8).enumerate() { - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(self.seed ^ i as u64); - h.write_u64(self.count); - self.count += 1; - chunk.copy_from_slice(&h.finish().to_le_bytes()); - } - out - } - - /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. - fn meta(&mut self) -> pdfcraft_annot::Meta { - let mut next = || { - self.count += 1; - let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - }; - let (a, b) = (next(), next()); - let id = format!( - "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", - a >> 32, - (a >> 16) & 0xffff, - a & 0xfff, - 0x8000 | (b >> 48) & 0x3fff, - b & 0xffff_ffff_ffff - ); - pdfcraft_annot::Meta { date: self.date.clone(), id } - } -} - -/// Perform an edit on a working copy (the caller discards it on error). -fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { - match edit { - Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, - Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, - Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, - Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, - Edit::InsertBlankPage { at, width, height } => { - pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; - } - Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, - Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, - Edit::ReplacePages { pages, name, bytes, src_pages } => { - let src = open_source(name, bytes)?; - pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; - } - Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, - Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, - Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { - let src = open_source(name, bytes)?; - let pages = match pages { - Some(p) => p.clone(), - None => (0..pdfcraft_organize::page_count(&src)?).collect(), - }; - // The file's bookmarks come along under one named after it, as in Combine Files. - let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); - let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; - cx.renamed_fields = outcome.renamed_fields; - } - Edit::AddBookmark { parent, index, title, page } => { - pdfcraft_organize::add_bookmark(doc, parent, *index, title, *page)?; - } - Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?, - Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?, - Edit::MoveBookmark { from, to_parent, index } => { - pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; - } - Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, - Edit::BookmarksFromStructure => { - let entries: Vec<_> = pdfcraft_a11y::headings(doc) - .into_iter() - .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) - .collect(); - pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; - } - Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, - Edit::AddMeasurement(m) => { - measure::add(doc, m, &cx.meta())?; - } - Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, - Edit::AddAnnotation(a) => { - pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; - } - Edit::AddCustomStamp { page, rect, name, file, author } => { - let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); - let natural = rect[2] == rect[0] && rect[3] == rect[1]; - let rotation = pdfcraft_organize::page_rotation(doc, *page)?; - let rect = if natural { - let k = (200.0 / sw.max(sh)).min(1.0); - let (w, h) = if matches!(rotation, 90 | 270) { (sh * k, sw * k) } else { (sw * k, sh * k) }; - [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] - } else { - *rect - }; - let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; - let style = pdfcraft_annot::Style::default_for(&shape); - let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; - let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; - pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; - } - Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, - Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, - Edit::ReplyToAnnotation { page, index, text, author } => { - pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; - } - Edit::SetAnnotationStatus { page, index, state, author } => { - pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; - } - Edit::EraseInk { page, index, path, radius } => { - pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; - } - Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by - // the strikeout's `/NM` (#2666). - let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; - } - Edit::MarkAnnotation { page, index, marked, author } => { - pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; - } - Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, - Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, - Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, - Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; - } - Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, - Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, - Edit::SetAnnotationInfo { page, index, author, subject, icon } => { - pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; - } - Edit::SetFieldValue { name, value } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js)?, - None => pdfcraft_forms::set_value(doc, name, value)?, - }, - Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, - None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, - }, - Edit::XfaEvent { som, activity } => { - let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; - let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - ran.map_err(EditError::Invalid)?; - } - Edit::SetFieldImage { name, image } => { - let (img, _) = embed_image(doc, name, image)?; - let px = match &*doc.get(img) { - pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), - _ => (1, 1), - }; - // An XFA image field keeps the picture in the data too (as Designer saves it), and a - // laid-out form shows it from there: so only pictures the layout can show, and only - // for image fields (a laid-out one carries its SOM path; a static form's field name - // is its path). - let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); - let image_field = is_xfa - && pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon)); - if image_field { - let Some(content_type) = image_content_type(image) else { - return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG or GIF picture; choose one of those"))); - }; - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone()); - let path = match cx.xfa.as_deref() { - Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), - None => pdfcraft_xfa::som_to_path(&som), - }; - let r = - pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; - if !r.written && r.warnings.is_empty() { - return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); - } - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - xfa_after_data_change(doc, cx, false)?; - } else { - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - } - } - Edit::ResetForm { names } => { - pdfcraft_forms::reset(doc, names.as_deref())?; - } - Edit::AddField { page, rect, kind, name } => { - pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; - } - Edit::SetFieldProps { name, props } => { - pdfcraft_forms::set_props(doc, name, props)?; - } - Edit::DuplicateField { name, pages } => { - if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { - return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); - } - } - Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, - Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, - Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, - Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, - Edit::AutoTagText => { - a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::SetAltText { figure, alt } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AutoTagFormFields => { - a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; - } - Edit::SetTagProperty { object, property, value } => { - a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MoveTag { object, anchor, placement } => { - a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::EditTextLine { page, line, text } => { - pdfcraft_edit::replace_line(doc, *page, *line, text)?; - } - Edit::SetFieldScript { name, event, script } => { - pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - Edit::ConvertPdfA { level } => { - pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?; - } - Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?, - Edit::AddOcrText { page, words } => { - pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; - } - Edit::EditPageImage { page, index, change } => { - let img = pdfcraft_edit::page_images(doc, *page)? - .into_iter() - .nth(*index) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; - let c = match change { - ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - ImageEdit::Rotate(q) => { - pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) - } - ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( - img.visible_rect.unwrap_or(img.rect), - 0, - *horizontal, - !*horizontal, - )), - ImageEdit::Replace { name, bytes } => { - if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); - } - pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) - } - ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), - ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, - }; - pdfcraft_edit::change_image(doc, *page, *index, &c)?; - } - Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, - Edit::EditTextBlock { page, block, text, style } => { - pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; - } - Edit::RegisterTextFont { page, bytes } => { - pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; - } - Edit::MarkDecorative { figure } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MarkTagArtifact { object } => { - a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AddHeaderFooter { pages, settings, replace } => { - let date = cx.today; - pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; - } - Edit::AddWatermark { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? - } - Edit::AddBackground { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_background(doc, pages, &s, *replace)? - } - Edit::RemoveMarks { kind } => { - let n = pdfcraft_model::pages(doc).len(); - if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); - } - } - Edit::AddText { page, text } => { - pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; - } - Edit::AddImage { page, rect, name, bytes } => { - let (image, natural) = embed_image(doc, name, bytes)?; - let rect = match rect { - Some(r) => *r, - None => { - let p = pdfcraft_model::pages(doc).swap_remove(*page); - let (pw, ph) = p.display_size(doc); - let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); - let (w, h) = (natural.0 * k, natural.1 * k); - [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] - } - }; - pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; - } - Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, - Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, - Edit::ReplaceImage { page, index, name, bytes } => { - let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); - let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); - }; - let (image, _) = embed_image(doc, name, bytes)?; - pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; - } - Edit::ApplyRedactions { pages } => { - pdfcraft_redact::apply(doc, pages.as_deref())?; - } - Edit::ClearRedactions => { - pdfcraft_redact::clear_marks(doc, None)?; - } - Edit::AddLink { page, rect, action, style } => { - pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; - } - Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, - Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, - Edit::RemoveLinks { pages } => { - if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); - } - } - Edit::AddLinks { links, style } => { - if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); - } - } - Edit::ImportData { bytes, .. } => { - // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; - // XFDF, FDF, Acrobat's XML and tables go the usual way. - let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); - match text { - Some(Ok(text)) if is_xfa_data_file(&text) => { - let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); - let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) - .map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); - xfa_after_data_change(doc, cx, true)?; - } - Some(Err(why)) => return Err(EditError::Invalid(why)), - _ => { - pdfcraft_xfdf::import(doc, bytes)?; - } - } - } - Edit::RemoveHidden { which } => { - pdfcraft_redact::sanitize::remove_hidden(doc, which)?; - } - Edit::Sanitize => { - pdfcraft_redact::sanitize::sanitize(doc)?; - } - Edit::Flatten { comments, fields } => { - // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers - // would lay the form out again from its template over the baked pages. A dynamic - // form that was never laid out keeps its XFA: without it only placeholders remain. - let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; - match shape { - Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, - Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), - None => {} - } - } - Edit::XfaToStatic => { - let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { - return Err(EditError::Invalid("this document is not an XFA form".into())); - }; - if !shape.strippable() { - return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); - } - strip_xfa(doc, cx)?; - } - Edit::FlattenFillSign => { - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; - } - Edit::Protect(p) => { - p.validate()?; - let seed = cx.entropy(); - // Without a permissions password nothing is restricted, so the owner password is a - // random one nobody needs. - let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); - let params = pdfcraft_cos::NewEncryption { - algorithm: p.algorithm, - user_password: p.open_password.as_deref().unwrap_or(""), - owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), - permissions: p.permission_bits(), - encrypt_metadata: p.encrypt_metadata, - seed, - }; - doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; - } - Edit::RemoveProtection => { - if doc.output_handler().is_none() { - return Err(EditError::Protection("the document isn't protected".into())); - } - doc.remove_encryption(); - } - Edit::Batch { edits, .. } => { - for e in edits { - run_edit(doc, e, cx)?; - } - } - } - Ok(()) -} - -/// The comment list, read from the object graph (as `inspect` lists comments). -fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { - pdfcraft_annot::summaries(doc) - .into_iter() - .map(|s| pdfcraft_render::Annotation { - page: s.page, - subtype: s.subtype, - author: s.author, - contents: s.contents, - modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)), - name: s.name, - in_reply_to: s.in_reply_to, - rect: s.rect, - color: s.color, - index: s.index, - state: s.state, - quads: s.quads, - locked: s.locked, - intent: s.intent, - fill_sign: s.fill_sign, - }) - .collect() -} - -/// Seconds to add to UTC for local time (0 where unknown). -fn local_utc_offset() -> i64 { - #[cfg(not(target_arch = "wasm32"))] - return i64::from(chrono::Local::now().offset().local_minus_utc()); - #[cfg(target_arch = "wasm32")] - return 0; -} - -/// (year, month, day) from a PDF date `D:YYYYMMDD…`. -fn parse_ymd(d: &str) -> Option<(i64, u32, u32)> { - let d = d.strip_prefix("D:").unwrap_or(d); - Some((d.get(0..4)?.parse().ok()?, d.get(4..6)?.parse().ok()?, d.get(6..8)?.parse().ok()?)) -} - -/// The passwords after `edit`, if it changes them. -fn keys_after(edit: &Edit) -> Option { - match edit { - Edit::Protect(p) => { - Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) - } - Edit::RemoveProtection => Some(Keys::default()), - Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), - _ => None, - } -} - -/// The recovered user password of an R2–R4 file as text for the renderer, which tries a password -/// as its UTF-8 bytes and then in PDFDocEncoding: UTF-8 bytes (as some writers store passwords -/// PDFDocEncoding can't hold, such as "şifre") stay UTF-8, and other bytes are read as -/// PDFDocEncoding, so either way the renderer gets back exactly these bytes. -fn renderer_password(bytes: &[u8]) -> String { - match std::str::from_utf8(bytes) { - Ok(text) => text.to_owned(), - Err(_) => bytes.iter().map(|b| pdfcraft_cos::pdfdoc_char(*b)).collect(), - } -} - -/// The last-resort guard (AGENTS.md §4): run `f`, turning a panic that escapes it into an error -/// message, so one bad file or edit can't take the app and its other documents down. It is a -/// safety net for bugs, not a substitute for returning errors. -pub fn guard(f: impl FnOnce() -> T) -> Result { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|p| { - p.downcast_ref::<&str>().map(|s| (*s).to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "unknown error".into()) - }) -} - -/// Parse another PDF to copy pages from. -fn open_source(name: &str, bytes: &Arc>) -> Result { - open_source_with(name, bytes, None) -} - -/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file. -fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> Result { - source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) -} - -/// Why a file can't be a source for Combine Files (or Insert / Replace pages). -#[derive(Debug, Clone, PartialEq, thiserror::Error)] -pub enum SourceProblem { - #[error("it is password-protected")] - Password, - #[error("the password is wrong")] - WrongPassword, - #[error("its security settings don't allow copying pages")] - NotPermitted, - #[error("{0}")] - Unreadable(String), -} - -fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { - match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { - Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), - Ok(Ok(d)) => Ok(d), - Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), - Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), - Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), - Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), - } -} - -/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not -/// why: what Combine would refuse it for, so the Combine files list can say so before Combine -/// is pressed. The owner (permissions) password lifts the restriction on copying pages. -pub fn combine_source_check(bytes: &Arc>, password: Option<&str>) -> Result<(), SourceProblem> { - source_document(bytes, password).map(|_| ()) -} - -/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever -/// its permissions allow; `None` when it can't be read. -pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option { - let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?; - pdfcraft_organize::page_count(&doc).ok() -} - -/// The singular or the plural wording, by count. Both are written out: a language forms its plural -/// its own way, and `one` and `many` may differ in more than an "s". -fn plural(one: &'static str, many: &'static str, n: usize) -> Label { - Label::of(if n == 1 { one } else { many }) -} - -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum EditError { - #[error("no such document")] - NoDocument, - #[error("this document can't be edited: {0}")] - ReadOnly(String), - #[error("the document's security settings don't allow {0}; open it with the owner password to make this change")] - NotPermitted(&'static str), - #[error("{0}")] - Organize(#[from] pdfcraft_organize::OrganizeError), - #[error("{0}")] - Bookmark(#[from] pdfcraft_organize::OutlineError), - #[error("{0}")] - Comment(#[from] pdfcraft_annot::AnnotError), - #[error(transparent)] - Measure(#[from] measure::MeasureError), - #[error("{0}")] - Protection(String), - #[error("the password is incorrect")] - WrongPassword, - #[error("{0}")] - Form(#[from] pdfcraft_forms::FormError), - #[error(transparent)] - Redact(#[from] pdfcraft_redact::RedactError), - #[error("{0}")] - Print(String), - #[error("{0}")] - Accessibility(String), - #[error(transparent)] - Data(#[from] pdfcraft_xfdf::DataError), - #[error("{0}")] - Edit(#[from] pdfcraft_edit::EditError), - #[error("{0}")] - Create(#[from] pdfcraft_create::CreateError), - #[error("the edited document could not be written: {0}")] - Write(String), - #[error("the edited document could not be reopened: {0}")] - Reopen(String), - #[error("couldn't use {0}")] - Source(String), - #[error("nothing to undo")] - NothingToUndo, - #[error("nothing to redo")] - NothingToRedo, - #[error("{0}")] - Sign(String), - #[error("{0}")] - Optimize(String), - #[error("cancelled")] - Cancelled, - #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] - SignedRewrite(String), - #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] - Signed, - #[error("{0}")] - Invalid(String), -} - -impl From for EditError { - fn from(e: pdfcraft_sign::SignError) -> Self { - EditError::Sign(e.to_string()) - } -} - -#[derive(Default)] -pub struct Session { - docs: Vec, - next_id: u64, - /// Seconds since the Unix epoch, injected so saves are deterministic in tests. - clock: Option i64>, - /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). - /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the - /// injected time AS local time and zeroes the zone, so injecting only it leaves either the - /// local dates or the UTC ones wrong by the zone's offset. - utc_offset: Option i64>, - /// Certificates trusted for signing (Acrobat: Trusted Certificates). - trust: Arc, - /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). - js_off: bool, - /// Preferences ▸ Date format, when not the default (see [`dates`]). - date_format: Option, - /// Preferences ▸ Date format ▸ Language, when not following the interface language. - date_language: Option, -} - -/// Immutable session state needed while a document is prepared away from its owning thread. -#[derive(Clone)] -pub struct OpenContext { - now: Option, - js_off: bool, - trust: Arc, -} - -/// A fully prepared document that has not yet been registered in a session. -pub struct PreparedDocument { - document: Document, -} - -impl OpenContext { - /// Prepare a document without mutating the session that supplied this context. - pub fn prepare_open( - &self, - name: impl Into, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - let name = name.into(); - guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) - .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) - } -} - -/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. -fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { - let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; - xfa_redraw(doc)?; - // The form's own values: one its field's font can't draw is drawn with `?` and reported. - report.warnings.extend(undrawn_field_warnings(doc)); - Ok(report) -} - -/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the -/// picture its signer's application drew. -pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { - for f in pdfcraft_forms::fields(doc) { - if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { - continue; - } - pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } - Ok(()) -} - -/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font -/// can't draw (laying out an XFA form's own values). -fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { - pdfcraft_forms::undrawn_fields(doc) - .into_iter() - .map(|(name, c)| { - format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) - }) - .collect() -} - -/// The form's fields as the XFA data layer wants them. -fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { - use pdfcraft_forms::FieldKind as K; - pdfcraft_forms::fields(doc) - .into_iter() - .map(|f| { - let data = match f.kind { - K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), - K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), - K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), - K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, - }; - pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } - }) - .collect() -} - -/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not -/// be written. -/// `datasets`: the stream this edit already wrote, replaced in place rather than added again -/// (and set to the one written). -/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). -fn xfa_sync_datasets( - doc: &mut pdfcraft_cos::Document, - tpl: Option<&pdfcraft_xfa::model::Template>, - datasets: &mut Option, -) -> Result, String> { - let data = xfa_field_data(doc); - let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; - if r.stream.is_some() { - *datasets = r.stream; - } - Ok(r.warnings) -} - -/// What a document whose form script ran away is told. -const XFA_SCRIPTS_OFF: &str = - "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; - -/// Most XFA warnings kept per document. -const MAX_XFA_WARNINGS: usize = 50; - -/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). -fn note_warnings(list: &mut Vec, new: &[String]) { - for w in new { - if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { - list.push(w.clone()); - } - } -} - -/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or -/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an -/// encoding this can't read. -fn decode_data_file(bytes: &[u8]) -> Option> { - if bytes.starts_with(b"%FDF") { - return None; - } - let declared = |text: &str| -> Option { - let head = text.get(..text.len().min(256))?; - let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; - let enc = decl.split_once("encoding=")?.1; - let quote = enc.chars().next()?; - Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) - }; - if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { - match declared(&text).as_deref() { - None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { - // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 - // differs, and is rare in form data). - return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); - } - Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), - } - } - // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. - let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); - match declared(&latin1).as_deref() { - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), - _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), - } -} - -/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, -/// buttons that did nothing but run scripts, and fields whose value differs from the form's -/// data (data PdfCraft could not read into them, now lost). -fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { - let fields = pdfcraft_forms::fields(doc); - let data = xfa_field_data(doc); - let differ = pdfcraft_xfa::read_values(doc, &data) - .into_iter() - .filter(|(name, value)| { - let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; - match value { - pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), - pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, - pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), - pdfcraft_xfa::FieldData::None => false, - } - }) - .count(); - let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_removed.extend(r.notes()); - if differ > 0 { - cx.xfa_removed.push(format!( - "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" - )); - } - Ok(()) -} - -/// Keep the document's XFA state in step with its file after an edit: a script may have laid -/// the form out again (rows added, subforms shown), an edit may have taken the XFA out -/// (flattened, or made static), or an undo put it back. -fn sync_xfa_state(doc: &mut Document) { - let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; - match pdfcraft_xfa::existing_layout(cos) { - Some(report) => { - if doc.xfa.is_none() { - // Back after an undo: the XFA's own warnings apply again, the strip's don't. - doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); - if doc.xfa_template.is_none() && !doc.xfa_scripts_off { - doc.xfa_template = xfa::template(cos); - } - } - doc.xfa = Some(report); - } - None => { - doc.xfa = None; - doc.xfa_template = None; - // Only what the strip said stays (the XFA's own warnings no longer apply). - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - } - } -} - -/// How the warnings of a strip start, so they can be told from the XFA's own. -const XFA_REMOVED_PREFIX: &str = "XFA removed: "; - -/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), -/// rather than XFDF, Acrobat's `` XML or a table? By its root element. -fn is_xfa_data_file(text: &str) -> bool { - let text = text.get(..text.len().min(65_536)).unwrap_or(text); - let mut rest = text.trim_start_matches('\u{feff}').trim_start(); - // Past the XML declaration, comments and processing instructions. - let mut guard = 0; - while guard < 100 { - guard += 1; - if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); - } else { - break; - } - } - let Some(tag) = rest.strip_prefix('<') else { return false }; - let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); - let local = name.rsplit(':').next().unwrap_or(name); - !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") -} - -/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. -fn image_content_type(bytes: &[u8]) -> Option<&'static str> { - match pdfcraft_xfa::image::kind(bytes)? { - pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), - pdfcraft_xfa::image::Kind::Png => Some("image/png"), - pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), - } -} - -/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is -/// laid out again from the data (then, when `calculate`, its calculations run), and the -/// fields take the data's values. -fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { - if let Some(tpl) = cx.xfa.clone() { - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; - let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) - .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = r { - cx.xfa_out.errors.push(e); - } - } - let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; - if !taken.is_empty() { - cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); - } - // Not laid out again (a static form, or scripts off): fields the data no longer holds are - // emptied, so the data replaced is the data shown. - if cx.xfa.is_none() && calculate { - let data = xfa_field_data(doc); - let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); - for f in pdfcraft_forms::fields(doc) { - if present.contains(&f.name) || f.value.is_empty() { - continue; - } - use pdfcraft_forms::FieldKind as K; - let empty = match f.kind { - K::Text => FieldValue::Text(String::new()), - K::CheckBox => FieldValue::Check(false), - K::Radio => FieldValue::Radio(None), - K::Combo | K::List => FieldValue::Choice(Vec::new()), - K::PushButton | K::Signature => continue, - }; - if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { - cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); - } - } - } - Ok(()) -} - -/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns -/// the names of the fields that changed. -fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { - let data = xfa_field_data(doc); - let fields = pdfcraft_forms::fields(doc); - let mut changed = Vec::new(); - for (name, value) in pdfcraft_xfa::read_values(doc, &data) { - let Some(f) = fields.iter().find(|f| f.name == name) else { continue }; - let new = match value { - pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { - // Saved values, one per line for a multi-select list; shown text stands for - // its saved value (an older viewer may have written it). - let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); - let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { - t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() - } else { - vec![saved(t.trim())] - }; - (f.value != picked).then_some(FieldValue::Choice(picked)) - } - pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), - pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), - pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), - pdfcraft_xfa::FieldData::None => None, - }; - if let Some(v) = new - && pdfcraft_forms::set_value(doc, &name, &v).is_ok() - { - changed.push(name); - } - } - Ok(changed) -} - -/// Validate the signature fields of `cos` (written as `bytes`). -fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec { - pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) -} - -impl Session { - pub fn new() -> Self { - Self::default() - } - - /// Use a fixed clock (tests) instead of the system time. - pub fn with_clock(mut self, clock: fn() -> i64) -> Self { - self.clock = Some(clock); - self - } - - /// A real clock for a host that has neither a clock nor a time zone of its own (a - /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so - /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in - /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every - /// use because the zone changes with the date (daylight saving). Unlike - /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. - pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { - self.clock = Some(clock); - self.utc_offset = Some(utc_offset); - self - } - - /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under - /// the test clock (which is already local), else the system's. - fn local_offset(&self) -> i64 { - match (self.utc_offset, self.clock) { - (Some(zone), _) => zone(), - (None, Some(_)) => 0, - (None, None) => local_utc_offset(), - } - } - - /// Seconds since the Unix epoch from the session clock (0 when unknown). - pub fn now_secs(&self) -> i64 { - self.now().unwrap_or(0) - } - - fn now(&self) -> Option { - if let Some(c) = self.clock { - return Some(c()); - } - #[cfg(not(target_arch = "wasm32"))] - return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); - #[cfg(target_arch = "wasm32")] - return None; - } - - /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock - /// is taken as local time. - /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). - pub fn stamp_by_line(&self, author: &str) -> String { - let offset = self.local_offset(); - let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); - let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); - let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); - const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; - let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; - format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) - } - - pub fn today(&self) -> (i64, u32, u32) { - let offset = self.local_offset(); - self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) - } - - /// Open a document from bytes. Rendering starts lazily when pages are requested. - /// - /// `password` is tried as either the user or owner password when the file is encrypted. - /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named - /// " (revision n)". - pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { - let doc = self.get(id).ok_or("no such document")?; - let ends = doc.revision_ends(); - let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; - let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); - let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); - self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) - } - - /// Snapshot the session state that affects open-time scripts, dates and signature trust. - pub fn open_context(&self) -> OpenContext { - OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() } - } - - /// Prepare a document without adding it to this session. - pub fn prepare_open( - &self, - name: impl Into, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - self.open_context().prepare_open(name, path, bytes, password) - } - - /// Register a prepared document. This is the only open step that mutates the session. - pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { - self.next_id += 1; - let id = DocId(self.next_id); - let mut document = prepared.document; - document.id = id; - self.docs.push(document); - id - } - - /// Synchronous convenience API used by the CLI and automation. - pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { - let prepared = self.prepare_open(name, path, bytes, password)?; - Ok(self.commit_open(prepared)) - } - - fn prepare_open_unguarded( - context: &OpenContext, - name: String, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); - // The renderer authenticates on its own. It cannot use the owner password of R2–R4 - // files, so give it the user password that owner authentication recovers. - let (info, render_password) = match inspect(bytes.clone(), password) { - Ok(info) => (info, password.map(str::to_owned)), - Err(OpenError::WrongPassword) => { - let user = match &cos { - Ok(Ok(d)) => d.security().and_then(|s| s.recovered_user_password()), - _ => None, - }; - let user = renderer_password(&user.ok_or(OpenError::WrongPassword)?); - (inspect(bytes.clone(), Some(&user))?, Some(user)) - } - Err(e) => return Err(e), - }; - // A dynamic XFA form is a shell around an XML template; lay the template out into real - // pages and fields so the rest of the engine works on it. The original bytes stay: the - // laid-out form is one appended revision. - let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - // Write `work` as one more revision and reopen it: what the document then is. - let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { - let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; - let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; - let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; - Ok((new_bytes, new_info, new_cos)) - }; - let (bytes, info, cos, xfa) = match (info.xfa, cos) { - (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { - let mut work = cos.clone(); - let laid_out = guard(|| xfa_layout(&mut work)) - .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) - .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); - match laid_out { - Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), - Err(e) => { - let mut info = info; - info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); - (bytes, info, Ok(Ok(cos)), None) - } - } - } - // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values - // filled in by another viewer since; give the fields those values. - (Some(_), Ok(Ok(cos))) => { - let report = pdfcraft_xfa::existing_layout(&cos); - let mut work = cos.clone(); - let synced = - guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); - match synced { - Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), - Ok(names) => match rebase(&work) { - Ok((b, mut i, c)) => { - // The fields were rewritten from the XFA data: say so, and which. - let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); - let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; - i.warnings.push(format!( - "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", - names.len(), - if names.len() == 1 { "" } else { "s" }, - shown.join(", ") - )); - (b, i, Ok(Ok(c)), report) - } - Err(e) => { - let mut info = info; - info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); - (bytes, info, Ok(Ok(cos)), report) - } - }, - Err(e) => { - let mut info = info; - info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); - (bytes, info, Ok(Ok(cos)), report) - } - } - } - (_, cos) => (bytes, info, cos, None), - }; - // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in - // Acrobat; what they change is one more revision, still nothing to save. - let mut script_output = js::JsOutput::default(); - let mut ran_away = false; - let xfa_template = match (&xfa, &cos) { - (Some(_), Ok(Ok(c))) => xfa::template(c), - _ => None, - }; - let (bytes, info, cos) = match (&xfa, cos) { - (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { - Some(tpl) => { - let mut work = cos.clone(); - let pages = info.pages.len(); - let ran = guard(|| { - let mut run = xfa::XfaRun { page_count: pages, out: &mut script_output, datasets: None, ran_away: false }; - let changes = xfa::on_open(&mut work, &tpl, &mut run); - if run.ran_away { - ran_away = true; - } - changes - }) - .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); - match ran { - Ok(changes) if work.is_modified() => match rebase(&work) { - Ok((b, mut i, c)) => { - // The file as saved differs from what opening shows: say so. - if !changes.is_empty() { - i.warnings.push(format!( - "This form's scripts changed it on opening ({}); saving keeps those changes", - changes.describe() - )); - } - (b, i, Ok(Ok(c))) - } - Err(e) => { - script_output.errors.push(format!("The form's scripts could not be applied: {e}")); - (bytes, info, Ok(Ok(cos))) - } - }, - Ok(_) => (bytes, info, Ok(Ok(cos))), - Err(e) => { - script_output.errors.push(format!("The form's scripts could not run: {e}")); - (bytes, info, Ok(Ok(cos))) - } - } - } - None => (bytes, info, Ok(Ok(cos))), - }, - (_, cos) => (bytes, info, cos), - }; - // The catalog leads to no page, but the reader found pages by scanning: gather the page - // objects into a page tree as one more revision, or page edits address none of them - // (#1656). Kept only when the reader then shows the same pages, and never on a signed - // document, where writing the repair could rewrite the file under its signatures. - let (bytes, info, cos) = match cos { - Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { - let mut work = cos.clone(); - let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); - let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { - Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), - _ => None, - }; - match rebuilt { - Some((b, i, mut c)) => { - for line in cos.repair_log() { - c.note_repair(line.clone()); - } - c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); - (b, i, Ok(Ok(c))) - } - None => (bytes, info, Ok(Ok(cos))), - } - } - cos => (bytes, info, cos), - }; - // XFA notes survive the document being re-read after edits. - let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); - // A script that ran away on open turns the form's scripts off for this document. - document.xfa_template = if ran_away { None } else { xfa_template }; - document.js_output.append(script_output); - if ran_away { - document.xfa_scripts_off = true; - document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - note_warnings(&mut document.xfa_warnings, &xfa_warnings); - Ok(PreparedDocument { document }) - } - - /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { - let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; - let (mut editor, read_only_reason) = match cos { - Ok(Ok(cos)) => { - let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; - let saved = cos.bytes().clone(); - ( - Some(Editor { - cos, - saved, - saved_keys: keys.clone(), - unsaved_file: false, - undo: Vec::new(), - redo: Vec::new(), - keys, - appearances: AppearanceCache::default(), - text_blocks: TextBlocksCache::default(), - }), - None, - ) - } - Ok(Err(e)) => (None, Some(e.to_string())), - Err(_) => (None, Some("the document structure could not be read for editing".into())), - }; - let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); - let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); - let display = display_bytes(editor.as_mut(), &bytes); - let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); - let (form, adopted) = editor - .as_ref() - .map(|e| { - let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos); - xfa::mark_script_buttons(&e.cos, &mut fields.items); - (fields.items, fields.adopted_page_fields) - }) - .unwrap_or_default(); - // Fields the file lists only as page widgets are adopted leniently; the file says so. - if adopted > 0 - && let Some(e) = &mut editor - { - let line = if adopted == 1 { - "the form's /Fields list omits 1 field; it was read from the page annotations".to_string() - } else { - format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations") - }; - e.cos.note_repair(line); - } - Document { - id: DocId(0), - name, - path, - bytes, - display, - info, - renderer, - password: render_password, - dirty: false, - generation: 0, - snapshot_generation: 0, - read_only_reason, - form: Arc::new(form), - marker_scan: OnceLock::new(), - links: OnceLock::new(), - signatures: Arc::new(signatures), - trust: trust.clone(), - sig_cache, - editor, - config, - js_output: Default::default(), - xfa, - xfa_template: None, - xfa_scripts_off: false, - fonts_stale: false, - xfa_warnings: Vec::new(), - edit_notices: Vec::new(), - } - } - - fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { - self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) - } - - /// Apply an edit. On success the previous state is undoable and the view data is refreshed. - pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { - let now = self.now(); - let today = self.today(); - let js_off = self.js_off; - let doc = self.doc_mut(id)?; - doc.edit_notices.clear(); - let name = doc.name.clone(); - let is_xfa = doc.info.xfa.is_some(); - let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); - if !js_off { - cx.xfa = doc.xfa_template.clone(); - } - cx.pages = doc.info.pages.len(); - cx.today = today; - let reason = doc.read_only_reason.clone().unwrap_or_default(); - let signed = doc.is_signed(); - let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; - if let Some(p) = editor.cos.permissions() { - check_permission(&edit, &p)?; - } - // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it - // in the paper's colour and draw the new text in the ink's. - let edit = match edit { - Edit::EditTextBlock { page, block, text, mut style } => { - // Only invisible text is sampled; the blocks are usually cached from opening it. - if style.cover.is_none() - && let Some(info) = doc.info.pages.get(page) - && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) - && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() - { - style.cover = Some(paper); - style.color = style.color.or(Some(ink)); - } - Edit::EditTextBlock { page, block, text, style } - } - other => other, - }; - // Nothing to bake: leave undo history and the dirty flag alone. - if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { - return Ok(()); - } - let mut next = editor.cos.clone(); - if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); - } - cx.renamed_fields.clear(); - // `next` is a copy: if the edit fails or crashes, the document is unchanged. - guard(|| run_edit(&mut next, &edit, &mut cx)) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - // Redaction removed what its own interpreter places under the marks. The marks were drawn - // over the page as the renderer shows it, so the renderer has the last word. - if let Edit::ApplyRedactions { pages } = &edit { - let marks: Vec = - pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &doc.info.pages, doc.password.as_deref())) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - } - // Renamed fields are said, not slipped in: a repair-log note (with the caveat that - // scripts were left alone) and a count in the undo step's label. - let renamed_note = |renamed: &[(String, String)]| { - (!renamed.is_empty()).then(|| { - let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); - let more = renamed.len().saturating_sub(shown.len()); - let more = if more > 0 { format!(", and {more} more") } else { String::new() }; - format!( - "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", - renamed.len(), - if renamed.len() == 1 { "" } else { "s" }, - shown.join(", ") - ) - }) - }; - if let Some(note) = renamed_note(&cx.renamed_fields) { - next.note_repair(note); - } - // Only Insert from File renames fields; its label says so in a wording the interface - // translates (the count lives in the repair note). - let label = match (&edit, cx.renamed_fields.len()) { - (_, 0) => edit.label(), - (Edit::InsertPagesFrom { name, .. }, n) => Label::with( - if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, - name.as_str(), - ), - _ => edit.label(), - }; - // An XFA form's scripts answer the change: exit and validate scripts of the field, then - // every calculation (and a reset recalculates). They read the data, so it is brought up - // to date first. - if let Some(tpl) = cx.xfa.clone() { - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let datasets = &mut cx.xfa_datasets; - let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - cx.xfa_out.errors.extend(notes); - } - fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { - match e { - Edit::SetFieldValue { name, .. } => changed.push(name.clone()), - Edit::ResetForm { .. } => *reset = true, - Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), - _ => {} - } - } - let mut changed: Vec = Vec::new(); - let mut reset = false; - collect(&edit, &mut changed, &mut reset); - if !changed.is_empty() || reset { - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; - // Every changed field's scripts, then the calculations once (after a reset, - // only those). - let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) - .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = ran { - cx.xfa_out.errors.push(e); - } - } - } - // XFA forms keep their values in the datasets packet too, for Adobe's viewers. - let mut xfa_notes = Vec::new(); - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); - xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - } - // Signed documents are only ever saved incrementally: an edit that needs a full rewrite - // (applying redactions, changing security, sanitizing) would invalidate the signatures. - // So does any edit to a file rebuilt from object headers: it has no chain to append to. - let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); - if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { - return Err(EditError::SignedRewrite(edit.label().to_string())); - } - // What this edit drew with `?` (a value or comment text its font can't draw, stored as - // typed): reported, never silent (AGENTS.md §4). - let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { - guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() - } else { - Vec::new() - }; - let previous = std::mem::replace(&mut editor.cos, next); - let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); - let previous_keys = std::mem::replace(&mut editor.keys, keys); - let scope = scope_of(&edit); - editor.undo.push((label, previous, previous_keys, scope)); - if editor.undo.len() > MAX_UNDO { - editor.undo.remove(0); - } - editor.redo.clear(); - Self::adopt_keys(doc); - let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) - .unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - // Roll back: the edit produced something we cannot display. - if let Some(ed) = doc.editor.as_mut() - && let Some((_, prev, keys, _)) = ed.undo.pop() - { - ed.cos = prev; - ed.keys = keys; - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.dirty = true; - doc.generation += 1; - doc.edit_notices = notices; - if !cx.xfa_removed.is_empty() { - let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - note_warnings(&mut doc.xfa_warnings, &removed); - note_warnings(&mut doc.info.warnings, &removed); - } - note_warnings(&mut doc.xfa_warnings, &xfa_notes); - let notes = doc.xfa_warnings.clone(); - note_warnings(&mut doc.info.warnings, ¬es); - if let Some(js) = cx.js { - doc.js_output.append(js.output); - } - doc.js_output.append(cx.xfa_out); - if cx.xfa_ran_away { - doc.xfa_template = None; - doc.xfa_scripts_off = true; - doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - Ok(()) - } - - pub fn undo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - doc.edit_notices.clear(); - let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; - let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; - let current = std::mem::replace(&mut editor.cos, prev); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.redo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?; - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - pub fn redo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - doc.edit_notices.clear(); - let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; - let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; - let current = std::mem::replace(&mut editor.cos, next); - let current_keys = std::mem::replace(&mut editor.keys, keys); - editor.undo.push((label.clone(), current, current_keys, scope)); - Self::adopt_keys(doc); - Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?; - Self::sync_dirty(doc); - doc.generation += 1; - Ok(label) - } - - /// Unsaved when the working graph is not the last opened or saved file. Undo back to that - /// file is clean; undo past a later save is not, because the restored snapshot's own base - /// is an earlier revision. - fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); - } - - /// Use the current state's password for viewing (protection edits change it). - fn adopt_keys(doc: &mut Document) { - if let Some(e) = doc.editor.as_ref() { - doc.password = e.keys.render.clone(); - doc.config.password = e.keys.render.as_deref().map(Arc::from); - } - } - - /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits - /// keep the renderer; other scopes also replace it. - /// Comment and form edits skip the full re-inspection (seconds on very large files): the - /// comment list and field values are re-read from the object graph instead. - fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { - if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { - return Self::refresh(doc); - } - if scope == Scope::Content { - return Self::refresh_content(doc, None); - } - if scope == Scope::Links { - return Self::refresh_links(doc); - } - // Metadata edits keep the renderer, which holds the working file anyway. - if scope != Scope::Metadata { - Self::release_working(doc); - } - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // Comment edits, and form edits (a script may change annotation properties), may change - // annotation dictionaries, invalidating cached appearances. - if matches!(scope, Scope::Comments | Scope::Form) { - editor.appearances.clear(); - } - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - if scope == Scope::Metadata { - // Read the way the full inspection reads them (NULs and spaces trimmed, empty is - // none), so editing one entry doesn't change how the others display. - let info = |key| pdfcraft_organize::info(&editor.cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); - doc.info.title = info("Title"); - doc.info.author = info("Author"); - doc.info.subject = info("Subject"); - doc.info.keywords = info("Keywords"); - doc.info.creator = info("Creator"); - doc.info.producer = info("Producer"); - doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); - doc.info.file_size = bytes.len(); - if !doc.signatures.is_empty() { - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.signatures = Arc::new(signatures); - } - doc.bytes = bytes; - return Ok(()); - } - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - let form = Arc::new(form); - match scope { - Scope::Metadata => return Ok(()), - Scope::Comments => { - doc.info.annotations = comment_list(&editor.cos); - // Link indexes are positions in /Annots, so comment insertion/deletion can move them. - doc.links = OnceLock::new(); - } - Scope::Form => { - // PERF: keystroke path, index once. A linear find per field is O(F^2); - // forms reach 10k fields. - let mut by_name = std::collections::HashMap::with_capacity(form.len()); - for x in form.iter() { - // First match wins (do not simplify to collect: it keeps the last). - by_name.entry(x.name.as_str()).or_insert(x); - } - for f in &mut doc.info.fields { - if let Some(ff) = by_name.get(f.name.as_str()) { - f.value = match ff.kind { - pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { - Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) - } - _ if ff.value.is_empty() => None, - _ => Some(ff.value.join(", ")), - }; - } - } - } - Scope::Links => return Self::refresh_links(doc), - // Handled above; kept as a full refresh so the match stays total. - Scope::Full => return Self::refresh(doc), - // Handled above (content-only refresh); the arm keeps the match total. - Scope::Content => return Self::refresh_content(doc, None), - } - doc.info.file_size = bytes.len(); - doc.form = form; - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// Which derived lists a content-only edit can change: added content (`None`, from - /// undo/redo, re-reads both: always correct, rarely paid). - fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { - let touches_added = edit.is_none_or(|e| { - matches!( - e, - Edit::AddText { .. } | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } - ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) - }); - let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) - }); - (touches_added, touches_marks) - } - - /// Rebuild working bytes, display bytes and renderer after a content-only edit: page - /// content streams, resources and added content/marks. The page tree, annotations, form - /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on - /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, - /// from undo/redo, re-reads both: always correct, rarely paid). The cached display - /// appearances are kept: content edits leave every annotation dictionary alone. - fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - // The edit must still leave a file the viewer can open; anything else rolls back above. - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; - let (touches_added, touches_marks) = Self::content_refresh_lists(edit); - if touches_added || touches_marks { - doc.marker_scan = OnceLock::new(); - } - doc.info.file_size = bytes.len(); - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - doc.fonts_stale = true; - Ok(()) - } - - /// Let go of the working file before the next one is written (gap 13). The working file is - /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the - /// working bytes, the display bytes and the renderer) while writing the new one held three - /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes - /// are the original's (already held by the editor, so free) and the renderer draws nothing. - /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh - /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes - /// from the editor, never from these. - fn release_working(doc: &mut Document) { - let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; - // Dropping the pool stops its workers; each lets go of the file when it exits. - doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); - doc.display = original.clone(); - doc.bytes = original; - } - - /// After a refresh failed: draw the display bytes it left (the original, once released), - /// so the page still shows a document. - fn show_released(doc: &mut Document) { - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - - /// Rebuild working bytes, inspection and renderer from the current edit state. - fn refresh(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, true, true) - } - - /// Reinspect a link edit while preserving the page-content marker scan cache. - fn refresh_links(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, false, true) - } - - fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { - // A full refresh may follow structural or annotation changes: cached appearances - // may be stale, so they are rebuilt below. - if let Some(editor) = doc.editor.as_mut() { - editor.appearances.clear(); - } - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - // Keep the user's layer choices where the layers still exist. - let mut info = info; - for l in &mut info.layers { - if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { - l.visible = old.visible; - } - } - note_warnings(&mut info.warnings, &doc.xfa_warnings); - doc.info = info; - doc.fonts_stale = false; - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - doc.form = Arc::new(form); - if invalidate_markers { - doc.marker_scan = OnceLock::new(); - } - if invalidate_links { - doc.links = OnceLock::new(); - } - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.display = display_bytes(Some(editor), &bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.bytes = bytes; - Ok(()) - } - - /// The bytes to write for Save: an incremental update of the file as opened/last saved, - /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. - /// - /// A file whose cross-reference data had to be rebuilt is written in full even when nothing - /// was edited: its bytes as opened are the damaged file, and this save is how the repair the - /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would - /// break its signatures, so with no edits it keeps its bytes, damage and all. - pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; - let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); - if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { - return Ok(editor.saved.clone()); - } - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - // A restored snapshot can be based on an earlier revision. Append to the last saved - // file so that revision stays in the chain. A full rewrite (redaction, new encryption, - // a reconstructed file) still writes the current graph on its own. - let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); - guard(|| { - if on_saved || rewrite { - write_incremental(&editor.cos, &opts) - } else { - let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&editor.cos)?; - write_incremental(&base, &opts) - } - }) - .map_err(EditError::Write)? - .map(Arc::new) - .map_err(|e| EditError::Write(e.to_string())) - } - - /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). - pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if doc.is_signed() { - return Err(EditError::Signed); - } - let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - guard(|| write_full(&editor.cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this - /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the - /// file has been replaced. Same error as `mark_saved`. - pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; - pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) - } - - /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file - /// so the next save appends only newer edits. Undo history is kept. - pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - if let Some(editor) = doc.editor.as_mut() { - // An encrypted file needs the password it is protected with now (the owner - // password when known, so saving never downgrades this session's rights). - let keys = editor.keys.clone(); - editor.cos = - pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - editor.saved = bytes; - editor.saved_keys = keys; - editor.unsaved_file = false; - } - if let Some(p) = path { - doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); - doc.path = Some(p); - } - doc.dirty = false; - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// Enter the permissions (owner) password of a document opened with its user password, - /// lifting its restrictions for this session (Acrobat asks for it before changing security). - /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states - /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents - /// opened by the owner need nothing. - pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - if editor.cos.permissions().is_none_or(|p| p.owner) { - return Ok(()); - } - editor.cos.unlock(password).map_err(|e| match e { - pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, - other => EditError::Protection(other.to_string()), - })?; - // Saving a state that keeps the security it was opened with reopens it with the same - // passwords, so it reopens as owner. - let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { - if !cos.encryption_changed() { - keys.reopen = Some(password.to_owned()); - } - }; - reopen_as_owner(&editor.cos, &mut editor.keys); - // Other states with the same restricted access are unlocked too; states opened with - // other passwords (protection changed and saved since) stay as they are. - for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { - if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { - reopen_as_owner(cos, keys); - } - } - Ok(()) - } - - /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is - /// cleared, as in Acrobat. - pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let base = editor.saved.clone(); - // The saved file opens with the passwords it was saved with. The current snapshot may - // be an older revision (undo past that save), so its own base is not the saved file. - let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; - candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); - candidates.push(Keys::default()); - let (cos, keys) = candidates - .into_iter() - .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) - .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; - editor.cos = cos; - editor.keys = keys; - editor.undo.clear(); - editor.redo.clear(); - Self::adopt_keys(doc); - Self::sync_dirty(doc); - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open - /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA - /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, - /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was - /// opened with is tried; on any failure the document is left as it was. - pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { - let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; - let doc = &self.docs[old]; - let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); - let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); - let new_id = self.open(name, path, bytes, password.as_deref())?; - let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; - let mut doc = self.docs.remove(at); - doc.id = id; - // Caches keyed by the edit generation (edit lines, images, measurements) must see a new - // one; there is nothing new to autosave. - doc.generation = generation + 1; - doc.snapshot_generation = doc.generation; - // `open` pushed the new version last, so the old one is still at `old`. - *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; - Ok(()) - } - - /// The page count of another PDF (Replace Pages, Insert Pages dialogs). - pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { - Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) - } - - /// A new blank document (Create ▸ Blank page). - pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { - self.write_new(&pdfcraft_create::blank(width, height, pages)?) - } - - /// A new document with one page per image (PNG, JPEG). - pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images(images)?) - } - - /// Create image pages at embedded resolution or a fixed dpi, without resampling. - pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) - } - - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is - /// refused, naming the line and the character (never set as `?`). - pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) - } - - /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. - fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { - let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; - self.write_new(&doc) - } - - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. - pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { - let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); - }; - let title = name.rsplit_once('.').map_or(name, |(s, _)| s); - // Image decoders read untrusted bytes: a panic in one must not take the app down. - let created = guard(|| match kind { - SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), - SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), - SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), - }) - .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; - Ok((kind, created?)) - } - - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. - pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { - let pdf = match source_kind(name, bytes) { - Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, - None => name.to_ascii_lowercase().ends_with(".pdf"), - }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } - } - - /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium - /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects - /// packed into compressed object streams. Returns the bytes and how many objects were - /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). - pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { - let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; - Ok((bytes, report.merged)) - } - - /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove - /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents - /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in - /// place, without progress. - pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { - self.optimize_job(id, settings, discard)?.run(|_| true) - } - - /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text - /// are form data only. - pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. - let xfa = doc.info.xfa.is_some(); - if format == DataFormat::Xdp && !xfa { - return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); - } - guard(|| match format { - DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), - DataFormat::Fdf => Ok(pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name)), - DataFormat::Xml | DataFormat::Xdp if xfa => { - pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) - } - other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), - }) - .map_err(EditError::Write)? - .map_err(EditError::Write) - } - - /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). - pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if !doc.allows_printing() { - return Err(EditError::NotPermitted("printing")); - } - // Print as image: the pages used become pictures first, then the picture document is - // imposed in their place (its page k is the k-th distinct source page used). - if let Some(raster) = &settings.raster { - let mut distinct: Vec = Vec::new(); - for &p in &settings.pages { - if !distinct.contains(&p) { - distinct.push(p); - } - } - let hide_comments = matches!(settings.content, print::Content::Document | print::Content::FormFieldsOnly); - let bytes = print_raster::rasterize(doc, &distinct, raster, hide_comments).map_err(EditError::Print)?; - let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; - let remapped = print::Settings { - pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), - content: print::Content::Document, - raster: None, - ..settings.clone() - }; - return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); - } - let mut cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => { - pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? - } - }; - // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer - // action) has changed one, the print copy's default configuration says which are on, so a - // hidden layer stays off on paper too; untouched, the document's own configuration prints. - if !doc.config.layers.is_empty() { - set_default_layer_state(&mut cos, &doc.info.layers); - } - pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) - } - - /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); - /// only `pages` are read, the others are `false`. - pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { - let Some(doc) = self.get(id) else { return Vec::new() }; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { - Ok(c) => c, - Err(_) => return Vec::new(), - }, - }; - (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() - } - - /// Combine whole files, in order, into new PDF bytes (one bookmark per file). - pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { - let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; - let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); - let out = pdfcraft_organize::combine(&named)?; - self.write_new(&out) - } - - /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). - pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { - self.combine_unlocked(sources, &[]) - } - - /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as - /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see - /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a - /// time: a source is opened, its pages are copied into the result, and its bytes are released - /// before the next item is read. So the number of sources is not limited, and one source's - /// document is held at a time. An item's error stops the combine and is returned as it is. - /// `Ok(None)` when there are no sources. - pub fn combine_each(&self, sources: I) -> Result>>, E> - where - I: IntoIterator>, - E: From, - { - let mut combiner = pdfcraft_organize::Combiner::new(); - let mut added = false; - for source in sources { - let (title, bytes, range) = source?; - let doc = open_source(&title, &bytes)?; - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let pages = match range { - Some(r) => { - let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{title}: {e}")))?; - Some(p) - } - None => None, - }; - combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; - added = true; - } - if !added { - return Ok(None); - } - let out = combiner.finish().map_err(EditError::from)?; - Ok(Some(self.write_new(&out)?)) - } - - /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by - /// position (missing or `None`: no password). The result is not encrypted. - pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// The pre-flight over byte sources: what combining them, in order, would rename in each one - /// (whole files; page ranges are the combine's to apply). A source that cannot be opened - /// previews as nothing — its row already says why it cannot be combined. - pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { - let docs: Vec> = - sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); - let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(|d| d.as_ref()).collect(); - let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); - // One preview per source, in step with it: a file that cannot be opened has none. - docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() - } - - /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. - pub fn combine_unlocked_reporting( - &self, - sources: &[CombineSource], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let docs = sources - .iter() - .enumerate() - .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) - .collect::, _>>()?; - let mut pages = Vec::with_capacity(docs.len()); - for ((name, _, range), d) in sources.iter().zip(&docs) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = - sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); - let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one - /// file split into several runs placed apart: each file is opened and copied once, so it keeps - /// one bookmark, its links between its own pages, its fields and its attachments whole (see - /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes - /// and password; a source without a key is a file of its own. - pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. - pub fn combine_grouped_reporting( - &self, - sources: &[CombineSource], - groups: &[u64], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); - for (i, (name, bytes, _)) in sources.iter().enumerate() { - let key = groups.get(i).copied(); - let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); - let at = match known { - Some(at) => at, - None => { - docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); - keys.push(key); - docs.len() - 1 - } - }; - file.push(at); - } - let mut pages = Vec::with_capacity(sources.len()); - for ((name, _, range), at) in sources.iter().zip(&file) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let mut runs: Vec> = Vec::with_capacity(sources.len()); - for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - runs.push((*at, name.as_str(), d, p.as_deref())); - } - let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// New PDF bytes containing copies of `pages` of the document (Extract Pages). - pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("extracting pages")); - } - let out = pdfcraft_organize::extract_pages(src, pages)?; - self.write_new(&out) - } - - /// Split the document into several new PDFs. - pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - pdfcraft_organize::split_ranges(n, by) - .into_iter() - .map(|r| { - let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; - Ok((r.start + 1, r.end, self.write_new(&doc)?)) - }) - .collect() - } - - /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in - /// order while their size (as single-page files, an over-estimate because shared fonts and - /// images count once per part) fits; a page larger than the limit is a part of its own. - pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - let mut cuts = Vec::new(); - let mut used = 0usize; - for p in 0..n { - let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); - if used > 0 && used + size > max_bytes { - cuts.push(p); - used = 0; - } - used += size; - } - self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) - } - - /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle - /// per line in user space, URI). Text that already has a link over it is skipped. - pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - let mut out = Vec::new(); - for page in 0..doc.info.pages.len() { - let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let Some(text) = res.text else { continue }; - let found: std::cell::RefCell, String)>> = Default::default(); - let hits = text.find_with(|chars| { - let urls = pdfcraft_annot::links::find_urls(chars); - let ranges = urls.iter().map(|u| u.0.clone()).collect(); - *found.borrow_mut() = urls; - ranges - }); - for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { - let rects: Vec<[f64; 4]> = text - .line_rects(glyphs) - .into_iter() - .map(|v| { - let q = doc.info.pages[page].view_rect_to_quad(v); - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - [ - xs.iter().copied().fold(f64::MAX, f64::min), - ys.iter().copied().fold(f64::MAX, f64::min), - xs.iter().copied().fold(f64::MIN, f64::max), - ys.iter().copied().fold(f64::MIN, f64::max), - ] - }) - .filter(|r| { - !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) - }) - .collect(); - if !rects.is_empty() { - out.push((page, rects, uri)); - } - } - } - out - } - - /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. - /// The document's own structure is read for it, so every bookmark can be reached by paging. - pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { - let doc = self.get(id)?; - if let Some(editor) = doc.editor.as_ref() { - let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); - let rows = page - .bookmarks - .into_iter() - .map(|b| { - let view = b.view.map_or_else(Default::default, |(kind, numbers)| { - let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); - pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) - }); - BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view } - }) - .collect(); - return Some(BookmarkPage { rows, next: page.next, truncated: false }); - } - let all = outline_rows(&doc.info.outline); - let end = offset.saturating_add(limit); - let rows = all.iter().skip(offset).take(limit).cloned().collect(); - Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) - } - - /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). - pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let top: Vec<(Option, String)> = match doc.editor.as_ref() { - Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), - None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), - }; - let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); - out.sort_by_key(|x| x.0); - out.dedup_by_key(|x| x.0); - out - } - - /// Open freshly created bytes (combine / extract) as a new, unsaved document. - pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { - let id = self.open(name, None, bytes, None)?; - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - // Unsaved work with no edit yet still needs its first recovery snapshot. - d.generation += 1; - } - Ok(id) - } - - fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) - } - - /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone - /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. - fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let bytes = self.write_unchecked(doc)?; - let expected = pdfcraft_organize::page_count(doc)?; - pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; - Ok(bytes) - } - - /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. - fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Documents with unsaved changes made since the last call: their current working file, - /// for crash recovery. Encrypted documents stay encrypted in the snapshot. - pub fn autosave_snapshots(&mut self) -> Vec { - let mut out = Vec::new(); - for d in &mut self.docs { - if d.dirty && d.generation != d.snapshot_generation { - d.snapshot_generation = d.generation; - out.push(RecoverySnapshot { - doc: d.id, - name: d.name.clone(), - path: d.path.clone(), - bytes: d.bytes.clone(), - encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), - }); - } - } - out - } - - /// Mark a document opened from a recovery file: it has unsaved changes and belongs at - /// `path` (where Save writes), as when the session ended. - pub fn mark_recovered(&mut self, id: DocId, path: Option) { - if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { - d.path = path; - d.dirty = true; - if let Some(e) = d.editor.as_mut() { - e.unsaved_file = true; - } - d.generation += 1; - d.snapshot_generation = d.generation; // its bytes are already in the recovery store - } - } - - /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; - /// callers must drop cached rasters and text for the document. - pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let Some(l) = doc.info.layers.get_mut(layer) else { return false }; - if l.visible == visible { - return false; - } - l.visible = visible; - use_layer_choices(doc); - true - } - - /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each - /// layer by its optional content group; groups that aren't layers are skipped. With - /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. - /// Returns `true` if any layer changed; callers must drop cached rasters and text for the - /// document, as for [`Self::set_layer_visible`]. - pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { - return false; - } - use_layer_choices(doc); - true - } - - /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form - /// fields and links still draw. Returns whether anything changed. - pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.hide_comments == hide { - return false; - } - doc.config.hide_comments = hide; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether - /// anything changed; callers must drop cached rasters and text for the document. - pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.reading_colors == colors { - return false; - } - doc.config.reading_colors = colors; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// The document's reading-colours setting; `None` when no such document is open. - pub fn reading_colors(&self, id: DocId) -> Option { - Some(self.get(id)?.config.reading_colors) - } - - /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment - /// with its number, author, type, date, text and replies, grouped by page. - pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let text = comment_summary(&doc.name, &doc.info.annotations, sort); - self.create_report(&format!("Summary of Comments on {}", doc.name), &text) - } - - /// The certificates trusted for signing. - pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { - &self.trust.certs - } - - /// Replace the trusted certificates, revalidate every open document's signatures, - /// and rebuild its display copy and renderer to match (working file untouched, - /// generation/dirty unchanged: trust alone is not an edit). - pub fn set_trusted_certificates(&mut self, certs: Vec) { - self.trust = Arc::new(TrustStore { certs }); - for doc in &mut self.docs { - doc.trust = self.trust.clone(); - let Some(editor) = doc.editor.as_mut() else { continue }; - // Keep verdict refresh and renderer refresh together. Viewer caches are - // invalidated by the UI trust path; trust alone changes no document artwork. - let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; - doc.display = display_bytes(Some(editor), &doc.bytes); - doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - } - - /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). - pub fn signing_date(&self) -> String { - let offset = self.local_offset(); - let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); - let stamp = local.trim_end_matches('Z'); - if offset == 0 { - return format!("{stamp}Z"); - } - let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; - format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) - } - - /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns - /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty - /// `opts.date` takes the session clock. - pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - // Encrypted documents are signed under their permissions (the signer checks them). - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - Ok(Arc::new(pdfcraft_sign::sign(&editor.cos, id, &opts)?)) - } - - /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by - /// `tsa`. The transport lives with the caller; the engine never opens a socket. - pub fn sign_with_timestamp( - &self, - doc: DocId, - id: &pdfcraft_sign::DigitalId, - mut opts: SignOptions, - tsa: &dyn pdfcraft_sign::TimestampAuthority, - ) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?); - // Checked like a save before anyone writes it (#2166). - self.check_save(doc, &signed)?; - Ok(signed) - } - - /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's - /// current state. An empty `date` takes the session clock; the transport is the caller's. - pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let date = if date.is_empty() { self.signing_date() } else { date }; - let stamped = Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?); - self.check_save(doc, &stamped)?; - Ok(stamped) - } - - /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature - /// (PAdES B-LT): an incremental update that never rewrites signed bytes. - pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let embedded = Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?); - self.check_save(doc, &embedded)?; - Ok(embedded) - } - - /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], - /// and the edit history before signing is dropped (signing can't be undone). - pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - self.mark_saved(id, bytes, path)?; - let doc = self.doc_mut(id)?; - if let Some(e) = doc.editor.as_mut() { - e.undo.clear(); - e.redo.clear(); - } - Ok(()) - } - - pub fn close(&mut self, id: DocId) { - self.docs.retain(|d| d.id != id); - } - - pub fn get(&self, id: DocId) -> Option<&Document> { - self.docs.iter().find(|d| d.id == id) - } - - /// Read document `id`'s font list again if content edits since the last full inspection may - /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. - pub fn refresh_fonts(&mut self, id: DocId) { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; - if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { - doc.info.fonts = info.fonts; - } - doc.fonts_stale = false; - } - - pub fn docs(&self) -> &[Document] { - &self.docs - } -} - -/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` -/// (0-based) of a PDF. -#[derive(Clone, Debug, PartialEq)] -pub struct MarkFile { - pub name: String, - pub bytes: Arc>, - pub page: usize, -} - -/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. -fn embed_image( - doc: &mut pdfcraft_cos::Document, - name: &str, - bytes: &[u8], -) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, bytes) -} - -/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. -fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { - let head = &f.bytes[..f.bytes.len().min(1024)]; - if head.windows(5).any(|w| w == b"%PDF-") { - let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; - let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?; - Ok(pdfcraft_edit::MarkSource { xobject, size, image: false }) - } else { - let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?; - Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) - } -} - -/// What Optimize PDF did. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct OptimizeReport { - pub optimize: optimize::Report, - /// Identical objects merged. - pub merged: usize, - /// Remove Hidden Information categories discarded, with counts. - pub discarded: Vec<(Hidden, usize)>, -} - -/// Order of Summarize Comments. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum SummarySort { - #[default] - Page, - Author, - Date, - Type, -} - -impl SummarySort { - pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; - pub fn name(self) -> &'static str { - match self { - SummarySort::Page => "Page", - SummarySort::Author => "Author", - SummarySort::Date => "Date", - SummarySort::Type => "Type", - } - } - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) - } -} - -/// Acrobat's name for a comment type, as comment lists show it. -pub fn comment_type_name(subtype: &str) -> &str { - match subtype { - "Text" => "Sticky Note", - "FreeText" => "Text Box", - "Highlight" => "Highlight", - "Underline" => "Underline", - "StrikeOut" => "Strikethrough", - "Squiggly" => "Squiggly", - "Square" => "Rectangle", - "Circle" => "Oval", - "Line" => "Line", - "Ink" => "Pencil", - "Polygon" => "Polygon", - "PolyLine" => "Polygonal Line", - "Stamp" => "Stamp", - "Caret" => "Inserted Text", - "FileAttachment" => "File Attachment", - "Sound" => "Sound", - "Redact" => "Redaction", - other => other, - } -} - -/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. -pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { - match a.intent.as_deref() { - Some("FreeTextCallout") => "Callout", - Some("PolygonCloud") => "Cloud", - Some("FreeTextTypeWriter") => "Typewriter", - _ => comment_type_name(&a.subtype), - } -} - -/// The text of a comment summary: one block per comment (replies indented under it), with a -/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are -/// pop-ups; the number is the comment's position on its page. -pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { - use std::fmt::Write; - let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.in_reply_to.is_none() && a.state.is_none()).collect(); - // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the - // direct parent, so following one level would leave the deeper replies out of the summary. - fn write_replies(out: &mut String, all: &[pdfcraft_render::Annotation], parent: &str, depth: usize) { - use std::fmt::Write; - if depth > 64 { - return; - } - for r in all.iter().filter(|r| r.state.is_none() && r.in_reply_to.as_deref() == Some(parent)) { - let indent = " ".repeat(depth + 1); - let _ = - writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); - for line in r.contents.as_deref().unwrap_or("").lines() { - let _ = writeln!(out, "{indent}{line}"); - } - if let Some(nm) = r.name.as_deref() { - write_replies(out, all, nm, depth + 1); - } - } - } - let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); - let mut last = usize::MAX; - let mut n = 0; - for a in &top { - if a.page != last { - last = a.page; - n = 0; - } - n += 1; - numbered.push((n, a)); - } - match sort { - SummarySort::Page => {} - SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), - SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), - SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))), - } - let mut out = format!("Summary of Comments on {name}\n\n"); - if numbered.is_empty() { - out.push_str("This document has no comments.\n"); - return out; - } - let mut heading = usize::MAX; - for (n, a) in numbered { - if sort == SummarySort::Page && a.page != heading { - heading = a.page; - let _ = writeln!(out, "Page: {}", a.page + 1); - } - let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a)); - if sort != SummarySort::Page { - let _ = write!(out, " Page: {}", a.page + 1); - } - let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); - if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { - let _ = writeln!(out, "{c}"); - } - if let Some(nm) = a.name.as_deref() { - write_replies(&mut out, all, nm, 0); - } - out.push('\n'); - } - out +use test_hooks::{check_refresh_panic, panic_on_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, secondary_scan_counts}; +use xfa_form::{ + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, + undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, +}; + +mod clock; +mod doc_state; + +use clock::{local_utc_offset, parse_ymd}; +pub use doc_state::{DocId, ImageEdit, OptimizeReport, RecoverySnapshot, SplitPart, guard, merge_data_files}; +use doc_state::{LOW_RESOLUTION_PRINT_DPI, render_threads, renderer_password, signatures_of, writable}; + +/// The configuration of the pool that draws the viewer: the document's own, plus installed CJK +/// fonts for fonts a PDF does not embed (`pdfcraft_render::RenderConfig::system_fonts`). Exports, +/// printing, OCR and automation use the document configuration itself, so nothing drawn with an +/// installed font is saved or printed (AGENTS.md §1.2, §1.4). +fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::RenderConfig { + config.clone().on_screen() } #[cfg(test)] mod tests; - -/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and -/// orientation (as displayed). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageParity { - #[default] - Both, - Even, - Odd, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageOrientation { - #[default] - Both, - Landscape, - Portrait, -} - -/// The pages of `pages` (0-based) that pass the filters. -pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec { - pages - .iter() - .copied() - .filter(|p| match parity { - PageParity::Both => true, - // Page numbers are 1-based: index 1 is page 2, an even page. - PageParity::Even => p % 2 == 1, - PageParity::Odd => p % 2 == 0, - }) - .filter(|p| { - let Some(pg) = info.pages.get(*p) else { return false }; - match orientation { - PageOrientation::Both => true, - PageOrientation::Landscape => pg.width > pg.height, - PageOrientation::Portrait => pg.width <= pg.height, - } - }) - .collect() -} diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 4902d72f53..e1d54d56ce 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -176,7 +176,7 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool { /// Resolve a PDF remote-Go-To file specification to a local path without opening it. /// Relative paths are based on the source document, which should be an absolute path; an /// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are -/// refused ([`craft_docpath::resolve`]). +/// refused ([`craft_docpath::resolve_absolute`]). pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result { if raw.is_empty() || raw.trim().is_empty() { return Err(RemoteDocumentPathError::Empty); @@ -197,12 +197,14 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result< return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url)); } } - let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?; - // A relative result would be read against the working directory. - if !path.is_absolute() { - return Err(RemoteDocumentPathError::MissingSourcePath); - } - Ok(path) + craft_docpath::resolve_absolute(raw, source).map_err(|r| match r { + // A path that resolves but not to an absolute one needed a source with an absolute path. With + // such a source, it is a path this platform cannot open (`C:\x.pdf` on Unix). + craft_docpath::Refused::Invalid if !source.is_some_and(Path::is_absolute) && craft_docpath::resolve(raw, source).is_ok() => { + RemoteDocumentPathError::MissingSourcePath + } + r => RemoteDocumentPathError::NotLocal(r), + }) } /// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures). @@ -444,328 +446,4 @@ fn mixes_scripts(label: &str) -> bool { } #[cfg(test)] -mod tests { - #[test] - fn remote_documents_must_be_regular_pdf_files() { - use super::{RemoteDocumentPathError as E, read_remote_document as read}; - let dir = std::env::temp_dir().join(format!("pdfcraft-gotor-read-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("folder.pdf")).unwrap(); - std::fs::write(dir.join("doc.PDF"), b"%PDF-1.7\n").unwrap(); - std::fs::write(dir.join("notes.txt"), b"text").unwrap(); - assert_eq!(read(&dir.join("doc.PDF")).as_deref(), Ok(&b"%PDF-1.7\n"[..])); - assert_eq!(read(&dir.join("folder.pdf")), Err(E::NotAFile), "a directory is refused"); - assert_eq!(read(&dir.join("notes.txt")), Err(E::NotPdf)); - assert_eq!(read(&dir), Err(E::NotPdf)); - #[cfg(unix)] - { - // Sparse: the size guard must reject this without allocating or reading a GiB. - let large = dir.join("large.pdf"); - std::fs::File::create(&large).unwrap().set_len(super::MAX_REMOTE_DOCUMENT_BYTES + 1).unwrap(); - assert_eq!(read(&large), Err(E::TooLarge)); - } - assert!(matches!(read(&dir.join("missing.pdf")), Err(E::Read(_)))); - #[cfg(unix)] - { - // A device (like /dev/zero, which never ends) and a FIFO (which blocks) are never opened. - std::os::unix::fs::symlink("/dev/zero", dir.join("zero.pdf")).unwrap(); - assert_eq!(read(&dir.join("zero.pdf")), Err(E::NotAFile)); - let fifo = dir.join("pipe.pdf"); - if std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) { - assert_eq!(read(&fifo), Err(E::NotAFile)); - } - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn remote_document_absolute_classification_uses_the_original_platform_path() { - use super::remote_document_path_is_absolute as absolute; - for relative in ["target.pdf", "../appendix/target.pdf", "folder/target.pdf"] { - assert!(!absolute(relative), "{relative}"); - } - #[cfg(unix)] - { - assert!(absolute("/manual/target.pdf")); - assert!(!absolute(r"C:\manual\target.pdf")); - } - #[cfg(windows)] - { - assert!(absolute(r"C:\manual\target.pdf")); - assert!(absolute("C:/manual/target.pdf")); - assert!(!absolute(r"C:target.pdf"), "drive-relative is not absolute"); - assert!(!absolute(r"\manual\target.pdf"), "rooted without a drive is not absolute"); - } - } - - #[test] - fn remote_document_paths_are_local_and_source_relative() { - use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; - use std::path::Path; - - let source = std::env::temp_dir().join("chapter").join("source.pdf"); - let source = source.as_path(); - let folder = source.parent().unwrap(); - assert_eq!(resolve(Some(source), "target.pdf"), Ok(folder.join("target.pdf"))); - assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(folder.parent().unwrap().join("target.pdf").as_path())); - #[cfg(unix)] - assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf"))); - assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath)); - assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used"); - assert_eq!(resolve(Some(source), ""), Err(E::Empty)); - assert_eq!(resolve(Some(source), " "), Err(E::Empty)); - for url in - ["file:///tmp/a.pdf", "file://h/s/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] - { - assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}"); - } - assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden)); - assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong)); - } - - #[test] - fn remote_document_paths_to_other_computers_and_devices_are_refused() { - use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; - - let source = std::env::temp_dir().join("chapter").join("source.pdf"); - let source = source.as_path(); - // Refused before any filesystem access: none of these exist, and a stat of a network path - // would contact its host. - for not_local in [ - "//server/share/a.pdf", - "\\\\server\\share\\a.pdf", - "\\\\h\\s\\a.pdf", - "\\\\?\\UNC\\h\\s\\a.pdf", - "\\??\\UNC\\h\\s\\a.pdf", - "/??/x", - "\\??\\C:\\a.pdf", - "\\\\.\\device\\a.pdf", - "/\\server/share/a.pdf", - "NUL.pdf", - ] { - assert!(matches!(resolve(Some(source), not_local), Err(E::NotLocal(_))), "{not_local:?}"); - } - // Linux reads `//tmp/x` as `/tmp/x`, but on Windows it names a share. - #[cfg(unix)] - { - let doubled = format!("/{}", source.with_file_name("a.pdf").display()); - assert!(matches!(resolve(Some(source), &doubled), Err(E::NotLocal(_))), "{doubled}"); - } - } - - #[test] - fn urls_follow_the_artcraft_scheme() { - assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP)); - assert_eq!(super::GITHUB, format!("https://github.com/storytold/{}", super::APP)); - for l in super::LINKS { - assert!(l.url.starts_with("https://"), "{}", l.url); - assert!(crate::commands::command(l.command).is_some(), "{} is a registered command", l.command); - } - } - - #[test] - fn documents_may_offer_web_and_email_addresses() { - use super::document_url; - for ok in [ - "https://example.org", - "HTTPS://Example.org/a?b=c#d", - "http://example.org:8080/x", - "mailto:someone@example.org", - "https://[::1]:8443/", - // Plain spaces turn up in real documents' links and are visible in the dialog. - "mailto:orders@example.org?subject=Order form", - "https://example.org/My Report.pdf", - ] { - assert_eq!(document_url(ok).as_deref(), Ok(ok), "{ok}"); - } - let multibyte = format!("https://example.org/{}", "é".repeat(super::MAX_DOCUMENT_URL - 20)); - assert!(document_url(&multibyte).is_ok(), "the limit counts characters, not bytes"); - assert_eq!(document_url(" https://example.org/ \n").as_deref(), Ok("https://example.org/"), "surrounding whitespace is trimmed"); - } - - #[test] - fn documents_may_not_offer_other_kinds_of_address() { - use super::{BlockedLink as B, MAX_DOCUMENT_URL, document_url}; - let scheme = |s: &str| Err(B::Scheme(s.into())); - assert_eq!(document_url("file:///C:/Windows/System32/calc.exe"), scheme("file")); - assert_eq!(document_url("FILE://server/share/x.exe"), scheme("file")); - assert_eq!(document_url("javascript:alert(1)"), scheme("javascript")); - assert_eq!(document_url("data:text/html,"), scheme("data")); - assert_eq!(document_url("ms-settings:privacy"), scheme("ms-settings")); - assert_eq!(document_url("smb://server/share"), scheme("smb")); - assert_eq!(document_url(""), Err(B::Empty)); - assert_eq!(document_url(" "), Err(B::Empty)); - for bad in [ - "example.org", - "//example.org", - "1http://x", - "ht tp://x", - "https:", - "https://", - "https:///path", - "https://user@/x", - "mailto:", - "https://trusted.example /x", - ] { - assert_eq!(document_url(bad), Err(B::Malformed), "{bad}"); - } - for hidden in [ - "https://exa\u{202E}gro.elpmaxe", - "https://example.org\u{200B}.evil", - "https://a.org/\u{0007}", - "https://a.org/x\ty", - "https://a.org/x\ny", - "https://example.org/a\u{00A0}b", - ] { - assert_eq!(document_url(hidden), Err(B::Hidden), "{hidden:?}"); - } - let long = format!("https://example.org/?q={}", "a".repeat(MAX_DOCUMENT_URL)); - assert!(matches!(document_url(&long), Err(B::TooLong(_)))); - } - - #[test] - fn the_host_shown_is_the_one_the_address_goes_to() { - use super::host; - assert_eq!(host("https://example.org/path"), Some("example.org")); - assert_eq!(host("https://trusted.example@other.example/"), Some("other.example")); - assert_eq!(host("http://user:pw@other.example:8080?x"), Some("other.example")); - assert_eq!(host("https://[::1]:8443/"), Some("[::1]")); - // Browsers read `\` as `/`, so this goes to other.example, not trusted.example. - assert_eq!(host("https://other.example\\@trusted.example/"), Some("other.example")); - assert_eq!(host("https://other.example\\x@trusted.example/"), Some("other.example")); - assert_eq!(host("mailto:a@example.org"), None); - assert_eq!(host("https:///nohost"), None); - } - - #[test] - fn addresses_the_url_parser_rejects_are_refused() { - use super::{BlockedLink as B, document_url}; - // The webbrowser crate would open each of these as a local file path. - for bad in ["mailto://a^b/x", "mailto://a:99999/", "mailto://[x]/", "mailto://a\\b/", "mailto://a b/", "https://a^b/", "http://ax"), scheme("data")); + assert_eq!(document_url("ms-settings:privacy"), scheme("ms-settings")); + assert_eq!(document_url("smb://server/share"), scheme("smb")); + assert_eq!(document_url(""), Err(B::Empty)); + assert_eq!(document_url(" "), Err(B::Empty)); + for bad in [ + "example.org", + "//example.org", + "1http://x", + "ht tp://x", + "https:", + "https://", + "https:///path", + "https://user@/x", + "mailto:", + "https://trusted.example /x", + ] { + assert_eq!(document_url(bad), Err(B::Malformed), "{bad}"); + } + for hidden in [ + "https://exa\u{202E}gro.elpmaxe", + "https://example.org\u{200B}.evil", + "https://a.org/\u{0007}", + "https://a.org/x\ty", + "https://a.org/x\ny", + "https://example.org/a\u{00A0}b", + ] { + assert_eq!(document_url(hidden), Err(B::Hidden), "{hidden:?}"); + } + let long = format!("https://example.org/?q={}", "a".repeat(MAX_DOCUMENT_URL)); + assert!(matches!(document_url(&long), Err(B::TooLong(_)))); +} + +#[test] +fn the_host_shown_is_the_one_the_address_goes_to() { + use super::host; + assert_eq!(host("https://example.org/path"), Some("example.org")); + assert_eq!(host("https://trusted.example@other.example/"), Some("other.example")); + assert_eq!(host("http://user:pw@other.example:8080?x"), Some("other.example")); + assert_eq!(host("https://[::1]:8443/"), Some("[::1]")); + // Browsers read `\` as `/`, so this goes to other.example, not trusted.example. + assert_eq!(host("https://other.example\\@trusted.example/"), Some("other.example")); + assert_eq!(host("https://other.example\\x@trusted.example/"), Some("other.example")); + assert_eq!(host("mailto:a@example.org"), None); + assert_eq!(host("https:///nohost"), None); +} + +#[test] +fn addresses_the_url_parser_rejects_are_refused() { + use super::{BlockedLink as B, document_url}; + // The webbrowser crate would open each of these as a local file path. + for bad in ["mailto://a^b/x", "mailto://a:99999/", "mailto://[x]/", "mailto://a\\b/", "mailto://a b/", "https://a^b/", "http://a f32 { impl Default for OcrSettings { fn default() -> Self { - OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true } + OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true, native_text_only: false } } } @@ -75,7 +80,7 @@ impl OcrPage { /// The recogniser, loaded once (it takes a moment) and shared. pub fn engine() -> Result, String> { static OCR: Mutex>> = Mutex::new(None); - let mut slot = OCR.lock().unwrap_or_else(|e| e.into_inner()); + let mut slot = OCR.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(o) = slot.as_ref() { return Ok(o.clone()); } @@ -105,7 +110,8 @@ impl OcrJob { /// Read the pages. `progress(done, total)` is called before each page; returning `false` /// stops (the pages read so far are returned). pub fn run(self, ocr: &Ocr, mut progress: impl FnMut(usize, usize) -> bool) -> Vec { - let config = RenderConfig { password: self.password.as_deref().map(Arc::from), ..Default::default() }; + let config = + RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() }; let mut r = PageRenderer::new(self.bytes.clone(), config); let mut out = Vec::new(); let total = self.pages.len(); diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs new file mode 100644 index 0000000000..32db07abee --- /dev/null +++ b/crates/engine/src/open.rs @@ -0,0 +1,357 @@ +//! Opening a document: preparing it away from the session, then registering it. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect}; + +use crate::history::{Editor, Keys, TextBlocksCache}; +use crate::{ + AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard, info_fields, js, + note_warnings, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, +}; + +/// Immutable session state needed while a document is prepared away from its owning thread. +#[derive(Clone)] +pub struct OpenContext { + now: Option, + js_off: bool, + trust: Arc, + xfa_time_scale: u32, +} + +/// A fully prepared document that has not yet been registered in a session. +pub struct PreparedDocument { + document: Document, +} + +struct DocumentOpenData<'a> { + name: String, + path: Option, + bytes: Arc>, + info: DocInfo, + structure: Result, Box>, + render_password: Option, + password: Option<&'a str>, + xfa: Option, +} + +impl OpenContext { + /// Prepare a document without mutating the session that supplied this context. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let name = name.into(); + guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) + .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + } +} + +impl Session { + /// Snapshot the session state that affects open-time scripts, dates and signature trust. + pub fn open_context(&self) -> OpenContext { + OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) } + } + + /// Prepare a document without adding it to this session. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + self.open_context().prepare_open(name, path, bytes, password) + } + + /// Register a prepared document. This is the only open step that mutates the session. + pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { + self.next_id += 1; + let id = DocId(self.next_id); + let mut document = prepared.document; + document.id = id; + self.docs.push(document); + id + } + + /// Synchronous convenience API used by the CLI and automation. + pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + let prepared = self.prepare_open(name, path, bytes, password)?; + Ok(self.commit_open(prepared)) + } + + fn prepare_open_unguarded( + context: &OpenContext, + name: String, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); + // The renderer authenticates on its own. It cannot use the owner password of R2–R4 + // files, so give it the user password that owner authentication recovers. + let (info, render_password) = match inspect(bytes.clone(), password) { + Ok(info) => (info, password.map(str::to_owned)), + Err(OpenError::WrongPassword) => { + let user = match &cos { + Ok(Ok(d)) => recovered_render_password(d), + _ => None, + }; + let user = user.ok_or(OpenError::WrongPassword)?; + (inspect(bytes.clone(), Some(&user))?, Some(user)) + } + Err(e) => return Err(e), + }; + // A dynamic XFA form is a shell around an XML template; lay the template out into real + // pages and fields so the rest of the engine works on it. The original bytes stay: the + // laid-out form is one appended revision. + let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + // Write `work` as one more revision and reopen it: what the document then is. + let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { + let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; + let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; + let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; + Ok((new_bytes, new_info, new_cos)) + }; + let (bytes, info, cos, xfa) = match (info.xfa, cos) { + (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { + let mut work = cos.clone(); + let laid_out = guard(|| xfa_layout(&mut work)) + .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) + .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); + match laid_out { + Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), + Err(e) => { + let mut info = info; + info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); + (bytes, info, Ok(Ok(cos)), None) + } + } + } + // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values + // filled in by another viewer since; give the fields those values. + (Some(_), Ok(Ok(cos))) => { + let report = pdfcraft_xfa::existing_layout(&cos); + let mut work = cos.clone(); + let synced = + guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); + match synced { + Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), + Ok(names) => match rebase(&work) { + Ok((b, mut i, c)) => { + // The fields were rewritten from the XFA data: say so, and which. + let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); + let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; + i.warnings.push(format!( + "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", + names.len(), + if names.len() == 1 { "" } else { "s" }, + shown.join(", ") + )); + (b, i, Ok(Ok(c)), report) + } + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + }, + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + } + } + (_, cos) => (bytes, info, cos, None), + }; + // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in + // Acrobat; what they change is one more revision, still nothing to save. + let mut script_output = js::JsOutput::default(); + let mut ran_away = false; + let xfa_template = match (&xfa, &cos) { + (Some(_), Ok(Ok(c))) => xfa::template(c), + _ => None, + }; + let (bytes, info, cos) = match (&xfa, cos) { + (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { + Some(tpl) => { + let mut work = cos.clone(); + let pages = info.pages.len(); + let ran = guard(|| { + let mut run = xfa::XfaRun { + page_count: pages, + out: &mut script_output, + datasets: None, + ran_away: false, + time_scale: context.xfa_time_scale, + }; + let changes = xfa::on_open(&mut work, &tpl, &mut run); + if run.ran_away { + ran_away = true; + } + changes + }) + .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); + match ran { + Ok(changes) if work.is_modified() => match rebase(&work) { + Ok((b, mut i, c)) => { + // The file as saved differs from what opening shows: say so. + if !changes.is_empty() { + i.warnings.push(format!( + "This form's scripts changed it on opening ({}); saving keeps those changes", + changes.describe() + )); + } + (b, i, Ok(Ok(c))) + } + Err(e) => { + script_output.errors.push(format!("The form's scripts could not be applied: {e}")); + (bytes, info, Ok(Ok(cos))) + } + }, + Ok(_) => (bytes, info, Ok(Ok(cos))), + Err(e) => { + script_output.errors.push(format!("The form's scripts could not run: {e}")); + (bytes, info, Ok(Ok(cos))) + } + } + } + None => (bytes, info, Ok(Ok(cos))), + }, + (_, cos) => (bytes, info, cos), + }; + // The catalog leads to no page, but the reader found pages by scanning: gather the page + // objects into a page tree as one more revision, or page edits address none of them + // (#1656). Kept only when the reader then shows the same pages, and never on a signed + // document, where writing the repair could rewrite the file under its signatures. + let (bytes, info, cos) = match cos { + Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { + let mut work = cos.clone(); + let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); + let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { + Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), + _ => None, + }; + match rebuilt { + Some((b, i, mut c)) => { + for line in cos.repair_log() { + c.note_repair(line.clone()); + } + c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); + (b, i, Ok(Ok(c))) + } + None => (bytes, info, Ok(Ok(cos))), + } + } + cos => (bytes, info, cos), + }; + // XFA notes survive the document being re-read after edits. + let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; + let mut document = + Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa }); + // A script that ran away on open turns the form's scripts off for this document. + document.xfa_template = if ran_away { None } else { xfa_template }; + document.js_output.append(script_output); + if ran_away { + document.xfa_scripts_off = true; + document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + note_warnings(&mut document.xfa_warnings, &xfa_warnings); + Ok(PreparedDocument { document }) + } + + /// The last step of opening: build the document record and register it. + fn build_document(trust: &Arc, data: DocumentOpenData<'_>) -> Document { + let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data; + let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; + let (mut editor, read_only_reason) = match cos { + Ok(Ok(cos)) => { + let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; + let saved = cos.bytes().clone(); + ( + Some(Editor { + cos, + saved, + saved_keys: keys.clone(), + unsaved_file: false, + undo: Vec::new(), + redo: Vec::new(), + keys, + appearances: AppearanceCache::default(), + field_appearances: pdfcraft_forms::FieldAppearanceCache::default(), + text_blocks: TextBlocksCache::default(), + }), + None, + ) + } + Ok(Err(e)) => (None, Some(e.to_string())), + Err(_) => (None, Some("the document structure could not be read for editing".into())), + }; + let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); + let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); + // The inspection reads only `/Info`; a file stating its title or author only in XMP still + // shows them (#2373). + if let Some(e) = &editor { + for (field, value) in info_fields(&mut info, &e.cos) { + if field.is_none() { + *field = value; + } + } + } + let display = display_bytes(editor.as_mut(), &bytes); + let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config)); + let (form, adopted) = editor + .as_ref() + .map(|e| { + let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos); + xfa::mark_script_buttons(&e.cos, &mut fields.items); + (fields.items, fields.adopted_page_fields) + }) + .unwrap_or_default(); + // Fields the file lists only as page widgets are adopted leniently; the file says so. + if adopted > 0 + && let Some(e) = &mut editor + { + let line = if adopted == 1 { + "the form's /Fields list omits 1 field; it was read from the page annotations".to_string() + } else { + format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations") + }; + e.cos.note_repair(line); + } + Document { + id: DocId(0), + name, + path, + bytes, + display, + info, + renderer, + password: render_password, + dirty: false, + generation: 0, + snapshot_generation: 0, + read_only_reason, + form: Arc::new(form), + marker_scan: OnceLock::new(), + links: OnceLock::new(), + signatures: Arc::new(signatures), + trust: trust.clone(), + sig_cache, + editor, + config, + js_output: Default::default(), + xfa, + xfa_template: None, + xfa_scripts_off: false, + fonts_stale: false, + xfa_warnings: Vec::new(), + edit_notices: Vec::new(), + } + } +} diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index ee8c957f93..42af737bab 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -68,7 +68,11 @@ impl Session { cos: editor.cos.clone(), settings: settings.clone(), discard: discard.to_vec(), - opts: SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }, + opts: SaveOptions { + mod_date: self.now().map(pdfcraft_cos::pdf_date), + object_streams: settings.effective_object_streams(), + ..SaveOptions::default() + }, }) } } @@ -83,6 +87,9 @@ impl OptimizeJob { fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result { let OptimizeJob { mut cos, settings, discard, opts } = self; + // Like every file written, without the values of Password-flag fields (#3926, #3944). + pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; + pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; step(OptimizeStage::Discarding)?; let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; diff --git a/crates/engine/src/page_filter.rs b/crates/engine/src/page_filter.rs new file mode 100644 index 0000000000..aaedfbd65c --- /dev/null +++ b/crates/engine/src/page_filter.rs @@ -0,0 +1,41 @@ +//! Page filters: even or odd page numbers, and orientation as displayed. + +/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and +/// orientation (as displayed). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageParity { + #[default] + Both, + Even, + Odd, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrientation { + #[default] + Both, + Landscape, + Portrait, +} + +/// The pages of `pages` (0-based) that pass the filters. +pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec { + pages + .iter() + .copied() + .filter(|p| match parity { + PageParity::Both => true, + // Page numbers are 1-based: index 1 is page 2, an even page. + PageParity::Even => p % 2 == 1, + PageParity::Odd => p % 2 == 0, + }) + .filter(|p| { + let Some(pg) = info.pages.get(*p) else { return false }; + match orientation { + PageOrientation::Both => true, + PageOrientation::Landscape => pg.width > pg.height, + PageOrientation::Portrait => pg.width <= pg.height, + } + }) + .collect() +} diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs new file mode 100644 index 0000000000..b29941720a --- /dev/null +++ b/crates/engine/src/pictures.rs @@ -0,0 +1,59 @@ +//! Pictures placed on pages: added images, and the pictures of watermarks and backgrounds. + +use std::sync::Arc; + +use crate::EditError; + +/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` +/// (0-based) of a PDF. +#[derive(Clone, Debug, PartialEq)] +pub struct MarkFile { + pub name: String, + pub bytes: Arc>, + pub page: usize, +} + +/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. +pub(crate) fn embed_image( + doc: &mut pdfcraft_cos::Document, + name: &str, + bytes: &[u8], +) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { + pdfcraft_create::image_xobject(doc, name, bytes) +} + +/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its +/// shape in a box of any other shape. A box that is already that shape (to within rounding) is +/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite +/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. +pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { + if !rect.iter().all(|v| v.is_finite()) { + return rect; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]); + let usable = |v: f64| v.is_finite() && v > 0.0; + if !(usable(bw) && usable(bh) && usable(w) && usable(h)) { + return rect; + } + let k = (bw / w).min(bh / h); + let (fw, fh) = (w * k, h * k); + if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 { + return rect; + } + let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0); + [cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0] +} + +/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. +pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { + let head = &f.bytes[..f.bytes.len().min(1024)]; + if head.windows(5).any(|w| w == b"%PDF-") { + let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; + let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?; + Ok(pdfcraft_edit::MarkSource { xobject, size, image: false }) + } else { + let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?; + Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) + } +} diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs index 117c8e14cc..4ed5895860 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -1,25 +1,33 @@ //! Print as image (Acrobat's Advanced Print Setup ▸ Print As Image): every page //! sent to the printer as a picture at a chosen resolution, for printers that skip text, //! shading or transparency. The pages are rendered the way the viewer shows them (reading -//! colours off, the print's own colours), made into an image-only document, and that document -//! is imposed on the paper like any other. The print is no longer searchable text. +//! colours off, the print's own colours) with only what the print's content choice keeps, made +//! into an image-only document, and that document is imposed on the paper like any other. The +//! print is no longer searchable text. +use pdfcraft_cos::{SaveOptions, write_incremental}; use pdfcraft_create::ImageResolution; -use pdfcraft_print::Raster; +use pdfcraft_print::{Content, Raster}; use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest}; -use crate::Document; use crate::export::encode_png; +use crate::{Document, guard}; /// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one -/// page per entry, the size of the source page as displayed. Pages too large for the renderer -/// at `raster.dpi` render at the largest resolution it allows. -pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, hide_comments: bool) -> Result, String> { +/// page per entry, the size of the source page as displayed, drawing what `content` prints. +/// Pages too large for the renderer at `raster.dpi` render at the largest resolution it allows. +pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Content) -> Result, String> { let dpi = raster.dpi.clamp(72.0, 600.0); let src = doc.export_source(); let mut config = src.config.clone(); - config.hide_comments = hide_comments; - let mut renderer = PageRenderer::new(src.bytes, config); + // The copy below holds only what prints, so the viewer's own comment hiding plays no part. + config.hide_comments = false; + let printed = guard(|| -> Result, String> { + let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?; + pdfcraft_print::keep_printed(&mut cos, content); + write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string()) + })??; + let mut renderer = PageRenderer::new(std::sync::Arc::new(printed), config); let mut images: Vec<(String, Vec)> = Vec::with_capacity(pages.len()); let mut used: Option = None; for &page in pages { diff --git a/crates/engine/src/protection.rs b/crates/engine/src/protection.rs new file mode 100644 index 0000000000..e66f01538a --- /dev/null +++ b/crates/engine/src/protection.rs @@ -0,0 +1,158 @@ +//! Password protection to apply, and the security a document has. + +use crate::EditError; + +/// Document Properties ▸ Security. +#[derive(Clone, Debug, PartialEq)] +pub struct SecuritySummary { + /// "AES, 256-bit" etc. + pub method: String, + /// Opened with the owner password (no restrictions apply). + pub owner: bool, + /// For pending protection: the restrictions as they will apply to others. + pub permissions: pdfcraft_cos::Permissions, + /// Set in this session; written by the next save. + pub pending: bool, +} + +/// What printing a protected document allows (Acrobat: Printing allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Printing { + None, + /// "Low Resolution (150 dpi)". + Low, + High, +} + +/// What changes a protected document allows (Acrobat: Changes allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Changes { + None, + /// "Inserting, deleting, and rotating pages". + Pages, + /// "Filling in form fields and signing existing signature fields". + FillSign, + /// "Commenting, filling in form fields, and signing existing signature fields". + CommentFillSign, + /// "Any except extracting pages". + AnyExceptExtract, +} + +/// Password protection to apply (Protect Using Password and its Advanced options). +#[derive(Clone, PartialEq)] +pub struct Protection { + /// Required to open the document (the user password). + pub open_password: Option, + /// Required to change security and lift the restrictions below (the owner password). + pub permissions_password: Option, + pub printing: Printing, + pub changes: Changes, + /// "Enable copying of text, images, and other content". + pub copy: bool, + /// "Enable text access for screen reader devices". + pub accessibility: bool, + /// Compatibility level; AES-256 (Acrobat X and later) by default. + pub algorithm: pdfcraft_cos::Algorithm, + /// `false`: "Encrypt all document contents except metadata". + pub encrypt_metadata: bool, +} + +impl std::fmt::Debug for Protection { + // Never print passwords (edits end up in logs and error messages). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Protection") + .field("open_password", &self.open_password.as_ref().map(|_| "…")) + .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) + .field("printing", &self.printing) + .field("changes", &self.changes) + .field("copy", &self.copy) + .field("accessibility", &self.accessibility) + .field("algorithm", &self.algorithm) + .field("encrypt_metadata", &self.encrypt_metadata) + .finish() + } +} + +impl Default for Protection { + /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. + fn default() -> Self { + Self { + open_password: None, + permissions_password: None, + printing: Printing::High, + changes: Changes::None, + copy: false, + accessibility: true, + algorithm: pdfcraft_cos::Algorithm::Aes256, + encrypt_metadata: true, + } + } +} + +impl Protection { + /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. + pub fn permission_bits(&self) -> i32 { + let bit = |n: u32| 1i32 << (n - 1); + if self.permissions_password.is_none() { + return -1; // nothing restricted without a permissions password + } + let mut p = 0; + match self.printing { + Printing::None => {} + Printing::Low => p |= bit(3), + Printing::High => p |= bit(3) | bit(12), + } + p |= match self.changes { + Changes::None => 0, + Changes::Pages => bit(11), + Changes::FillSign => bit(9), + Changes::CommentFillSign => bit(6) | bit(9), + Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), + }; + if self.copy { + p |= bit(5) | bit(10); + } + if self.accessibility { + p |= bit(10); + } + p + } + + pub(crate) fn validate(&self) -> Result<(), EditError> { + let bad = |m: &str| Err(EditError::Protection(m.to_string())); + match (&self.open_password, &self.permissions_password) { + (None, None) => return bad("enter a password to open the document, a permissions password, or both"), + (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), + _ => {} + } + if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) { + return bad("passwords can't be empty"); + } + if self.algorithm != pdfcraft_cos::Algorithm::Aes256 + && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) + { + return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); + } + // Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep, + // which drops soft hyphens and folds compatibility forms; older levels: the first 32 + // bytes, padded). One that comes out as the empty password opens the file without asking, + // and an open password that comes out as the permissions password lifts the restrictions. + let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p); + let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective)); + let empty = effective(""); + if open.iter().chain(&permissions).any(|p| *p == empty) { + return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"); + } + if open.is_some() && open == permissions { + return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)"); + } + Ok(()) + } +} + +/// Opened as owner, or nothing is restricted (no permissions password was set): security may +/// be changed, as in Acrobat. +pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { + const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 + p.owner || p.bits & ALL == ALL +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs new file mode 100644 index 0000000000..9a4cb04dac --- /dev/null +++ b/crates/engine/src/session.rs @@ -0,0 +1,48 @@ +//! The session: the open documents and the operations on them. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; +use pdfcraft_render::{LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; + +#[cfg(test)] +use crate::check_refresh_panic; +use crate::edit::{EditCtx, check_permission, run_edit}; +use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; +use crate::{ + BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution, + LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, SummarySort, TrustStore, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, display_config, guard, + info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster, + render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, + use_layer_choices, writable, xfa, xfa_sync_datasets, +}; + +mod editing; +mod files; +mod save; +mod view; + +#[derive(Default)] +pub struct Session { + pub(crate) docs: Vec, + pub(crate) next_id: u64, + /// Seconds since the Unix epoch, injected so saves are deterministic in tests. + clock: Option i64>, + /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). + /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the + /// injected time AS local time and zeroes the zone, so injecting only it leaves either the + /// local dates or the UTC ones wrong by the zone's offset. + utc_offset: Option i64>, + /// Certificates trusted for signing (Acrobat: Trusted Certificates). + pub(crate) trust: Arc, + /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). + pub(crate) js_off: bool, + /// Preferences ▸ Date format, when not the default (see [`dates`]). + pub(crate) date_format: Option, + /// Preferences ▸ Date format ▸ Language, when not following the interface language. + pub(crate) date_language: Option, + /// What the XFA scripts' time limits are multiplied by (`None`: 1); see + /// [`Session::with_xfa_time_scale`]. + pub(crate) xfa_time_scale: Option, +} diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs new file mode 100644 index 0000000000..7758dc7c85 --- /dev/null +++ b/crates/engine/src/session/editing.rs @@ -0,0 +1,541 @@ +#[cfg(test)] +use super::check_refresh_panic; +use super::{ + Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, display_config, guard, info_fields, inspect, js, keys_after, + note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, + uses_scripts, write_incremental, xfa, xfa_sync_datasets, +}; +use crate::{EditNotice, MarkKind}; + +impl Session { + /// Apply an edit. On success the previous state is undoable and the view data is refreshed. + pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { + let now = self.now(); + let today = self.today(); + let js_off = self.js_off; + let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); + let doc = self.doc_mut(id)?; + doc.edit_notices.clear(); + let name = doc.name.clone(); + let is_xfa = doc.info.xfa.is_some(); + let mut scope = scope_of(&edit); + let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48)); + if !js_off { + cx.xfa = doc.xfa_template.clone(); + } + cx.xfa_time_scale = xfa_time_scale; + cx.pages = doc.info.pages.len(); + cx.today = today; + let reason = doc.read_only_reason.clone().unwrap_or_default(); + let signed = doc.is_signed(); + let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; + if let Some(p) = editor.cos.permissions() { + check_permission(&edit, &p)?; + } + // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it + // in the paper's colour and draw the new text in the ink's. + let edit = match edit { + Edit::EditTextBlock { page, block, text, mut style } => { + // Only invisible text is sampled; the blocks are usually cached from opening it. + if style.cover.is_none() + && let Some(info) = doc.info.pages.get(page) + && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) + && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() + { + style.cover = Some(paper); + style.color = style.color.or(Some(ink)); + } + Edit::EditTextBlock { page, block, text, style } + } + other => other, + }; + // Nothing to bake: leave undo history and the dirty flag alone. + if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { + return Ok(()); + } + let mut next = editor.cos.clone(); + if !js_off && uses_scripts(&edit) { + cx.js = Some(js::JsRunner::new(&next, &name)); + } + cx.renamed_fields.clear(); + cx.redaction_overlays.clear(); + // `next` is a copy: if the edit fails or crashes, the document is unchanged. + guard(|| run_edit(&mut next, &edit, &mut cx)) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + // Redaction removed what its own interpreter places under the marks. The marks were drawn + // over the page as the renderer shows it, so the renderer has the last word. + if let Edit::ApplyRedactions { pages } = &edit { + let marks: Vec = + pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + } + // Renamed fields are said, not slipped in: a repair-log note (with the caveat that + // scripts were left alone) and a count in the undo step's label. + let renamed_note = |renamed: &[(String, String)]| { + (!renamed.is_empty()).then(|| { + let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); + let more = renamed.len().saturating_sub(shown.len()); + let more = if more > 0 { format!(", and {more} more") } else { String::new() }; + format!( + "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", + renamed.len(), + if renamed.len() == 1 { "" } else { "s" }, + shown.join(", ") + ) + }) + }; + if let Some(note) = renamed_note(&cx.renamed_fields) { + next.note_repair(note); + } + // Only Insert from File renames fields; its label says so in a wording the interface + // translates (the count lives in the repair note). + let label = match (&edit, cx.renamed_fields.len()) { + (_, 0) => edit.label(), + (Edit::InsertPagesFrom { name, .. }, n) => Label::with( + if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, + name.as_str(), + ), + _ => edit.label(), + }; + // An XFA form's scripts answer the change: exit and validate scripts of the field, then + // every calculation (and a reset recalculates). They read the data, so it is brought up + // to date first. + if let Some(tpl) = cx.xfa.clone() { + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let datasets = &mut cx.xfa_datasets; + let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + cx.xfa_out.errors.extend(notes); + } + fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { + match e { + Edit::SetFieldValue { name, .. } => changed.push(name.clone()), + Edit::ResetForm { .. } => *reset = true, + Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), + _ => {} + } + } + let mut changed: Vec = Vec::new(); + let mut reset = false; + collect(&edit, &mut changed, &mut reset); + if !changed.is_empty() || reset { + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + time_scale: cx.xfa_time_scale, + }; + // Every changed field's scripts, then the calculations once (after a reset, + // only those). + let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) + .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = ran { + cx.xfa_out.errors.push(e); + } + } + } + // XFA forms keep their values in the datasets packet too, for Adobe's viewers. + let mut xfa_notes = Vec::new(); + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); + xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + // Rebuilding from stale data would discard accepted widget values. + if xfa_notes.is_empty() { + let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form)) + .unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + if let Some(warnings) = reflow { + xfa_notes.extend(warnings); + // Layout replaces pages and widgets, even if the page count is unchanged. + // Undo/redo must rebuild these too instead of refreshing only field values. + scope = Scope::Full; + } + } + } + // Signed documents are only ever saved incrementally: an edit that needs a full rewrite + // (applying redactions, changing security, sanitizing) would invalidate the signatures. + // So does any edit to a file rebuilt from object headers: it has no chain to append to. + let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); + if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { + return Err(EditError::SignedRewrite(edit.label().to_string())); + } + let notices = edit_notices(&edit, &editor.cos, &next); + let previous = std::mem::replace(&mut editor.cos, next); + let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); + let previous_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label, previous, previous_keys, scope)); + if editor.undo.len() > MAX_UNDO { + editor.undo.remove(0); + } + editor.redo.clear(); + Self::adopt_keys(doc); + let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) + .unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + // Roll back: the edit produced something we cannot display. + if let Some(ed) = doc.editor.as_mut() + && let Some((_, prev, keys, _)) = ed.undo.pop() + { + ed.cos = prev; + ed.keys = keys; + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.dirty = true; + doc.generation += 1; + doc.edit_notices = notices; + if !cx.xfa_removed.is_empty() { + let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + note_warnings(&mut doc.xfa_warnings, &removed); + note_warnings(&mut doc.info.warnings, &removed); + } + note_warnings(&mut doc.xfa_warnings, &xfa_notes); + let notes = doc.xfa_warnings.clone(); + note_warnings(&mut doc.info.warnings, ¬es); + if let Some(js) = cx.js { + doc.js_output.append(js.output); + } + doc.js_output.append(cx.xfa_out); + if cx.xfa_ran_away { + doc.xfa_template = None; + doc.xfa_scripts_off = true; + doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + Ok(()) + } + + pub fn undo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; + let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?; + let current = std::mem::replace(&mut editor.cos, prev); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.redo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.redo.pop() + { + let prev = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.undo.push((label, prev, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + pub fn redo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; + let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?; + let current = std::mem::replace(&mut editor.cos, next); + let current_keys = std::mem::replace(&mut editor.keys, keys); + editor.undo.push((label.clone(), current, current_keys, scope)); + Self::adopt_keys(doc); + let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + if let Some(ed) = doc.editor.as_mut() + && let Some((label, current, current_keys, scope)) = ed.undo.pop() + { + let next = std::mem::replace(&mut ed.cos, current); + let keys = std::mem::replace(&mut ed.keys, current_keys); + ed.redo.push((label, next, keys, scope)); + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.edit_notices.clear(); + Self::sync_dirty(doc); + doc.generation += 1; + Ok(label) + } + + /// Unsaved when the working graph is not the last opened or saved file. Undo back to that + /// file is clean; undo past a later save is not, because the restored snapshot's own base + /// is an earlier revision. + pub(super) fn sync_dirty(doc: &mut Document) { + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + } + + /// Use the current state's password for viewing (protection edits change it). + pub(super) fn adopt_keys(doc: &mut Document) { + if let Some(e) = doc.editor.as_ref() { + doc.password = e.keys.render.clone(); + doc.config.password = e.keys.render.as_deref().map(Arc::from); + } + } + + /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits + /// keep the renderer; other scopes also replace it. + /// Comment and form edits skip the full re-inspection (seconds on very large files): the + /// comment list and field values are re-read from the object graph instead. + fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { + if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { + return Self::refresh(doc); + } + if scope == Scope::Content { + return Self::refresh_content(doc, None); + } + if scope == Scope::Links { + return Self::refresh_links(doc); + } + // Metadata edits keep the renderer, which holds the working file anyway. + if scope != Scope::Metadata { + Self::release_working(doc); + } + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + // Comment edits, and form edits (a script may change annotation properties), may change + // annotation dictionaries, invalidating cached appearances. + if matches!(scope, Scope::Comments | Scope::Form) { + editor.appearances.clear(); + } + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + if scope == Scope::Metadata { + for (field, value) in info_fields(&mut doc.info, &editor.cos) { + *field = value; + } + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); + doc.info.file_size = bytes.len(); + #[cfg(test)] + check_refresh_panic(); + if !doc.signatures.is_empty() { + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.signatures = Arc::new(signatures); + } + doc.bytes = bytes; + return Ok(()); + } + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + let form = Arc::new(form); + match scope { + Scope::Metadata => return Ok(()), + Scope::Comments => { + doc.info.annotations = comment_list(&editor.cos); + // Link indexes are positions in /Annots, so comment insertion/deletion can move them. + doc.links = OnceLock::new(); + } + Scope::Form => { + // PERF: keystroke path, index once. A linear find per field is O(F^2); + // forms reach 10k fields. + let mut by_name = std::collections::HashMap::with_capacity(form.len()); + for x in form.iter() { + // First match wins (do not simplify to collect: it keeps the last). + by_name.entry(x.name.as_str()).or_insert(x); + } + for f in &mut doc.info.fields { + if let Some(ff) = by_name.get(f.name.as_str()) { + f.value = match ff.kind { + pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { + Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) + } + _ if ff.value.is_empty() => None, + _ => Some(ff.value.join(", ")), + }; + } + } + } + Scope::Links => return Self::refresh_links(doc), + // Handled above; kept as a full refresh so the match stays total. + Scope::Full => return Self::refresh(doc), + // Handled above (content-only refresh); the arm keeps the match total. + Scope::Content => return Self::refresh_content(doc, None), + } + doc.info.file_size = bytes.len(); + doc.form = form; + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.bytes = bytes; + Ok(()) + } + + /// Which derived lists a content-only edit can change: added content (`None`, from + /// undo/redo, re-reads both: always correct, rarely paid). + pub(crate) fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { + if let Some(Edit::Batch { edits, .. }) = edit { + // A batch of content edits (from a script, say) changes what its parts change. + return edits.iter().fold((false, false), |(added, marks), part| { + let (a, m) = Self::content_refresh_lists(Some(part)); + (added || a, marks || m) + }); + } + let touches_added = edit.is_none_or(|e| { + matches!( + e, + Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) + }); + let touches_marks = edit.is_none_or(|e| { + matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + }); + (touches_added, touches_marks) + } + + /// Rebuild working bytes, display bytes and renderer after a content-only edit: page + /// content streams, resources and added content/marks. The page tree, annotations, form + /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on + /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, + /// from undo/redo, re-reads both: always correct, rarely paid). The cached display + /// appearances are kept: content edits leave every annotation dictionary alone. + fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + // The edit must still leave a file the viewer can open; anything else rolls back above. + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; + let (touches_added, touches_marks) = Self::content_refresh_lists(edit); + if touches_added || touches_marks { + doc.marker_scan = OnceLock::new(); + } + doc.info.file_size = bytes.len(); + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.bytes = bytes; + doc.fonts_stale = true; + Ok(()) + } + + /// Let go of the working file before the next one is written (gap 13). The working file is + /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the + /// working bytes, the display bytes and the renderer) while writing the new one held three + /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes + /// are the original's (already held by the editor, so free) and the renderer draws nothing. + /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh + /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes + /// from the editor, never from these. + pub(crate) fn release_working(doc: &mut Document) { + let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; + // Dropping the pool stops its workers; each lets go of the file when it exits. + doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone()); + doc.display = original.clone(); + doc.bytes = original; + #[cfg(test)] + check_refresh_panic(); + } + + /// After a refresh failed: draw the display bytes it left (the original, once released), + /// so the page still shows a document. + pub(super) fn show_released(doc: &mut Document) { + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + } + + /// Rebuild working bytes, inspection and renderer from the current edit state. + pub(crate) fn refresh(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, true, true) + } + + /// Reinspect a link edit while preserving the page-content marker scan cache. + fn refresh_links(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, false, true) + } + + fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { + // A full refresh may follow structural or annotation changes: cached appearances + // may be stale, so they are rebuilt below. + if let Some(editor) = doc.editor.as_mut() { + editor.appearances.clear(); + } + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + // Keep the user's layer choices where the layers still exist. + let mut info = info; + for l in &mut info.layers { + if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { + l.visible = old.visible; + } + } + note_warnings(&mut info.warnings, &doc.xfa_warnings); + // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP + // are filled in again after every full refresh. + for (field, value) in info_fields(&mut info, &editor.cos) { + if field.is_none() { + *field = value; + } + } + doc.info = info; + doc.fonts_stale = false; + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + doc.form = Arc::new(form); + if invalidate_markers { + doc.marker_scan = OnceLock::new(); + } + if invalidate_links { + doc.links = OnceLock::new(); + } + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.bytes = bytes; + Ok(()) + } +} + +/// What `edit`, taking `prev` to `next`, wants said: never silent (AGENTS.md §4). +fn edit_notices(edit: &Edit, prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { + // What it drew with `?` (a value or comment text its font can't draw, stored as typed). + let mut notices: Vec = if matches!(scope_of(edit), Scope::Form | Scope::Comments | Scope::Full) { + guard(|| undrawn_notices(prev, next)).unwrap_or_default().into_iter().map(EditNotice::Undrawn).collect() + } else { + Vec::new() + }; + // Pages whose own white backdrop Remove background couldn't turn back on. + if matches!(edit, Edit::RemoveMarks { kind: MarkKind::Background }) { + let pages = guard(|| pdfcraft_edit::backdrops_left_off(next)).unwrap_or_default(); + if !pages.is_empty() { + notices.push(EditNotice::BackdropsLeftOff { pages }); + } + } + notices +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs new file mode 100644 index 0000000000..34d75e5c82 --- /dev/null +++ b/crates/engine/src/session/files.rs @@ -0,0 +1,403 @@ +use super::{ + Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, + Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state, + source_kind, writable, write_full, +}; + +impl Session { + /// The page count of another PDF (Replace Pages, Insert Pages dialogs). + pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) + } + + /// A new blank document (Create ▸ Blank page). + pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { + self.write_new(&pdfcraft_create::blank(width, height, pages)?) + } + + /// A new document with one page per image (PNG, JPEG). + pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images(images)?) + } + + /// Create image pages at embedded resolution or a fixed dpi, without resampling. + pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) + } + + /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// refused, naming the line and the character (never set as `?`). + pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) + } + + /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, + /// compare reports), which can quote any language: characters Helvetica can't draw are written + /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { + let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; + self.write_new(&doc) + } + + /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked + /// (it must open and allow copying pages) and returned as it is. + pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + let Some(kind) = source_kind(name, bytes) else { + return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + }; + let title = name.rsplit_once('.').map_or(name, |(s, _)| s); + // Image decoders read untrusted bytes: a panic in one must not take the app down. + let created = guard(|| match kind { + SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), + SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), + SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + }) + .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; + Ok((kind, created?)) + } + + /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as + /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf + /// without a `%PDF-` header, which the reader may still open. + pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = match source_kind(name, bytes) { + Some(SourceKind::Pdf) => true, + Some(SourceKind::Image | SourceKind::Text) => false, + None => name.to_ascii_lowercase().ends_with(".pdf"), + }; + if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + } + + /// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi, + /// JPEG medium quality; black-and-white averaged down to 300 ppi above 450 ppi, and stored as + /// CCITT Group 4 whenever smaller; thumbnails dropped), identical resources merged, unused + /// objects dropped, objects packed into compressed object streams. Returns the bytes and how + /// many objects were merged. The open document is not changed (Acrobat saves the reduced copy + /// as a new file). + pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { + let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; + Ok((bytes, report.merged)) + } + + /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove + /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents + /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in + /// place, without progress. + pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { + self.optimize_job(id, settings, discard)?.run(|_| true) + } + + /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text + /// are form data only. + pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. + let xfa = doc.info.xfa.is_some(); + if format == DataFormat::Xdp && !xfa { + return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); + } + guard(|| match format { + DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), + DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), + DataFormat::Xml | DataFormat::Xdp if xfa => { + pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) + } + other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), + }) + .map_err(EditError::Write)? + .map_err(EditError::Write) + } + + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). + pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if !doc.allows_printing() { + return Err(EditError::NotPermitted("printing")); + } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); + // Print as image: the pages used become pictures first, then the picture document is + // imposed in their place (its page k is the k-th distinct source page used). + if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { + let mut distinct: Vec = Vec::new(); + for &p in &settings.pages { + if !distinct.contains(&p) { + distinct.push(p); + } + } + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; + let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; + let remapped = print::Settings { + pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), + content: print::Content::Document, + raster: None, + ..settings.clone() + }; + return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); + } + let mut cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => { + pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? + } + }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } + pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) + } + + /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); + /// only `pages` are read, the others are `false`. + pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { + let Some(doc) = self.get(id) else { return Vec::new() }; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }, + }; + (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + } + + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). + pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { + let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; + let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); + let out = pdfcraft_organize::combine(&named)?; + self.write_new(&out) + } + + /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). + pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { + self.combine_unlocked(sources, &[]) + } + + /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as + /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see + /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a + /// time: a source is opened, its pages are copied into the result, and its bytes are released + /// before the next item is read. So the number of sources is not limited, and one source's + /// document is held at a time. An item's error stops the combine and is returned as it is. + /// `Ok(None)` when there are no sources. + pub fn combine_each(&self, sources: I) -> Result>>, E> + where + I: IntoIterator>, + E: From, + { + let mut combiner = pdfcraft_organize::Combiner::new(); + let mut added = false; + for source in sources { + let (title, bytes, range) = source?; + let doc = open_source(&title, &bytes)?; + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let pages = match range { + Some(r) => { + let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{title}: {e}")))?; + Some(p) + } + None => None, + }; + combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; + added = true; + } + if !added { + return Ok(None); + } + let out = combiner.finish().map_err(EditError::from)?; + Ok(Some(self.write_new(&out)?)) + } + + /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by + /// position (missing or `None`: no password). The result is not encrypted. + pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// The pre-flight over byte sources: what combining them, in order, would rename in each one + /// (whole files; page ranges are the combine's to apply). A source that cannot be opened + /// previews as nothing — its row already says why it cannot be combined. + pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { + let docs: Vec> = + sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); + let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); + let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); + // One preview per source, in step with it: a file that cannot be opened has none. + docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() + } + + /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. + pub fn combine_unlocked_reporting( + &self, + sources: &[CombineSource], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let docs = sources + .iter() + .enumerate() + .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) + .collect::, _>>()?; + let mut pages = Vec::with_capacity(docs.len()); + for ((name, _, range), d) in sources.iter().zip(&docs) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = + sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); + let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one + /// file split into several runs placed apart: each file is opened and copied once, so it keeps + /// one bookmark, its links between its own pages, its fields and its attachments whole (see + /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes + /// and password; a source without a key is a file of its own. + pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. + pub fn combine_grouped_reporting( + &self, + sources: &[CombineSource], + groups: &[u64], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); + for (i, (name, bytes, _)) in sources.iter().enumerate() { + let key = groups.get(i).copied(); + let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); + let at = match known { + Some(at) => at, + None => { + docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); + keys.push(key); + docs.len() - 1 + } + }; + file.push(at); + } + let mut pages = Vec::with_capacity(sources.len()); + for ((name, _, range), at) in sources.iter().zip(&file) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let mut runs: Vec> = Vec::with_capacity(sources.len()); + for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + runs.push((*at, name.as_str(), d, p.as_deref())); + } + let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// New PDF bytes containing copies of `pages` of the document (Extract Pages). + pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("extracting pages")); + } + let out = pdfcraft_organize::extract_pages(src, pages)?; + self.write_new(&out) + } + + /// Split the document into several new PDFs. + pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + pdfcraft_organize::split_ranges(n, by) + .into_iter() + .map(|r| { + let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; + Ok((r.start + 1, r.end, self.write_new(&doc)?)) + }) + .collect() + } + + /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in + /// order while their size (as single-page files, an over-estimate because shared fonts and + /// images count once per part) fits; a page larger than the limit is a part of its own. + pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + let mut cuts = Vec::new(); + let mut used = 0usize; + for p in 0..n { + let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); + if used > 0 && used + size > max_bytes { + cuts.push(p); + used = 0; + } + used += size; + } + self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) + } + + fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) + } + + /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone + /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. + fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let bytes = self.write_unchecked(doc)?; + let expected = pdfcraft_organize::page_count(doc)?; + pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; + Ok(bytes) + } + + /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and + /// Split copy the fields of the pages they take. + fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let doc = writable(doc)?; + write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } +} diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs new file mode 100644 index 0000000000..1e6918f6a6 --- /dev/null +++ b/crates/engine/src/session/save.rs @@ -0,0 +1,338 @@ +use super::{ + Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, + display_config, guard, render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets, +}; + +impl Session { + /// The bytes to write for Save: an incremental update of the file as opened/last saved, + /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. + /// + /// A file whose cross-reference data had to be rebuilt is written in full even when nothing + /// was edited: its bytes as opened are the damaged file, and this save is how the repair the + /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would + /// break its signatures, so with no edits it keeps its bytes, damage and all. + pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; + let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); + if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { + return Ok(editor.saved.clone()); + } + // What the user removed again before saving (a cleared redaction mark) stays out of the file. + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; + // A restored snapshot can be based on an earlier revision. Append to the last saved + // file so that revision stays in the chain. A full rewrite (redaction, new encryption, + // a reconstructed file) still writes the current graph on its own. + let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; + guard(|| { + if on_saved || rewrite { + write_incremental(&cos, &opts) + } else { + let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; + base.overlay_changes_from(&cos)?; + write_incremental(&base, &opts) + } + }) + .map_err(EditError::Write)? + .map(Arc::new) + .map_err(|e| EditError::Write(e.to_string())) + } + + /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). + pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.is_signed() { + return Err(EditError::Signed); + } + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } + + /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this + /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the + /// file has been replaced. Same error as `mark_saved`. + pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; + pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) + } + + /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file + /// so the next save appends only newer edits. Undo history is kept. + pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + if let Some(editor) = doc.editor.as_mut() { + // An encrypted file needs the password it is protected with now (the owner + // password when known, so saving never downgrades this session's rights). + let keys = editor.keys.clone(); + // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. + let typed = pdfcraft_forms::password_values(&editor.cos); + editor.cos = + pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; + if !typed.is_empty() && doc.info.xfa.is_some() { + let mut datasets = None; + let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); + } + editor.saved = bytes; + editor.saved_keys = keys; + editor.unsaved_file = false; + } + if let Some(p) = path { + doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); + doc.path = Some(p); + } + doc.dirty = false; + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// Enter the permissions (owner) password of a document opened with its user password, + /// lifting its restrictions for this session (Acrobat asks for it before changing security). + /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states + /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents + /// opened by the owner need nothing. + pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + if editor.cos.permissions().is_none_or(|p| p.owner) { + return Ok(()); + } + editor.cos.unlock(password).map_err(|e| match e { + pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, + other => EditError::Protection(other.to_string()), + })?; + // Saving a state that keeps the security it was opened with reopens it with the same + // passwords, so it reopens as owner. + let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { + if !cos.encryption_changed() { + keys.reopen = Some(password.to_owned()); + } + }; + reopen_as_owner(&editor.cos, &mut editor.keys); + // Other states with the same restricted access are unlocked too; states opened with + // other passwords (protection changed and saved since) stay as they are. + for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { + if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { + reopen_as_owner(cos, keys); + } + } + Ok(()) + } + + /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is + /// cleared, as in Acrobat. + pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let base = editor.saved.clone(); + // The saved file opens with the passwords it was saved with. The current snapshot may + // be an older revision (undo past that save), so its own base is not the saved file. + let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; + candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); + candidates.push(Keys::default()); + let (cos, keys) = candidates + .into_iter() + .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) + .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; + editor.cos = cos; + editor.keys = keys; + editor.undo.clear(); + editor.redo.clear(); + Self::adopt_keys(doc); + Self::sync_dirty(doc); + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open + /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA + /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, + /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was + /// opened with is tried; on any failure the document is left as it was. + pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { + let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; + let doc = &self.docs[old]; + let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); + let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); + let new_id = self.open(name, path, bytes, password.as_deref())?; + let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?; + let mut doc = self.docs.remove(at); + doc.id = id; + // How the document is shown is the reader's, not the file's: reading colours and hidden + // comments stay as they were (#3443). `open` built the renderer with the defaults. + if let Some(old) = self.docs.get(old) + && (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments) + { + doc.config.reading_colors = old.config.reading_colors; + doc.config.hide_comments = old.config.hide_comments; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + } + // Caches keyed by the edit generation (edit lines, images, measurements) must see a new + // one; there is nothing new to autosave. + doc.generation = generation + 1; + doc.snapshot_generation = doc.generation; + // `open` pushed the new version last, so the old one is still at `old`. + *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; + Ok(()) + } + + /// Whether a changed document still needs a recovery snapshot. This does not consume it. + pub fn has_pending_autosave(&self) -> bool { + self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation) + } + + /// Documents with unsaved changes made since the last call: their current working file, + /// for crash recovery. Encrypted documents stay encrypted in the snapshot. + pub fn autosave_snapshots(&mut self) -> Vec { + let mut out = Vec::new(); + for d in &mut self.docs { + if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926, #3944): a recovery file + // is written from a copy without them, and none is written if that copy fails. + let bytes = + match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; + d.snapshot_generation = d.generation; + out.push(RecoverySnapshot { + doc: d.id, + name: d.name.clone(), + path: d.path.clone(), + bytes, + encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), + }); + } + } + out + } + + /// Mark a document opened from a recovery file: it has unsaved changes and belongs at + /// `path` (where Save writes), as when the session ended. + pub fn mark_recovered(&mut self, id: DocId, path: Option) { + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.path = path; + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + d.generation += 1; + d.snapshot_generation = d.generation; // its bytes are already in the recovery store + } + } + + /// The certificates trusted for signing. + pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { + &self.trust.certs + } + + /// Replace the trusted certificates, revalidate every open document's signatures, + /// and rebuild its display copy and renderer to match (working file untouched, + /// generation/dirty unchanged: trust alone is not an edit). + pub fn set_trusted_certificates(&mut self, certs: Vec) { + self.trust = Arc::new(TrustStore { certs }); + for doc in &mut self.docs { + doc.trust = self.trust.clone(); + let Some(editor) = doc.editor.as_mut() else { continue }; + // Keep verdict refresh and renderer refresh together. Viewer caches are + // invalidated by the UI trust path; trust alone changes no document artwork. + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &doc.bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + } + } + + /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). + pub fn signing_date(&self) -> String { + let offset = self.local_offset(); + let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); + let stamp = local.trim_end_matches('Z'); + if offset == 0 { + return format!("{stamp}Z"); + } + let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; + format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) + } + + /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns + /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty + /// `opts.date` takes the session clock. + pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + // Encrypted documents are signed under their permissions (the signer checks them). + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + // A typed Password-field value is not signed into the file (#3926), as it isn't saved. + let cos = writable(&editor.cos)?; + Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) + } + + /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by + /// `tsa`. The transport lives with the caller; the engine never opens a socket. + pub fn sign_with_timestamp( + &self, + doc: DocId, + id: &pdfcraft_sign::DigitalId, + mut opts: SignOptions, + tsa: &dyn pdfcraft_sign::TimestampAuthority, + ) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + let cos = writable(&editor.cos)?; + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); + // Checked like a save before anyone writes it (#2166). + self.check_save(doc, &signed)?; + Ok(signed) + } + + /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's + /// current state. An empty `date` takes the session clock; the transport is the caller's. + pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let date = if date.is_empty() { self.signing_date() } else { date }; + let cos = writable(&editor.cos)?; + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); + self.check_save(doc, &stamped)?; + Ok(stamped) + } + + /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature + /// (PAdES B-LT): an incremental update that never rewrites signed bytes. + pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let cos = writable(&editor.cos)?; + let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); + self.check_save(doc, &embedded)?; + Ok(embedded) + } + + /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], + /// and the edit history before signing is dropped (signing can't be undone). + pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + self.mark_saved(id, bytes, path)?; + let doc = self.doc_mut(id)?; + if let Some(e) = doc.editor.as_mut() { + e.undo.clear(); + e.redo.clear(); + } + Ok(()) + } +} diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs new file mode 100644 index 0000000000..c0e7417c8e --- /dev/null +++ b/crates/engine/src/session/view.rs @@ -0,0 +1,307 @@ +use super::{ + Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort, + apply_layer_state, comment_summary, display_config, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices, +}; + +impl Session { + pub fn new() -> Self { + Self::default() + } + + /// Use a fixed clock (tests) instead of the system time. + pub fn with_clock(mut self, clock: fn() -> i64) -> Self { + self.clock = Some(clock); + self + } + + /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the + /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script + /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the + /// scripts do passes a large factor, so that a busy machine does not stop them. On native + /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so + /// there the factor has no effect. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.xfa_time_scale = Some(factor); + self + } + + /// A real clock for a host that has neither a clock nor a time zone of its own (a + /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so + /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in + /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every + /// use because the zone changes with the date (daylight saving). Unlike + /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.clock = Some(clock); + self.utc_offset = Some(utc_offset); + self + } + + /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under + /// the test clock (which is already local), else the system's. + pub(super) fn local_offset(&self) -> i64 { + match (self.utc_offset, self.clock) { + (Some(zone), _) => zone(), + (None, Some(_)) => 0, + (None, None) => local_utc_offset(), + } + } + + /// Seconds since the Unix epoch from the session clock (0 when unknown). + pub fn now_secs(&self) -> i64 { + self.now().unwrap_or(0) + } + + pub(crate) fn now(&self) -> Option { + if let Some(c) = self.clock { + return Some(c()); + } + #[cfg(not(target_arch = "wasm32"))] + return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); + #[cfg(target_arch = "wasm32")] + return None; + } + + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// is taken as local time. + /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). + pub fn stamp_by_line(&self, author: &str) -> String { + let offset = self.local_offset(); + let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); + let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); + let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); + const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; + let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; + format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) + } + + pub fn today(&self) -> (i64, u32, u32) { + let offset = self.local_offset(); + self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) + } + + /// Open a document from bytes. Rendering starts lazily when pages are requested. + /// + /// `password` is tried as either the user or owner password when the file is encrypted. + /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named + /// " (revision n)". + pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { + let doc = self.get(id).ok_or("no such document")?; + let ends = doc.revision_ends(); + let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; + let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); + let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); + self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) + } + + pub(crate) fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { + self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) + } + + /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle + /// per line in user space, URI). Text that already has a link over it is skipped. + pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut out = Vec::new(); + for page in 0..doc.info.pages.len() { + let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let Some(text) = res.text else { continue }; + let found: std::cell::RefCell, String)>> = Default::default(); + let hits = text.find_with(|chars| { + let urls = pdfcraft_annot::links::find_urls(chars); + let ranges = urls.iter().map(|u| u.0.clone()).collect(); + *found.borrow_mut() = urls; + ranges + }); + for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { + let rects: Vec<[f64; 4]> = text + .line_rects(glyphs) + .into_iter() + .map(|v| { + let q = doc.info.pages[page].view_rect_to_quad(v); + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + [ + xs.iter().copied().fold(f64::MAX, f64::min), + ys.iter().copied().fold(f64::MAX, f64::min), + xs.iter().copied().fold(f64::MIN, f64::max), + ys.iter().copied().fold(f64::MIN, f64::max), + ] + }) + .filter(|r| { + !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) + }) + .collect(); + if !rects.is_empty() { + out.push((page, rects, uri)); + } + } + } + out + } + + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. + /// The document's own structure is read for it, so every bookmark can be reached by paging. + pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { + let doc = self.get(id)?; + if let Some(editor) = doc.editor.as_ref() { + let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); + let rows = page + .bookmarks + .into_iter() + .map(|b| { + let view = b.view.map_or_else(Default::default, |(kind, numbers)| { + let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); + pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) + }); + BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } + }) + .collect(); + return Some(BookmarkPage { rows, next: page.next, truncated: false }); + } + let all = outline_rows(&doc.info.outline); + let end = offset.saturating_add(limit); + let rows = all.iter().skip(offset).take(limit).cloned().collect(); + Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) + } + + /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). + pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let top: Vec<(Option, String)> = match doc.editor.as_ref() { + Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), + None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), + }; + let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); + out.sort_by_key(|x| x.0); + out.dedup_by_key(|x| x.0); + out + } + + /// Open freshly created bytes (combine / extract) as a new, unsaved document. + pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { + let id = self.open(name, None, bytes, None)?; + if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { + d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } + // Unsaved work with no edit yet still needs its first recovery snapshot. + d.generation += 1; + } + Ok(id) + } + + /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; + /// callers must drop cached rasters and text for the document. + pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let Some(l) = doc.info.layers.get_mut(layer) else { return false }; + if l.visible == visible { + return false; + } + l.visible = visible; + use_layer_choices(doc); + true + } + + /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each + /// layer by its optional content group; groups that aren't layers are skipped. With + /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. + /// Returns `true` if any layer changed; callers must drop cached rasters and text for the + /// document, as for [`Self::set_layer_visible`]. + pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { + return false; + } + use_layer_choices(doc); + true + } + + /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns + /// `true` if any layer changed; callers must drop cached rasters and text for the document, + /// as for [`Self::set_layer_visible`]. + pub fn reset_layers(&mut self, id: DocId) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let mut changed = false; + for l in &mut doc.info.layers { + changed |= l.visible != l.initial; + l.visible = l.initial; + } + if changed { + use_layer_choices(doc); + } + changed + } + + /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form + /// fields and links still draw. Returns whether anything changed. + pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.hide_comments == hide { + return false; + } + doc.config.hide_comments = hide; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.generation += 1; + true + } + + /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether + /// anything changed; callers must drop cached rasters and text for the document. + pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.reading_colors == colors { + return false; + } + doc.config.reading_colors = colors; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); + doc.generation += 1; + true + } + + /// The document's reading-colours setting; `None` when no such document is open. + pub fn reading_colors(&self, id: DocId) -> Option { + Some(self.get(id)?.config.reading_colors) + } + + /// How the document's pages are drawn for viewing: its password and view settings (layer + /// visibility, hidden comments, reading colours). `None` when no such document is open. + pub fn render_config(&self, id: DocId) -> Option { + Some(self.get(id)?.config.clone()) + } + + /// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment + /// with its number, author, type, date, text and replies, grouped by page. + pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let text = comment_summary(&doc.name, &doc.info.annotations, sort); + self.create_report(&format!("Summary of Comments on {}", doc.name), &text) + } + + pub fn close(&mut self, id: DocId) { + self.docs.retain(|d| d.id != id); + } + + pub fn get(&self, id: DocId) -> Option<&Document> { + self.docs.iter().find(|d| d.id == id) + } + + /// Read document `id`'s font list again if content edits since the last full inspection may + /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. + pub fn refresh_fonts(&mut self, id: DocId) { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; + if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { + doc.info.fonts = info.fonts; + } + doc.fonts_stale = false; + } + + pub fn docs(&self) -> &[Document] { + &self.docs + } +} diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index daff674c19..ae8842ddfb 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -61,7 +61,7 @@ impl Document { pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32; let turn = pdfcraft_annot::picture_rotation(&editor.cos, page, index).map_err(|e| e.to_string())?; let mut cos = editor.cos.clone(); - pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?; + pdfcraft_annot::lift_annotation(&mut cos, page, index).map_err(|e| e.to_string())?; let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); Ok(Some(ImageSignaturePreview { image, opacity, turn, page, bytes, config: self.config.clone() })) })? diff --git a/crates/engine/src/signature_scan.rs b/crates/engine/src/signature_scan.rs index ba6ca269ab..4742754b01 100644 --- a/crates/engine/src/signature_scan.rs +++ b/crates/engine/src/signature_scan.rs @@ -602,303 +602,4 @@ fn render(area: &RgbImage, alpha: &[u8], bounds: Region) -> Result, Scan } #[cfg(test)] -mod tests { - use super::*; - - /// A gray "photo": `paper(x, y)` brightness with dark ink rectangles `[x, y, w, h]`. - fn photo(width: usize, height: usize, paper: impl Fn(usize, usize) -> u8, ink: &[[usize; 4]], ink_value: u8) -> Vec { - let mut out = vec![0; width * height]; - for y in 0..height { - for x in 0..width { - let inside = ink.iter().any(|r| x >= r[0] && x < r[0] + r[2] && y >= r[1] && y < r[1] + r[3]); - out[y * width + x] = if inside { ink_value } else { paper(x, y) }; - } - } - out - } - - fn rgb(gray: &[u8], width: usize, height: usize) -> RgbImage { - RgbImage::from_fn(width as u32, height as u32, |x, y| { - let v = gray[y as usize * width + x as usize]; - image::Rgb([v, v, v]) - }) - } - - fn jpeg(image: &RgbImage) -> Vec { - let mut out = Cursor::new(Vec::new()); - image.write_to(&mut out, ImageFormat::Jpeg).unwrap(); - out.into_inner() - } - - /// Photos keep the image signature limits (#1124): a 4 MiB file, at most 32 megapixels and - /// 8192 pixels per side decoded, and a working copy of at most 4 megapixels. - #[test] - fn photos_keep_the_image_signature_limits() { - let png = |image: &RgbImage| { - let mut out = Cursor::new(Vec::new()); - image.write_to(&mut out, ImageFormat::Png).unwrap(); - out.into_inner() - }; - assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); - assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); - assert!(matches!(SignaturePhoto::from_bytes(&png(&RgbImage::new(8193, 1))), Err(ScanError::Size))); - assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); - let [w, h] = SignaturePhoto::from_bytes(&png(&RgbImage::from_pixel(8000, 4000, image::Rgb([220, 220, 215])))).unwrap().size(); - assert!(w <= WORKING_MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_OUTPUT_PIXELS, "{w} x {h}"); - assert!((f64::from(w) / f64::from(h) - 2.0).abs() < 0.01, "the aspect ratio is kept: {w} x {h}"); - } - - fn region(r: [usize; 4]) -> Region { - Region { x: r[0], y: r[1], width: r[2], height: r[3] } - } - - fn opts() -> Options { - Options { sensitivity: DEFAULT_SENSITIVITY, despeckle: true, background_radius: None } - } - - #[test] - fn box_blur_averages_and_keeps_a_constant_signal() { - let mut values = vec![0.0, 0.0, 9.0, 0.0, 0.0]; - box_blur(&mut values, 5, 1, 1); - assert_eq!(values, vec![0.0, 3.0, 3.0, 3.0, 0.0]); - let mut flat = vec![7.0; 12]; - box_blur(&mut flat, 4, 3, 2); - assert!(flat.iter().all(|v| (v - 7.0).abs() < 1e-4)); - } - - #[test] - fn square_filters_match_a_naive_window_with_repeated_edges() { - let (w, h) = (13, 7); - let src: Vec = (0..w * h).map(|i| ((i * 37 + 11) % 251) as u8).collect(); - for radius in [1, 2, 5] { - for max in [true, false] { - let fast = square_filter(&src, w, h, radius, max); - for y in 0..h { - for x in 0..w { - let mut want = if max { 0 } else { 255 }; - for dy in -(radius as i64)..=radius as i64 { - for dx in -(radius as i64)..=radius as i64 { - let sx = (x as i64 + dx).clamp(0, w as i64 - 1) as usize; - let sy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize; - let v = src[sy * w + sx]; - want = if max { want.max(v) } else { want.min(v) }; - } - } - assert_eq!(fast[y * w + x], want, "radius {radius} max {max} at {x},{y}"); - } - } - } - } - } - - #[test] - fn components_are_eight_connected() { - let mask = [true, false, false, false, false, true, false, true, false, false, false, false]; - let (labels, count) = label_components(&mask, 4, 3); - assert_eq!(count, 2); - assert_eq!(labels[0], labels[5]); - assert_ne!(labels[7], labels[0]); - assert_eq!(labels[1], 0); - } - - #[test] - fn sensitivity_lowers_the_threshold() { - assert!(threshold(0) > threshold(50)); - assert!(threshold(50) > threshold(100)); - assert_eq!(threshold(200), threshold(100)); - } - - #[test] - fn ink_survives_a_shadow_gradient_and_the_shadow_does_not() { - let (w, h) = (160, 80); - let stroke = [20, 30, 120, 6]; - let gray = photo(w, h, |x, _| (235 - 115 * x / (w - 1)) as u8, &[stroke], 30); - let ink = extract_ink(&gray, w, h, opts()); - assert_eq!(ink.alpha[10 * w + 150], 0); - assert_eq!(ink.alpha[33 * w + 30], 255); - assert_eq!(ink.alpha[33 * w + 130], 255); - assert_eq!(ink.bounds, Some(region(stroke))); - } - - #[test] - fn a_sharp_shadow_edge_is_not_ink() { - let (w, h) = (200, 120); - let stroke = [30, 52, 140, 4]; - let gray = photo(w, h, |_, y| if y < 60 { 230 } else { 120 }, &[stroke], 30); - let ink = extract_ink(&gray, w, h, opts()); - for y in 58..63 { - assert_eq!(ink.alpha[y * w + 10], 0); - assert_eq!(ink.alpha[y * w + 190], 0); - } - assert_eq!(ink.bounds, Some(region(stroke))); - } - - #[test] - fn a_table_at_the_edge_of_the_frame_is_not_ink() { - let (w, h) = (300, 120); - let stroke = [60, 60, 200, 4]; - let gray = photo(w, h, |x, y| if x < 12 || y < 10 { 70 } else { 225 }, &[stroke], 30); - let ink = extract_ink(&gray, w, h, opts()); - assert_eq!(ink.alpha[5 * w + 150], 0); - assert_eq!(ink.alpha[60 * w + 5], 0); - assert_eq!(ink.alpha[11 * w + 150], 0); - assert_eq!(ink.bounds, Some(region(stroke))); - } - - #[test] - fn thick_strokes_stay_solid_when_the_window_is_sized_on_the_photo() { - let (w, h) = (600, 200); - let stroke = [50, 90, 500, 18]; - let gray = photo(w, h, |_, _| 225, &[stroke], 30); - let middle = 99 * w + 300; - // Sized on the tight frame alone, the window is narrower than the stroke. - assert_eq!(extract_ink(&gray, w, h, opts()).alpha[middle], 0); - let ink = extract_ink(&gray, w, h, Options { background_radius: Some((4.0 * w as f32 * BACKGROUND_RADIUS_RATIO) as usize), ..opts() }); - assert_eq!(ink.bounds, Some(region(stroke))); - assert!((90..108).all(|y| ink.alpha[y * w + 300] == 255)); - } - - #[test] - fn faint_stroke_ends_touching_ink_are_kept_and_isolated_ones_are_not() { - let (w, h) = (200, 60); - let mut gray = photo(w, h, |_, _| 220, &[[20, 28, 80, 4]], 40); - for y in 28..32 { - for x in (100..180).chain(10..15) { - gray[y * w + x] = 175; - } - } - let ink = extract_ink(&gray, w, h, opts()); - assert!(ink.alpha[30 * w + 150] > 0); - assert_eq!(ink.alpha[30 * w + 12], 0); - assert_eq!(ink.bounds, Some(region([20, 28, 160, 4]))); - } - - #[test] - fn dust_is_removed_unless_despeckling_is_off() { - let (w, h) = (200, 100); - let gray = photo(w, h, |_, _| 230, &[[20, 40, 150, 5], [190, 5, 1, 1]], 20); - assert!(extract_ink(&gray, w, h, Options { despeckle: false, ..opts() }).alpha[5 * w + 190] > 0); - let ink = extract_ink(&gray, w, h, opts()); - assert_eq!(ink.alpha[5 * w + 190], 0); - assert_eq!(ink.bounds, Some(region([20, 40, 150, 5]))); - } - - #[test] - fn faint_strokes_need_a_higher_sensitivity() { - let (w, h) = (120, 60); - let gray = photo(w, h, |_, _| 220, &[[10, 25, 100, 4]], 165); - assert!(extract_ink(&gray, w, h, Options { sensitivity: 0, ..opts() }).bounds.is_none()); - assert!(extract_ink(&gray, w, h, Options { sensitivity: 100, ..opts() }).bounds.is_some()); - } - - #[test] - fn blank_paper_has_no_ink() { - let gray = photo(64, 64, |x, y| (200 + (x * 7 + y * 13) % 20) as u8, &[], 0); - assert!(extract_ink(&gray, 64, 64, opts()).bounds.is_none()); - assert!(detect_region(&gray, 64, 64).is_none()); - } - - #[test] - fn detection_frames_all_words_and_ignores_dust_and_the_table_edge() { - let (w, h) = (400, 200); - let gray = photo(w, h, |_, y| if y >= 185 { 30 } else { 225 }, &[[40, 95, 90, 5], [230, 92, 30, 5], [300, 20, 1, 1]], 30); - let r = detect_region(&gray, w, h).unwrap(); - assert!(r.x <= 40 && r.x + r.width >= 260, "{r:?}"); - assert!(r.y > 20 && r.y + r.height < 185, "{r:?}"); - } - - #[test] - fn detection_frames_the_signature_not_the_sheet_on_a_dark_table() { - let (w, h) = (400, 300); - let sheet = |x: usize, y: usize| if (60..340).contains(&x) && (50..250).contains(&y) { 220 } else { 60 }; - let gray = photo(w, h, sheet, &[[120, 150, 140, 4], [150, 135, 4, 20]], 30); - let r = detect_region(&gray, w, h).unwrap(); - assert!(r.x > 80 && r.x + r.width < 320, "{r:?}"); - assert!(r.y > 70 && r.y + r.height < 230, "{r:?}"); - } - - #[test] - fn rects_turn_with_the_photo_and_back() { - let rect = PhotoRect { x: 10, y: 20, width: 30, height: 40 }; - let turned = rect.turned(Turn::Clockwise, 100, 80); - assert_eq!(turned, PhotoRect { x: 20, y: 10, width: 40, height: 30 }); - assert_eq!(turned.turned(Turn::CounterClockwise, 80, 100), rect); - assert_eq!(PhotoRect { x: 500, y: 0, width: 0, height: 900 }.clamped(100, 50), PhotoRect { x: 99, y: 0, width: 1, height: 50 }); - assert_eq!(PhotoRect { x: 0, y: 0, width: u32::MAX, height: 1 }.turned(Turn::CounterClockwise, 10, 10).y, 0); - } - - #[test] - fn a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour() { - let (w, h) = (600, 400); - let mut image = rgb(&photo(w, h, |x, _| (230 - 60 * x / w) as u8, &[], 0), w, h); - for (x, y, p) in image.enumerate_pixels_mut() { - if (150..450).contains(&x) && (200..206).contains(&y) || (200..206).contains(&x) && (170..206).contains(&y) { - *p = image::Rgb([30, 50, 140]); - } - } - let mut photo = SignaturePhoto::from_bytes(&jpeg(&image)).unwrap(); - let rect = photo.auto_frame(); - assert!(rect.x <= 150 && rect.x + rect.width >= 450 && rect.y <= 170 && rect.y + rect.height >= 206, "{rect:?}"); - let signature = photo.scan(rect, DEFAULT_SENSITIVITY, SCAN_MAX_SIDE).unwrap(); - let [sw, sh] = signature.size(); - assert!((300..340).contains(&sw) && (36..60).contains(&sh), "{sw}x{sh}"); - let pixels = signature.rgba().as_chunks::<4>().0; - assert_eq!(pixels[0][3], 0, "the margin is transparent"); - let solid: Vec<_> = pixels.iter().filter(|p| p[3] == 255).collect(); - assert!(!solid.is_empty()); - assert!(solid.iter().all(|p| p[2] > p[0] + 40), "blue ink stays blue: {:?}", solid[0]); - let quick = photo.scan(rect, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE).unwrap(); - assert!(quick.size()[0] <= sw); - } - - #[test] - fn a_sideways_signature_is_turned_upright() { - let (w, h) = (400, 600); - let gray = photo(w, h, |_, _| 225, &[[200, 120, 5, 300], [185, 300, 25, 4]], 30); - let mut photo = SignaturePhoto::from_bytes(&jpeg(&rgb(&gray, w, h))).unwrap(); - let rect = photo.auto_frame(); - assert_eq!(photo.size(), [600, 400]); - assert!(rect.width > rect.height * 2, "{rect:?}"); - } - - #[test] - fn an_empty_frame_reports_that_no_signature_was_found() { - let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(200, 100, image::Rgb([220, 220, 220])))).unwrap(); - let mut copy = photo.clone(); - assert_eq!(copy.auto_frame(), PhotoRect { x: 28, y: 35, width: 142, height: 30 }); - assert!(matches!(photo.scan(PhotoRect { x: 0, y: 0, width: 200, height: 100 }, 50, SCAN_MAX_SIDE), Err(ScanError::NoInk))); - } - - #[test] - fn large_photos_are_downscaled_and_invalid_ones_rejected() { - let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(5000, 100, image::Rgb([200, 200, 200])))).unwrap(); - let [w, h] = photo.size(); - assert!((4000..=WORKING_MAX_SIDE).contains(&w) && (81..=82).contains(&h), "{w} x {h}"); - assert!(photo.thumbnail(512).width() <= 512); - assert!(matches!(SignaturePhoto::from_bytes(b"not a photo"), Err(ScanError::Format))); - assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); - assert!(SignaturePhoto::from_bytes(b"\xff\xd8\xff\xe0truncated").is_err()); - assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); - assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); - // A header claiming a huge image is rejected before decoding. - let mut png = Vec::new(); - png.extend_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"); - png.extend_from_slice(&20_000_u32.to_be_bytes()); - png.extend_from_slice(&20_000_u32.to_be_bytes()); - png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]); - assert!(SignaturePhoto::from_bytes(&png).is_err()); - } - - #[test] - fn degenerate_buffers_do_not_panic() { - assert!(extract_ink(&[], 0, 0, opts()).bounds.is_none()); - assert!(extract_ink(&[0; 3], 2, 2, opts()).bounds.is_none()); - assert!(extract_ink(&[0], 1, 1, opts()).bounds.is_none()); - assert!(detect_region(&[255; 4], 2, 2).is_none()); - let mut empty: Vec = Vec::new(); - box_blur(&mut empty, 0, 0, 3); - assert!(square_filter(&[], 0, 0, 2, true).is_empty()); - let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(3, 2, image::Rgb([10, 10, 10])))).unwrap(); - let _ = photo.scan(PhotoRect { x: 9, y: 9, width: 0, height: 0 }, 255, 0); - } -} +mod tests; diff --git a/crates/engine/src/signature_scan/tests.rs b/crates/engine/src/signature_scan/tests.rs new file mode 100644 index 0000000000..c13200b1f1 --- /dev/null +++ b/crates/engine/src/signature_scan/tests.rs @@ -0,0 +1,298 @@ +use super::*; + +/// A gray "photo": `paper(x, y)` brightness with dark ink rectangles `[x, y, w, h]`. +fn photo(width: usize, height: usize, paper: impl Fn(usize, usize) -> u8, ink: &[[usize; 4]], ink_value: u8) -> Vec { + let mut out = vec![0; width * height]; + for y in 0..height { + for x in 0..width { + let inside = ink.iter().any(|r| x >= r[0] && x < r[0] + r[2] && y >= r[1] && y < r[1] + r[3]); + out[y * width + x] = if inside { ink_value } else { paper(x, y) }; + } + } + out +} + +fn rgb(gray: &[u8], width: usize, height: usize) -> RgbImage { + RgbImage::from_fn(width as u32, height as u32, |x, y| { + let v = gray[y as usize * width + x as usize]; + image::Rgb([v, v, v]) + }) +} + +fn jpeg(image: &RgbImage) -> Vec { + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Jpeg).unwrap(); + out.into_inner() +} + +/// Photos keep the image signature limits (#1124): a 4 MiB file, at most 32 megapixels and +/// 8192 pixels per side decoded, and a working copy of at most 4 megapixels. +#[test] +fn photos_keep_the_image_signature_limits() { + let png = |image: &RgbImage| { + let mut out = Cursor::new(Vec::new()); + image.write_to(&mut out, ImageFormat::Png).unwrap(); + out.into_inner() + }; + assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); + assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); + assert!(matches!(SignaturePhoto::from_bytes(&png(&RgbImage::new(8193, 1))), Err(ScanError::Size))); + assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); + let [w, h] = SignaturePhoto::from_bytes(&png(&RgbImage::from_pixel(8000, 4000, image::Rgb([220, 220, 215])))).unwrap().size(); + assert!(w <= WORKING_MAX_SIDE && u64::from(w) * u64::from(h) <= MAX_OUTPUT_PIXELS, "{w} x {h}"); + assert!((f64::from(w) / f64::from(h) - 2.0).abs() < 0.01, "the aspect ratio is kept: {w} x {h}"); +} + +fn region(r: [usize; 4]) -> Region { + Region { x: r[0], y: r[1], width: r[2], height: r[3] } +} + +fn opts() -> Options { + Options { sensitivity: DEFAULT_SENSITIVITY, despeckle: true, background_radius: None } +} + +#[test] +fn box_blur_averages_and_keeps_a_constant_signal() { + let mut values = vec![0.0, 0.0, 9.0, 0.0, 0.0]; + box_blur(&mut values, 5, 1, 1); + assert_eq!(values, vec![0.0, 3.0, 3.0, 3.0, 0.0]); + let mut flat = vec![7.0; 12]; + box_blur(&mut flat, 4, 3, 2); + assert!(flat.iter().all(|v| (v - 7.0).abs() < 1e-4)); +} + +#[test] +fn square_filters_match_a_naive_window_with_repeated_edges() { + let (w, h) = (13, 7); + let src: Vec = (0..w * h).map(|i| ((i * 37 + 11) % 251) as u8).collect(); + for radius in [1, 2, 5] { + for max in [true, false] { + let fast = square_filter(&src, w, h, radius, max); + for y in 0..h { + for x in 0..w { + let mut want = if max { 0 } else { 255 }; + for dy in -(radius as i64)..=radius as i64 { + for dx in -(radius as i64)..=radius as i64 { + let sx = (x as i64 + dx).clamp(0, w as i64 - 1) as usize; + let sy = (y as i64 + dy).clamp(0, h as i64 - 1) as usize; + let v = src[sy * w + sx]; + want = if max { want.max(v) } else { want.min(v) }; + } + } + assert_eq!(fast[y * w + x], want, "radius {radius} max {max} at {x},{y}"); + } + } + } + } +} + +#[test] +fn components_are_eight_connected() { + let mask = [true, false, false, false, false, true, false, true, false, false, false, false]; + let (labels, count) = label_components(&mask, 4, 3); + assert_eq!(count, 2); + assert_eq!(labels[0], labels[5]); + assert_ne!(labels[7], labels[0]); + assert_eq!(labels[1], 0); +} + +#[test] +fn sensitivity_lowers_the_threshold() { + assert!(threshold(0) > threshold(50)); + assert!(threshold(50) > threshold(100)); + assert_eq!(threshold(200), threshold(100)); +} + +#[test] +fn ink_survives_a_shadow_gradient_and_the_shadow_does_not() { + let (w, h) = (160, 80); + let stroke = [20, 30, 120, 6]; + let gray = photo(w, h, |x, _| (235 - 115 * x / (w - 1)) as u8, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[10 * w + 150], 0); + assert_eq!(ink.alpha[33 * w + 30], 255); + assert_eq!(ink.alpha[33 * w + 130], 255); + assert_eq!(ink.bounds, Some(region(stroke))); +} + +#[test] +fn a_sharp_shadow_edge_is_not_ink() { + let (w, h) = (200, 120); + let stroke = [30, 52, 140, 4]; + let gray = photo(w, h, |_, y| if y < 60 { 230 } else { 120 }, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + for y in 58..63 { + assert_eq!(ink.alpha[y * w + 10], 0); + assert_eq!(ink.alpha[y * w + 190], 0); + } + assert_eq!(ink.bounds, Some(region(stroke))); +} + +#[test] +fn a_table_at_the_edge_of_the_frame_is_not_ink() { + let (w, h) = (300, 120); + let stroke = [60, 60, 200, 4]; + let gray = photo(w, h, |x, y| if x < 12 || y < 10 { 70 } else { 225 }, &[stroke], 30); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[5 * w + 150], 0); + assert_eq!(ink.alpha[60 * w + 5], 0); + assert_eq!(ink.alpha[11 * w + 150], 0); + assert_eq!(ink.bounds, Some(region(stroke))); +} + +#[test] +fn thick_strokes_stay_solid_when_the_window_is_sized_on_the_photo() { + let (w, h) = (600, 200); + let stroke = [50, 90, 500, 18]; + let gray = photo(w, h, |_, _| 225, &[stroke], 30); + let middle = 99 * w + 300; + // Sized on the tight frame alone, the window is narrower than the stroke. + assert_eq!(extract_ink(&gray, w, h, opts()).alpha[middle], 0); + let ink = extract_ink(&gray, w, h, Options { background_radius: Some((4.0 * w as f32 * BACKGROUND_RADIUS_RATIO) as usize), ..opts() }); + assert_eq!(ink.bounds, Some(region(stroke))); + assert!((90..108).all(|y| ink.alpha[y * w + 300] == 255)); +} + +#[test] +fn faint_stroke_ends_touching_ink_are_kept_and_isolated_ones_are_not() { + let (w, h) = (200, 60); + let mut gray = photo(w, h, |_, _| 220, &[[20, 28, 80, 4]], 40); + for y in 28..32 { + for x in (100..180).chain(10..15) { + gray[y * w + x] = 175; + } + } + let ink = extract_ink(&gray, w, h, opts()); + assert!(ink.alpha[30 * w + 150] > 0); + assert_eq!(ink.alpha[30 * w + 12], 0); + assert_eq!(ink.bounds, Some(region([20, 28, 160, 4]))); +} + +#[test] +fn dust_is_removed_unless_despeckling_is_off() { + let (w, h) = (200, 100); + let gray = photo(w, h, |_, _| 230, &[[20, 40, 150, 5], [190, 5, 1, 1]], 20); + assert!(extract_ink(&gray, w, h, Options { despeckle: false, ..opts() }).alpha[5 * w + 190] > 0); + let ink = extract_ink(&gray, w, h, opts()); + assert_eq!(ink.alpha[5 * w + 190], 0); + assert_eq!(ink.bounds, Some(region([20, 40, 150, 5]))); +} + +#[test] +fn faint_strokes_need_a_higher_sensitivity() { + let (w, h) = (120, 60); + let gray = photo(w, h, |_, _| 220, &[[10, 25, 100, 4]], 165); + assert!(extract_ink(&gray, w, h, Options { sensitivity: 0, ..opts() }).bounds.is_none()); + assert!(extract_ink(&gray, w, h, Options { sensitivity: 100, ..opts() }).bounds.is_some()); +} + +#[test] +fn blank_paper_has_no_ink() { + let gray = photo(64, 64, |x, y| (200 + (x * 7 + y * 13) % 20) as u8, &[], 0); + assert!(extract_ink(&gray, 64, 64, opts()).bounds.is_none()); + assert!(detect_region(&gray, 64, 64).is_none()); +} + +#[test] +fn detection_frames_all_words_and_ignores_dust_and_the_table_edge() { + let (w, h) = (400, 200); + let gray = photo(w, h, |_, y| if y >= 185 { 30 } else { 225 }, &[[40, 95, 90, 5], [230, 92, 30, 5], [300, 20, 1, 1]], 30); + let r = detect_region(&gray, w, h).unwrap(); + assert!(r.x <= 40 && r.x + r.width >= 260, "{r:?}"); + assert!(r.y > 20 && r.y + r.height < 185, "{r:?}"); +} + +#[test] +fn detection_frames_the_signature_not_the_sheet_on_a_dark_table() { + let (w, h) = (400, 300); + let sheet = |x: usize, y: usize| if (60..340).contains(&x) && (50..250).contains(&y) { 220 } else { 60 }; + let gray = photo(w, h, sheet, &[[120, 150, 140, 4], [150, 135, 4, 20]], 30); + let r = detect_region(&gray, w, h).unwrap(); + assert!(r.x > 80 && r.x + r.width < 320, "{r:?}"); + assert!(r.y > 70 && r.y + r.height < 230, "{r:?}"); +} + +#[test] +fn rects_turn_with_the_photo_and_back() { + let rect = PhotoRect { x: 10, y: 20, width: 30, height: 40 }; + let turned = rect.turned(Turn::Clockwise, 100, 80); + assert_eq!(turned, PhotoRect { x: 20, y: 10, width: 40, height: 30 }); + assert_eq!(turned.turned(Turn::CounterClockwise, 80, 100), rect); + assert_eq!(PhotoRect { x: 500, y: 0, width: 0, height: 900 }.clamped(100, 50), PhotoRect { x: 99, y: 0, width: 1, height: 50 }); + assert_eq!(PhotoRect { x: 0, y: 0, width: u32::MAX, height: 1 }.turned(Turn::CounterClockwise, 10, 10).y, 0); +} + +#[test] +fn a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour() { + let (w, h) = (600, 400); + let mut image = rgb(&photo(w, h, |x, _| (230 - 60 * x / w) as u8, &[], 0), w, h); + for (x, y, p) in image.enumerate_pixels_mut() { + if (150..450).contains(&x) && (200..206).contains(&y) || (200..206).contains(&x) && (170..206).contains(&y) { + *p = image::Rgb([30, 50, 140]); + } + } + let mut photo = SignaturePhoto::from_bytes(&jpeg(&image)).unwrap(); + let rect = photo.auto_frame(); + assert!(rect.x <= 150 && rect.x + rect.width >= 450 && rect.y <= 170 && rect.y + rect.height >= 206, "{rect:?}"); + let signature = photo.scan(rect, DEFAULT_SENSITIVITY, SCAN_MAX_SIDE).unwrap(); + let [sw, sh] = signature.size(); + assert!((300..340).contains(&sw) && (36..60).contains(&sh), "{sw}x{sh}"); + let pixels = signature.rgba().as_chunks::<4>().0; + assert_eq!(pixels[0][3], 0, "the margin is transparent"); + let solid: Vec<_> = pixels.iter().filter(|p| p[3] == 255).collect(); + assert!(!solid.is_empty()); + assert!(solid.iter().all(|p| p[2] > p[0] + 40), "blue ink stays blue: {:?}", solid[0]); + let quick = photo.scan(rect, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE).unwrap(); + assert!(quick.size()[0] <= sw); +} + +#[test] +fn a_sideways_signature_is_turned_upright() { + let (w, h) = (400, 600); + let gray = photo(w, h, |_, _| 225, &[[200, 120, 5, 300], [185, 300, 25, 4]], 30); + let mut photo = SignaturePhoto::from_bytes(&jpeg(&rgb(&gray, w, h))).unwrap(); + let rect = photo.auto_frame(); + assert_eq!(photo.size(), [600, 400]); + assert!(rect.width > rect.height * 2, "{rect:?}"); +} + +#[test] +fn an_empty_frame_reports_that_no_signature_was_found() { + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(200, 100, image::Rgb([220, 220, 220])))).unwrap(); + let mut copy = photo.clone(); + assert_eq!(copy.auto_frame(), PhotoRect { x: 28, y: 35, width: 142, height: 30 }); + assert!(matches!(photo.scan(PhotoRect { x: 0, y: 0, width: 200, height: 100 }, 50, SCAN_MAX_SIDE), Err(ScanError::NoInk))); +} + +#[test] +fn large_photos_are_downscaled_and_invalid_ones_rejected() { + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(5000, 100, image::Rgb([200, 200, 200])))).unwrap(); + let [w, h] = photo.size(); + assert!((4000..=WORKING_MAX_SIDE).contains(&w) && (81..=82).contains(&h), "{w} x {h}"); + assert!(photo.thumbnail(512).width() <= 512); + assert!(matches!(SignaturePhoto::from_bytes(b"not a photo"), Err(ScanError::Format))); + assert!(matches!(SignaturePhoto::from_bytes(b"GIF89a\x02\0\x02\0"), Err(ScanError::Format))); + assert!(SignaturePhoto::from_bytes(b"\xff\xd8\xff\xe0truncated").is_err()); + assert!(matches!(SignaturePhoto::from_bytes(&vec![0; MAX_PHOTO_BYTES + 1]), Err(ScanError::Size))); + assert!(SignaturePhoto::read(std::io::repeat(0)).is_err()); + // A header claiming a huge image is rejected before decoding. + let mut png = Vec::new(); + png.extend_from_slice(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"); + png.extend_from_slice(&20_000_u32.to_be_bytes()); + png.extend_from_slice(&20_000_u32.to_be_bytes()); + png.extend_from_slice(&[8, 2, 0, 0, 0, 0, 0, 0, 0]); + assert!(SignaturePhoto::from_bytes(&png).is_err()); +} + +#[test] +fn degenerate_buffers_do_not_panic() { + assert!(extract_ink(&[], 0, 0, opts()).bounds.is_none()); + assert!(extract_ink(&[0; 3], 2, 2, opts()).bounds.is_none()); + assert!(extract_ink(&[0], 1, 1, opts()).bounds.is_none()); + assert!(detect_region(&[255; 4], 2, 2).is_none()); + let mut empty: Vec = Vec::new(); + box_blur(&mut empty, 0, 0, 3); + assert!(square_filter(&[], 0, 0, 2, true).is_empty()); + let photo = SignaturePhoto::from_bytes(&jpeg(&RgbImage::from_pixel(3, 2, image::Rgb([10, 10, 10])))).unwrap(); + let _ = photo.scan(PhotoRect { x: 9, y: 9, width: 0, height: 0 }, 255, 0); +} diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs new file mode 100644 index 0000000000..91d6efa3ee --- /dev/null +++ b/crates/engine/src/sources.rs @@ -0,0 +1,69 @@ +//! Other files to combine with or copy pages from: opening them, and why one can't be used. + +use std::sync::Arc; + +use crate::{EditError, SourceProblem}; + +/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). +pub type CombineSource = (String, Arc>, Option); + +/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names +/// separate (old fully-qualified name → new one), in order across the sources. +#[derive(Debug)] +pub struct Combined { + pub bytes: Arc>, + pub renamed_fields: Vec<(String, String)>, +} + +/// Parse another PDF to copy pages from. +pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { + open_source_with(name, bytes, None) +} + +/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file. +pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> Result { + source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) +} + +/// Import the comments of `src` onto the pages of `doc` with the same numbers. +pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> { + if pdfcraft_organize::import_comments(doc, src)? == 0 { + return Err(pdfcraft_xfdf::DataError::NothingImported.into()); + } + Ok(()) +} + +/// Parse another PDF to import comments from, whatever its permissions allow. +pub(crate) fn comment_source(bytes: &Arc>) -> Result { + match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) { + Ok(Ok(d)) => Ok(d), + Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), + Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), + Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), + } +} + +fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { + match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { + Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), + Ok(Ok(d)) => Ok(d), + Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), + Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), + Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), + Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), + } +} + +/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not +/// why: what Combine would refuse it for, so the Combine files list can say so before Combine +/// is pressed. The owner (permissions) password lifts the restriction on copying pages. +pub fn combine_source_check(bytes: &Arc>, password: Option<&str>) -> Result<(), SourceProblem> { + source_document(bytes, password).map(|_| ()) +} + +/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever +/// its permissions allow; `None` when it can't be read. +pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option { + let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?; + pdfcraft_organize::page_count(&doc).ok() +} diff --git a/crates/engine/src/stamp_preview.rs b/crates/engine/src/stamp_preview.rs new file mode 100644 index 0000000000..630ec0c3f0 --- /dev/null +++ b/crates/engine/src/stamp_preview.rs @@ -0,0 +1,140 @@ +//! A stamp while it is moved or resized: the page without it, and the stamp alone. Any other +//! comment while it is moved: the comment alone. + +use std::sync::Arc; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage}; + +use crate::{Document, guard}; + +/// Read-only layers for interactive movement and resizing: the page without the stamp, and the +/// stamp alone on a transparent page. Moving or resizing a stamp only changes its `/Rect`, which +/// its appearance is stretched to fit, so these two show the result before it is applied. +/// Preparing them never changes the working bytes, dirty flag or undo history. +pub struct StampPreview { + page: usize, + background: Arc>, + stamp: Arc>, + config: RenderConfig, +} + +impl StampPreview { + /// One worker for the page beneath the stamp. + pub fn background_renderer(&self) -> RenderPool { + RenderPool::new(self.background.clone(), 1, self.config.clone()) + } + + /// One worker for the stamp alone: its page has no content, no other annotations and no + /// background, so only the stamp's own pixels are drawn. + pub fn stamp_renderer(&self) -> RenderPool { + RenderPool::new(self.stamp.clone(), 1, self.stamp_config()) + } + + /// The page beneath the stamp, rendered synchronously with the renderer's normal pixel limits. + pub fn render_background(&self, scale: f32) -> Result { + self.render(&self.background, self.config.clone(), scale) + } + + /// The stamp alone on a transparent page, rendered like [`Self::render_background`]. + pub fn render_stamp(&self, scale: f32) -> Result { + self.render(&self.stamp, self.stamp_config(), scale) + } + + fn stamp_config(&self) -> RenderConfig { + RenderConfig { transparent: true, ..self.config.clone() } + } + + fn render(&self, bytes: &Arc>, config: RenderConfig, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let out = PageRenderer::new(bytes.clone(), config).render(RenderRequest { page: self.page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? + } +} + +impl Document { + /// The layers that show the stamp at `(page, index)` moved or resized as it is dragged. + /// `None` for an annotation that isn't a stamp, or one without an appearance to draw. + pub fn stamp_preview(&self, page: usize, index: usize) -> Result, String> { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let is_stamp = self.info.annotations.iter().any(|a| a.page == page && a.index == index && a.subtype == "Stamp"); + if !is_stamp { + return Ok(None); + } + let mut stamp = editor.cos.clone(); + if !pdfcraft_annot::isolate_annotation(&mut stamp, page, index).map_err(|e| e.to_string())? { + return Ok(None); + } + let mut background = editor.cos.clone(); + pdfcraft_annot::lift_annotation(&mut background, page, index).map_err(|e| e.to_string())?; + // The page beneath as it is displayed: comments without an appearance get theirs. + let background = pdfcraft_annot::with_missing_appearances(&background).unwrap_or(background); + let write = |cos: &pdfcraft_cos::Document| write_incremental(cos, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string()); + Ok(Some(StampPreview { page, background: write(&background)?, stamp: write(&stamp)?, config: self.config.clone() })) + })? + } +} + +/// Comments alone on a transparent page, for drawing them where they go while they are moved. +/// The page beneath keeps showing them where they were until the move is applied: rendering the page +/// without it would cost a whole page's render at every drag on a heavy plan. Read-only, as +/// [`StampPreview`] is. +pub struct CommentLayer { + page: usize, + bytes: Arc>, + config: RenderConfig, +} + +impl CommentLayer { + /// One worker for the comments alone: their page has no content and no other annotations. + pub fn renderer(&self) -> RenderPool { + RenderPool::new(self.bytes.clone(), 1, self.transparent()) + } + + /// The comments alone, rendered synchronously with the renderer's normal pixel limits. + pub fn render(&self, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let out = + PageRenderer::new(self.bytes.clone(), self.transparent()).render(RenderRequest { page: self.page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? + } + + fn transparent(&self) -> RenderConfig { + RenderConfig { transparent: true, ..self.config.clone() } + } +} + +impl Document { + /// The comments at `indices` on `page` alone (the ones moved together), drawn as on screen: + /// one without an appearance of its own gets the one the page view draws. `None` when one + /// of them doesn't exist or none has anything to draw. + pub fn comment_layer(&self, page: usize, indices: &[usize]) -> Result, String> { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + if indices.is_empty() || !indices.iter().all(|&i| self.info.annotations.iter().any(|a| a.page == page && a.index == i)) { + return Ok(None); + } + let mut alone = pdfcraft_annot::with_missing_appearances(&editor.cos).unwrap_or_else(|| editor.cos.clone()); + if !pdfcraft_annot::isolate_annotations(&mut alone, page, indices).map_err(|e| e.to_string())? { + return Ok(None); + } + let bytes = write_incremental(&alone, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string())?; + Ok(Some(CommentLayer { page, bytes, config: self.config.clone() })) + })? + } +} diff --git a/crates/engine/src/test_hooks.rs b/crates/engine/src/test_hooks.rs new file mode 100644 index 0000000000..d6e504599e --- /dev/null +++ b/crates/engine/src/test_hooks.rs @@ -0,0 +1,42 @@ +//! Test-only hooks: injected refresh panics and counts of the secondary page scans. + +thread_local! { + static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) }; + static REFRESH_PANICS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub(crate) fn panic_on_refresh(count: usize) { + REFRESH_PANICS.with(|n| n.set(count)); +} + +pub(crate) fn check_refresh_panic() { + REFRESH_PANICS.with(|n| { + let rem = n.get(); + if rem > 0 { + n.set(rem - 1); + panic!("injected refresh panic"); + } + }); +} + +pub(crate) fn reset_secondary_scan_counts() { + SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); +} + +pub(crate) fn secondary_scan_counts() -> (usize, usize) { + SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) +} + +pub(crate) fn record_marker_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers + 1, links)); + }); +} + +pub(crate) fn record_link_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers, links + 1)); + }); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41dac..333d0fd498 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,9 @@ use std::sync::Arc; +use super::document::is_placeholder_title; +use super::edit::{EditCtx, check_permission, run_edit}; use super::*; +use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors}; /// A minimal `n`-page document; page `i` shows "Page i+1". pub(crate) fn fixture(n: usize) -> Vec { @@ -196,6 +199,100 @@ fn rubber_stamps_point_left_as_displayed_on_rotated_pages() { } } +/// #2583, #1869: a sticky note placed on a turned page is drawn upright, with its top-left as +/// displayed where it was placed (the corner its `/Rect` keeps, as the NoRotate flag it is saved +/// with asks), exactly as on an unturned page, including after save. The comment list's +/// `shown_rect`, which the UI hit-tests, is that square. +#[test] +fn sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages() { + // The displayed 20 × 20 pt square whose top-left is (100, 40), in device pixels at scale 2. + let crop = |rendered: &pdfcraft_render::RenderedPage| -> Vec<[u8; 4]> { + (80..120).flat_map(|y| (200..240).map(move |x| (x, y))).map(|(x, y)| stamp_pixel(rendered, x, y)).collect() + }; + let mut upright = None; + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + let [x, y] = info.view_to_user(100.0, 40.0); + let shape = Shape::Note { at: [f64::from(x), f64::from(y)], icon: NoteIcon::Comment }; + let style = Style::default_for(&shape); + session.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, style, shape, contents: "Turned".into(), author: "Test".into() })).unwrap(); + let mut check = |session: &Session, id: DocId| { + let doc = session.get(id).unwrap(); + let info = &doc.info.pages[0]; + let note = &doc.info.annotations[0]; + assert!(note.no_rotate, "rotation {degrees}: notes are saved NoRotate"); + let shown = note.shown_rect(info.rotation); + let (a, b) = (info.user_to_view(shown[0], shown[1]), info.user_to_view(shown[2], shown[3])); + let shown = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + assert!(shown.iter().zip([100.0, 40.0, 120.0, 60.0]).all(|(v, w)| (v - w).abs() < 0.01), "rotation {degrees}: shown at {shown:?}"); + let icon = crop(&render_page(session, id)); + let reference = upright.get_or_insert_with(|| icon.clone()); + assert!(reference.iter().filter(|c| c.iter().any(|v| *v < 200)).count() > 100, "the note's icon is drawn"); + let off = icon.iter().zip(reference.iter()).filter(|(a, b)| a.iter().zip(b.iter()).any(|(a, b)| a.abs_diff(*b) > 48)).count(); + assert!(off <= 8, "rotation {degrees}: {off} of the icon's {} pixels differ from the unturned page's", icon.len()); + }; + check(&session, id); + let saved = session.save_bytes(id).unwrap(); + let mut reopened = Session::new().with_clock(|| 1_700_000_000); + let again = reopened.open("drawing.pdf", None, saved, None).unwrap(); + check(&reopened, again); + } +} + +/// #2392: Fill & Sign marks are drawn as displayed on every page rotation, including after save: +/// a checkmark's long stroke rises to the right, and a line runs across. +#[test] +fn fill_sign_marks_are_upright_as_displayed_on_rotated_pages() { + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + // Displayed boxes: a 40 pt checkmark centred at (50, 50), an 80 × 8 pt line at (130, 50). + for (mark, vx, w, h) in [(FillMark::Check, 50.0, 40.0, 40.0), (FillMark::Line, 130.0, 80.0, 8.0)] { + let shape = Shape::Mark { rect: displayed_rect(&info, vx, 50.0, w, h), mark }; + session + .apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style: Style::default_for(&shape), + shape, + contents: String::new(), + author: "Test".into(), + }), + ) + .unwrap(); + } + let check = |session: &Session, id: DocId| { + let rendered = render_page(session, id); + // Dark pixels in a displayed box, rendered at scale 2. + let ink = |x0: u32, y0: u32, x1: u32, y1: u32| -> Vec<(u32, u32)> { + (y0 * 2..y1 * 2) + .flat_map(|y| (x0 * 2..x1 * 2).map(move |x| (x, y))) + .filter(|&(x, y)| stamp_pixel(&rendered, x, y)[..3].iter().all(|c| *c < 128)) + .collect() + }; + let tick = ink(30, 30, 70, 70); + assert!(!tick.is_empty(), "rotation {degrees}: no checkmark drawn"); + let top = tick.iter().min_by_key(|p| p.1).unwrap(); + let bottom = tick.iter().max_by_key(|p| p.1).unwrap(); + // The long stroke ends at the top right; the short one meets it low, left of centre. + assert!(top.0 > bottom.0 + 20, "rotation {degrees}: highest ink {top:?} should be right of the lowest {bottom:?}"); + assert!(bottom.0 < 100 && top.0 > 110, "rotation {degrees}: checkmark {bottom:?} .. {top:?} is not upright"); + let line = ink(85, 40, 175, 60); + assert!(!line.is_empty(), "rotation {degrees}: no line drawn"); + let (x0, x1) = (line.iter().map(|p| p.0).min().unwrap(), line.iter().map(|p| p.0).max().unwrap()); + let (y0, y1) = (line.iter().map(|p| p.1).min().unwrap(), line.iter().map(|p| p.1).max().unwrap()); + assert!((x1 - x0) > 10 * (y1 - y0 + 1), "rotation {degrees}: line {x0}..{x1} x {y0}..{y1} should run across"); + }; + check(&session, id); + let saved = session.save_bytes(id).unwrap(); + let mut reopened = Session::new().with_clock(|| 1_700_000_000); + let again = reopened.open("drawing.pdf", None, saved, None).unwrap(); + check(&reopened, again); + } +} + /// Fill & Sign text and text boxes read across as displayed on turned pages (scanned forms are /// often stored sideways with `/Rotate`), including after save. #[test] @@ -320,6 +417,31 @@ fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() { } } +/// A visible digital signature reads upright as the page is displayed on every page rotation: +/// its picture, alone in the box, shows its quadrants where they are in the image. +#[test] +fn visible_signatures_keep_displayed_orientation_on_rotated_pages() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let picture = image::load_from_memory(&quadrant_png()).unwrap().to_rgba8(); + let graphic = sign::Graphic::Image { width: picture.width(), height: picture.height(), rgba: Arc::new(picture.into_raw()) }; + let bare = sign::Appearance { name: false, date: false, reason: false, location: false, distinguished_name: false, labels: false }; + for degrees in [0, 90, 180, 270] { + // After the test certificate's start date (2026-10-02). + let mut session = Session::new().with_clock(|| 1_800_000_000); + let blank = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open_new("contract.pdf", blank).unwrap(); + session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap(); + let info = session.get(id).unwrap().info.pages[0].clone(); + // The picture's proportions (2:1) as displayed, centred at (100, 100). + let rect = displayed_rect(&info, 100.0, 100.0, 80.0, 40.0); + let opts = SignOptions { page: 0, rect: Some(rect), graphic: Some(graphic.clone()), appearance: bare.clone(), ..SignOptions::default() }; + let signed = session.sign(id, &digital_id, opts).unwrap(); + let reopened = session.open("signed.pdf", None, signed, None).unwrap(); + assert_upright_quadrants(&session, reopened, degrees); + } +} + /// #1398: a PDF page used as a stamp stays upright, at its natural size and in an explicit box. #[test] fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() { @@ -352,6 +474,48 @@ fn pdf_page_stamps_keep_displayed_orientation_on_rotated_pages() { } } +/// #4077: a PDF page stamped into a box of another shape is fitted inside it, keeping its shape +/// and centred, and stays vector: a form XObject, not a picture of it. +#[test] +fn pdf_page_stamps_fit_inside_an_explicit_box() { + let bytes = Arc::new(half_red_page()); + let mut session = Session::new().with_clock(|| 1_700_000_000); + let blank = session.create_blank(595.0, 842.0, 1).unwrap(); + let id = session.open_new("text.pdf", blank).unwrap(); + // 483 pt wide (a text column) and 300 pt tall: the 80 × 40 page is 483 × 241.5, centred. + let stamp = |rect| Edit::AddCustomStamp { + page: 0, + rect, + name: "Diagram".into(), + file: MarkFile { name: "diagram.pdf".into(), bytes: bytes.clone(), page: 0 }, + author: "Test".into(), + }; + session.apply(id, stamp([56.0, 400.0, 539.0, 700.0])).unwrap(); + assert_eq!(session.get(id).unwrap().info.annotations[0].rect, [56.0, 429.25, 539.0, 670.75]); + // A box too tall and narrow: the width limits it, and it is centred vertically. + session.apply(id, stamp([100.0, 100.0, 140.0, 300.0])).unwrap(); + assert_eq!(session.get(id).unwrap().info.annotations[1].rect, [100.0, 190.0, 140.0, 210.0]); + + let doc = pdfcraft_cos::Document::open(session.save_bytes(id).unwrap()).unwrap(); + let page = &pdfcraft_model::pages(&doc)[0]; + let annots = doc.resolve(page.dict.get(b"Annots").unwrap()); + let first = annots.as_array().unwrap()[0].as_ref().unwrap(); + let annot = doc.get(first); + let annot = annot.as_dict().unwrap(); + let pdfcraft_cos::Object::Stream(picture) = doc.get(annot.reference(b"PCPicture").unwrap()).as_ref().clone() else { + panic!("the picture is a stream") + }; + assert_eq!(picture.dict.name(b"Subtype"), Some(&b"Form"[..]), "the page is kept as a form XObject"); + let ap = doc.resolve(annot.get(b"AP").unwrap()); + let pdfcraft_cos::Object::Stream(normal) = doc.get(ap.as_dict().unwrap().reference(b"N").unwrap()).as_ref().clone() else { + panic!("an appearance stream") + }; + let bbox: Vec = normal.dict.get(b"BBox").unwrap().as_array().unwrap().iter().filter_map(pdfcraft_cos::Object::as_f64).collect(); + assert_eq!(bbox, [56.0, 429.25, 539.0, 670.75]); + let content = String::from_utf8(normal.decoded().unwrap().to_vec()).unwrap(); + assert!(content.contains("/Pic Do") && !content.contains("BI"), "{content}"); +} + fn page_texts(s: &Session, id: DocId) -> Vec { let doc = s.get(id).unwrap(); let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; @@ -372,6 +536,38 @@ impl RenderRequestFor { } } +/// A file with its title and author only in XMP shows them in the document information and in +/// Document Properties ▸ Description (#2373). +#[test] +fn document_information_stated_only_in_xmp_is_shown() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let xmp = "\ + \ + Quarterly report\ + Ada"; + let mut dict = pdfcraft_cos::Dict::new(); + dict.set(b"Type".to_vec(), pdfcraft_cos::Object::name("Metadata")); + dict.set(b"Subtype".to_vec(), pdfcraft_cos::Object::name("XML")); + let m = cos.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(dict, xmp.as_bytes().to_vec()))); + let root = cos.root().unwrap(); + cos.update_dict(root, |c| c.set(b"Metadata".to_vec(), pdfcraft_cos::Object::Ref(m))).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("xmp.pdf", None, Arc::new(bytes), None).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!((doc.info.title.as_deref(), doc.info.author.as_deref(), doc.info.subject.as_deref()), (Some("Quarterly report"), Some("Ada"), None)); + assert_eq!(doc.info_value("Title").as_deref(), Some("Quarterly report")); + assert_eq!(doc.info_value("Author").as_deref(), Some("Ada")); + + // A full refresh (any structural edit, undo, redo) reads /Info again and must not lose them. + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!((doc.info.title.as_deref(), doc.info.author.as_deref()), (Some("Quarterly report"), Some("Ada")), "after an edit"); + s.undo(id).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!((doc.info.title.as_deref(), doc.info.author.as_deref()), (Some("Quarterly report"), Some("Ada")), "after undo"); +} + #[test] fn edits_update_view_data_and_mark_dirty() { let (mut s, id) = session_with(3); @@ -385,6 +581,80 @@ fn edits_update_view_data_and_mark_dirty() { assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed"); } +/// A font of an Adobe CJK collection that a PDF does not embed is drawn with an installed face on +/// the viewer's pool only. An export of the same page, which can be saved, has nothing drawn with +/// an installed font (AGENTS.md §1.2, §1.4). Skipped where no such face is installed. +#[test] +fn installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export() { + let content = "BT /F1 40 Tf 5 15 Td <7E419AD45B57> Tj ET"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /MSung-Light /Encoding /UniCNS-UCS2-H /DescendantFonts [6 0 R] >> endobj +6 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /MSung-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (CNS1) /Supplement 2 >> /FontDescriptor 7 0 R /DW 1000 >> endobj +7 0 obj << /Type /FontDescriptor /FontName /MSung-Light /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let mut s = Session::new(); + let id = s.open("cns1.pdf", None, Arc::new(pdf.into_bytes()), None).unwrap(); + let doc = s.get(id).unwrap(); + doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let viewer = loop { + if let Some(page) = doc.renderer.try_recv() { + assert!(page.error.is_none(), "{:?}", page.error); + break page; + } + assert!(std::time::Instant::now() < deadline, "page render did not finish"); + std::thread::yield_now(); + }; + let inked = |rgba: &[u8]| rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count(); + // Whether this machine has a face that can draw the page at all, asked of a renderer with the + // fallback switched on by hand, so a viewer pool that lost it fails below instead of skipping. + let probe = pdfcraft_render::PageRenderer::new(doc.display.clone(), pdfcraft_render::RenderConfig { system_fonts: true, ..Default::default() }) + .render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(probe.error.is_none(), "{:?}", probe.error); + if inked(&probe.rgba) < 300 { + eprintln!("no installed Traditional Chinese face (or PDFCRAFT_SYSTEM_FONTS=0): skipped"); + return; + } + assert!(inked(&viewer.rgba) > 300, "the viewer's pool draws it with the installed face ({} dark pixels)", inked(&viewer.rgba)); + let png = export::Exporter::new(doc).png(0, 72.0).unwrap(); + let exported = image::load_from_memory(&png).unwrap().into_rgba8(); + assert!(inked(exported.as_raw()) < 50, "an export draws nothing with an installed font ({} dark pixels)", inked(exported.as_raw())); + + // Reload rebuilds the pool when retaining a view preference. That path must keep + // the display-only fallback too; otherwise Chinese disappears after a file refresh. + let bytes = doc.bytes.clone(); + assert!(s.set_hide_comments(id, true)); + s.reload(id, bytes).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.comments_hidden()); + doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let reloaded = loop { + if let Some(page) = doc.renderer.try_recv() { + assert!(page.error.is_none(), "{:?}", page.error); + break page; + } + assert!(std::time::Instant::now() < deadline, "reloaded page render did not finish"); + std::thread::yield_now(); + }; + assert!(inked(&reloaded.rgba) > 300, "reload must retain visible Chinese text"); +} + #[test] fn metadata_edit_keeps_the_renderer_without_changing_page_content() { let (mut s, id) = session_with(1); @@ -685,7 +955,7 @@ fn layer_choices_survive_edits() { #[test] fn set_layer_actions_toggle_in_order_and_keep_radio_groups() { use pdfcraft_render::LayerOp::{Off, On, Toggle}; - let layer = |n: u32, visible: bool| Layer { id: (n, 0), name: format!("L{n}"), visible }; + let layer = |n: u32, visible: bool| Layer { id: (n, 0), name: format!("L{n}"), visible, initial: visible }; let run = |changes: &[(LayerOp, u32)], preserve_rb: bool| { // L1 and L2 are a radio-button group; L3 is on its own. let mut layers = vec![layer(1, true), layer(2, false), layer(3, true)]; @@ -761,6 +1031,59 @@ fn set_layer_actions_change_what_renders() { assert!(!s.set_layer_state(DocId(999), &[(LayerOp::On, (5, 0))], true)); } +#[test] +fn reset_layers_returns_to_the_files_visibility() { + // Red (left half, on) and Green (right half, off) are a radio-button group. + let content = "/OC /R BDC 1 0 0 rg 0 0 50 100 re f EMC /OC /G BDC 0 1 0 rg 50 0 50 100 re f EMC"; + let pdf = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] >> >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /R 5 0 R /G 6 0 R >> >> >>") + .stream("", content) + .obj("<< /Type /OCG /Name (Red) >>") + .obj("<< /Type /OCG /Name (Green) >>") + .build(); + // A page drawing on layer Blue, which that file has off. + let other = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /OFF [5 0 R] >> >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /B 5 0 R >> >> >>") + .stream("", "/OC /B BDC 0 0 1 rg 0 0 10 10 re f EMC") + .obj("<< /Type /OCG /Name (Blue) >>") + .build(); + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); + let shown = |s: &Session| s.get(id).unwrap().info.layers.iter().map(|l| (l.name.clone(), l.visible)).collect::>(); + let left = |s: &Session| { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let p = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + let i = (50 * p.width as usize + 25) * 4; + p.rgba[i..i + 3].to_vec() + }; + assert!(s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true)); + let edit = Edit::InsertPagesFrom { + name: "b.pdf".into(), + bytes: Arc::new(other), + pages: None, + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }; + s.apply(id, edit).unwrap(); + let blue = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Blue").unwrap(); + assert!(s.set_layer_visible(id, blue, true)); + assert_eq!(shown(&s), [("Red".into(), false), ("Green".into(), true), ("Blue".into(), true)]); + assert_eq!(left(&s), [255, 255, 255]); + // Every layer, the inserted one too, goes back to the file's default configuration. + assert!(s.reset_layers(id)); + assert_eq!(shown(&s), [("Red".into(), true), ("Green".into(), false), ("Blue".into(), false)]); + assert_eq!(left(&s), [255, 0, 0]); + assert!(!s.reset_layers(id), "already as the file opens"); + // What the eye sees is not an edit: Undo still undoes the insert. + assert_eq!(s.get(id).unwrap().can_undo(), Some("Insert pages from b.pdf")); + assert!(!s.reset_layers(DocId(999))); +} + #[test] fn edits_to_unknown_documents_are_rejected() { let (mut s, _) = session_with(1); @@ -1205,11 +1528,144 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); assert!(s.get(oid).unwrap().allows_copying()); assert!(extracting.iter().all(|c| enabled(&s, oid, c))); + // Comparing reads both documents' text out, into the result, the report and the comments. + let refused = || Err(EditError::NotPermitted("copying text and images")); + assert_eq!((s.compare(id, oid).map(|_| ()), s.compare(oid, id).map(|_| ())), (refused(), refused())); + assert_eq!(s.compare_report(oid, id).map(|_| ()), refused()); + assert!(s.compare(oid, oid).is_ok()); + assert_eq!(s.mark_differences(id, oid).map(|_| ()), refused()); // Unencrypted documents allow everything. let (s, plain) = session_with(1); assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c))); } +/// Table 22, bit 3 (#2669): a document that forbids printing disables Print up front instead +/// of refusing once the dialog is confirmed. Low-resolution printing (bit 3 on, bit 12 off) is +/// still printing, and the owner password lifts the restriction. +#[test] +fn print_restricted_documents_disable_print_unless_opened_by_the_owner() { + let enabled = |s: &Session, id, c| commands::is_enabled(commands::command(c).unwrap(), s, Some(id)); + let bytes = protected("", "owner", -1 ^ 0b100); + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); + assert!(!s.get(id).unwrap().allows_printing()); + assert!(!enabled(&s, id, "print.dialog")); + assert!(enabled(&s, id, "export.text"), "only printing is withheld"); + let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); + assert!(enabled(&s, oid, "print.dialog"), "the owner may print"); + let low = s.open("low.pdf", None, protected("", "owner", -1 ^ (1 << 11)), None).unwrap(); + assert!(enabled(&s, low, "print.dialog"), "low-resolution printing is printing"); +} + +/// Table 22, bit 12 clear: printing is limited to a low-level representation. Whatever the +/// settings ask for, the print-ready PDF carries each page as a picture of at most 150 dpi, with +/// no text or vector artwork left; the owner password lifts the limit. +#[test] +fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() { + let bytes = protected("", "owner", -1 ^ (1 << 11)); + let mut s = Session::new(); + let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); + let width = f64::from(s.get(low).unwrap().info.pages[0].width); + let actual = print::Settings { pages: vec![0], layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() }; + for raster in [None, Some(print::Raster { dpi: 600.0, grayscale: false })] { + let printed = s.print_pdf(low, &print::Settings { raster, ..actual.clone() }).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(printed.clone()), None).unwrap(); + assert!(page_texts(&s2, id2).iter().all(|t| t.trim().is_empty()), "{raster:?}: no text is left"); + let doc = pdfcraft_cos::Document::open(Arc::new(printed)).unwrap(); + let widths: Vec = doc + .scan_objects() + .filter_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => st.dict.int(b"Width"), + _ => None, + }) + .collect(); + assert!(!widths.is_empty() && widths.iter().all(|&w| w as f64 <= width * 150.0 / 72.0 + 1.0), "{raster:?}: {widths:?} px for {width} pt"); + } + let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap(); + let printed = s.print_pdf(owner, &actual).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(printed), None).unwrap(); + assert!(page_texts(&s2, id2).iter().any(|t| !t.trim().is_empty()), "the owner prints the pages themselves"); +} + +/// A 100 × 400 pt page with one band per kind of thing that prints, top to bottom: page content +/// (blue), a filled text field (green), a stamp (red) and a square comment (yellow). +fn print_content_pdf() -> Vec { + let content = "0 0 1 rg 0 300 100 100 re f"; + let ap = |colour: &str| { + let ops = format!("{colour} rg 0 0 100 100 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{ops}\nendstream", ops.len()) + }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 400] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Filled) /F 4 /P 3 0 R /Rect [0 200 100 300] /AP << /N 8 0 R >> >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Stamp /F 4 /P 3 0 R /Rect [0 100 100 200] /AP << /N 9 0 R >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Square /F 4 /P 3 0 R /Rect [0 0 100 100] /AP << /N 10 0 R >> >> endobj\n\ + 8 0 obj {} endobj\n\ + 9 0 obj {} endobj\n\ + 10 0 obj {} endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + ap("0 1 0"), + ap("1 0 0"), + ap("1 1 0"), + ) + .into_bytes() +} + +/// Low-resolution printing prints what the chosen content prints when the pages themselves are +/// printed: form fields only leaves out the page content and every comment, the document alone +/// leaves out the comments, and the document with stamps keeps only the stamps. +#[test] +fn low_resolution_printing_prints_only_the_chosen_content() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(print_content_pdf())).unwrap(); + cos.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ (1 << 11), + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + let low = s.open("low.pdf", None, bytes.clone(), None).unwrap(); + let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap(); + // Each band's centre, in pixels from the top of the sheet at 72 dpi, and its colour. + let bands = [("content", 50, [0, 0, 255]), ("field", 150, [0, 255, 0]), ("stamp", 250, [255, 0, 0]), ("comment", 350, [255, 255, 0])]; + let all: &[&str] = &["content", "field", "stamp", "comment"]; + for (content, shown) in [ + (print::Content::DocumentAndMarkups, all), + (print::Content::Document, &["content", "field"][..]), + (print::Content::DocumentAndStamps, &["content", "field", "stamp"][..]), + (print::Content::FormFieldsOnly, &["field"][..]), + ] { + let settings = print::Settings { + pages: vec![0], + paper: (100.0, 400.0), + layout: print::Layout::Size(print::SizeMode::Actual), + content, + ..Default::default() + }; + for (id, how) in [(owner, "pages"), (low, "pictures")] { + let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap()); + assert_eq!((w, h), (100, 400), "{content:?} as {how}"); + for (band, y, colour) in bands { + let px = rgba.get((y * w as usize + 50) * 4..).and_then(|p| p.get(..3)).unwrap(); + let near = |c: [u8; 3]| px.iter().zip(c).all(|(&a, b)| a.abs_diff(b) < 40); + let expected = shown.contains(&band); + assert!(if expected { near(colour) } else { near([255; 3]) }, "{content:?} as {how}: {band} drawn {expected} expected, pixel {px:?}"); + } + } + } +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, @@ -1303,12 +1759,42 @@ fn owner_password_of_older_revisions_opens_the_viewer_too() { } } +/// Render paths outside a session (the CLI's `info`, `text`, `render`) get the password the +/// renderer takes: the recovered user password for an R2–R4 owner password, else as given. +#[test] +fn render_password_for_recovers_the_user_password_of_older_revisions() { + for alg in [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128, pdfcraft_cos::Algorithm::Aes256] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: alg, + user_password: "u", + owner_password: "o", + permissions: 0, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let owner = crate::render_password_for(bytes.clone(), Some("o")); + let expected = if alg == pdfcraft_cos::Algorithm::Aes256 { "o" } else { "u" }; + assert_eq!(owner.as_deref(), Some(expected), "{alg:?}"); + assert!(pdfcraft_render::inspect(bytes.clone(), owner.as_deref()).is_ok(), "{alg:?}: the renderer opens it"); + assert_eq!(crate::render_password_for(bytes.clone(), Some("u")).as_deref(), Some("u"), "{alg:?}"); + assert_eq!(crate::render_password_for(bytes.clone(), Some("wrong")).as_deref(), Some("wrong"), "{alg:?}"); + assert_eq!(crate::render_password_for(bytes, None), None, "{alg:?}"); + } +} + #[test] fn autosave_snapshots_only_changed_documents() { let (mut s, id) = session_with(2); + assert!(!s.has_pending_autosave()); assert!(s.autosave_snapshots().is_empty(), "clean documents are not snapshotted"); s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert!(s.has_pending_autosave()); + assert!(s.has_pending_autosave(), "checking a deadline must not consume a snapshot"); let snaps = s.autosave_snapshots(); + assert!(!s.has_pending_autosave()); assert_eq!(snaps.len(), 1); assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file"); assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot"); @@ -1371,9 +1857,9 @@ fn outline_titles(items: &[pdfcraft_render::OutlineItem]) -> Vec { fn bookmark_edits_show_in_the_viewer_undo_and_save() { let (mut s, id) = session_with(3); let titles = |s: &Session| outline_titles(&s.get(id).unwrap().info.outline); - s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Start".into(), page: 0 }).unwrap(); - s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "End".into(), page: 2 }).unwrap(); - s.apply(id, Edit::AddBookmark { parent: vec![1], index: 0, title: "Détail".into(), page: 1 }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Start".into(), page: 0, left: None, top: None }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "End".into(), page: 2, left: None, top: None }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![1], index: 0, title: "Détail".into(), page: 1, left: None, top: None }).unwrap(); // The inspector (an independent parser) sees the same tree and destinations. assert_eq!(titles(&s), ["Start→1", "End→3[Détail→2]"]); s.apply(id, Edit::MoveBookmark { from: vec![1, 0], to_parent: vec![], index: 0 }).unwrap(); @@ -1437,6 +1923,153 @@ fn bookmarks_from_structure_nest_the_headings_and_undo() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// Three pages whose headings are set apart only by how they look (#1693): chapters in +/// Helvetica-Bold 18, sections in Helvetica-Bold 14, text in Helvetica 11. Page 2's chapter title +/// runs over two lines, its bold font is another subset of the same font, and one section is set +/// with `1 Tf` and a text matrix 14 times larger. Page 3 has two sections on one row. +fn styled_headings_fixture() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Chapter One) Tj ET BT /FR 11 Tf 72 670 Td (Body text) Tj ET \ + BT /FB 14 Tf 72 640 Td (Section A) Tj ET BT /FR 11 Tf 72 620 Td (More body) Tj ET"; + let page2 = "BT /FS 18 Tf 72 700 Td (Chapter Two) Tj ET BT /FS 18 Tf 72 680 Td (and its subtitle) Tj ET \ + BT /FB 1 Tf 14 0 0 14 72 640 Tm (Section B) Tj ET BT /FR 11 Tf 72 620 Td (Body) Tj ET \ + BT /FB 14 Tf 72 600 Td (Section C) Tj ET"; + // Two sections on one row, the right one drawn first: read from the left. + let page3 = "BT /FB 14 Tf 300 500 Td (Right part) Tj ET BT /FB 14 Tf 72 500 Td (Left part) Tj ET"; + let fonts = "/Font << /FB 6 0 R /FR 7 0 R /FS 8 0 R >>"; + pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj(format!("<< /Type /Pages /Kids [3 0 R 4 0 R 10 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << {fonts} >> >>")) + .obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 9 0 R >>") + .stream("", page1) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .stream("", page2) + .obj("<< /Type /Page /Parent 2 0 R /Contents 11 0 R >>") + .stream("", page3) + .build() +} + +/// #1693: the document's text styles, largest first, and bookmarks made from the chosen ones: +/// chapters at level 1, sections nested under the chapter before them. A two-line title is one +/// bookmark; a subset of the same font and a size set by the text matrix count as the style. +/// Each bookmark goes to its heading's top. One undo step; no style, or none in use, is refused. +#[test] +fn bookmarks_from_text_styles_nest_the_headings_and_undo() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap(); + let styles = s.get(id).unwrap().text_styles(); + let listed: Vec<(String, f64, bool, usize, usize)> = + styles.iter().map(|u| (u.style.font.clone(), u.style.size, u.style.bold, u.lines, u.pages)).collect(); + assert_eq!( + listed, + [ + ("Helvetica-Bold".to_string(), 18.0, true, 3, 2), + ("Helvetica-Bold".to_string(), 14.0, true, 5, 3), + ("Helvetica".to_string(), 11.0, false, 3, 2), + ] + ); + assert_eq!(styles[0].sample, "Chapter One"); + let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone()); + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter.clone(), section.clone()] }).unwrap(); + let outline = s.get(id).unwrap().info.outline.clone(); + assert_eq!( + outline_titles(&outline), + ["Untitled→0[Chapter One→1[Section A→1],Chapter Two and its subtitle→2[Section B→2,Section C→2,Left part→3,Right part→3]]"] + ); + let first = &outline[0].children[0]; + match first.view { + pdfcraft_render::DestView::Xyz { left: None, top: Some(top), zoom: None } => { + assert!((700.0..720.0).contains(&top), "the top of the heading's line: {top}") + } + ref v => panic!("{v:?}"), + } + assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from text styles")); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().info.outline.is_empty()); + // One level is enough; the sections are then left out. + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter] }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Chapter One→1,Chapter Two and its subtitle→2]"]); + s.undo(id).unwrap(); + // Nothing chosen, or a style no text is in: refused, nothing changes. + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![] }).unwrap_err(); + assert!(err.to_string().contains("at least one bookmark level"), "{err}"); + let unused = pdfcraft_engine_style(§ion, 30.0); + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![unused] }).unwrap_err(); + assert!(err.to_string().contains("no text is set in the chosen styles"), "{err}"); + assert!(s.get(id).unwrap().info.outline.is_empty()); +} + +/// CodeRabbit on #4560: a line goes to the first level whose style it is in, so the same style +/// for two levels (or one within the size tolerance of the other) would leave the later level +/// empty. Refused at the edit, for the dialog and automation alike; nothing changes. +#[test] +fn bookmarks_from_text_styles_refuse_one_style_for_two_levels() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap(); + let styles = s.get(id).unwrap().text_styles(); + let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone()); + let near = pdfcraft_engine_style(§ion, section.size + 0.1); + for levels in [vec![chapter.clone(), chapter.clone()], vec![chapter.clone(), section.clone(), near]] { + let n = levels.len(); + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels }).unwrap_err(); + assert!(err.to_string().contains(&format!("levels {} and {n} have the same text style", n - 1)), "{err}"); + assert!(s.get(id).unwrap().info.outline.is_empty()); + } +} + +/// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document +/// that forbids copying lists no styles and refuses the edit, as it refuses other extraction. +#[test] +fn bookmarks_from_text_styles_need_copying_permission() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(styled_headings_fixture())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); + assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_copying()); + assert!(s.get(id).unwrap().text_styles().is_empty(), "no samples of text that may not be copied"); + let style = text_style_bookmarks::TextStyle { font: "Helvetica-Bold".into(), size: 18.0, bold: true, italic: false }; + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style.clone()] }).unwrap_err(); + assert_eq!(err, EditError::NotPermitted("copying text and images")); + let owner = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); + assert!(!s.get(owner).unwrap().text_styles().is_empty()); + assert!(s.apply(owner, Edit::BookmarksFromTextStyles { levels: vec![style] }).is_ok(), "the owner may"); +} + +/// CodeRabbit on #4560: two headings in one style in two columns, the right one a little lower, +/// are two bookmarks, not one heading run on across the columns. +#[test] +fn headings_in_two_columns_are_two_bookmarks() { + let page = "BT /FB 14 Tf 72 700 Td (Left heading) Tj ET BT /FB 14 Tf 320 690 Td (Right heading) Tj ET"; + let bytes = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Resources << /Font << /FB 5 0 R >> >> >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>") + .stream("", page) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .build(); + let mut s = Session::new(); + let id = s.open("cols.pdf", None, Arc::new(bytes), None).unwrap(); + let style = s.get(id).unwrap().text_styles()[0].style.clone(); + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style] }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Left heading→1,Right heading→1]"]); +} + +/// `style` at another size. +fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle { + text_style_bookmarks::TextStyle { size, ..style.clone() } +} + #[test] fn number_pages_shows_in_the_viewer_and_undoes() { let (mut s, id) = session_with(5); @@ -1619,6 +2252,34 @@ fn permissions_password_restricts_others_but_not_this_session() { assert!(s2.get(id3).unwrap().allows_security_change()); } +/// Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted document +/// it needs the same rights as removing security, not just the right to make changes (#3921). +#[test] +fn convert_to_pdfa_needs_the_owners_rights_on_an_encrypted_document() { + let pdfa = || Edit::ConvertPdfA { level: pdfcraft_preflight::Level::A2b }; + let refused = Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")); + // Changes allowed; printing (bit 3) and copying (bit 5) withheld. + let bytes = protected("pw", "owner", !((1 << 2) | (1 << 4))); + let mut s = Session::new(); + let id = s.open("x.pdf", None, bytes.clone(), Some("pw")).unwrap(); + assert!(s.get(id).unwrap().allows_modification()); + assert_eq!(s.apply(id, pdfa()), refused); + // Nor inside a batch. + let batch = Edit::Batch { label: "x".into(), edits: vec![Edit::RotatePages { pages: vec![0], degrees: 90 }, pdfa()] }; + assert_eq!(s.apply(id, batch), refused); + // Nothing was applied: no undo step, and saving gives back the encrypted original. + assert!(s.get(id).unwrap().can_undo().is_none()); + assert_eq!(s.save_bytes(id).unwrap().as_slice(), bytes.as_slice()); + // The owner's password allows it, and the result is no longer encrypted. + let id2 = s.open("x.pdf", None, bytes, Some("owner")).unwrap(); + s.apply(id2, pdfa()).unwrap(); + let saved = s.save_bytes(id2).unwrap(); + assert!(pdfcraft_cos::Document::open(saved).unwrap().trailer().get(b"Encrypt").is_none()); + // An unencrypted document needs nothing more. + let (mut s, id) = session_with(1); + s.apply(id, pdfa()).unwrap(); +} + #[test] fn combine_source_check_says_why_a_file_cannot_be_combined() { let (mut s, id) = session_with(1); @@ -1730,6 +2391,84 @@ fn passwords_that_encryption_would_make_empty_or_equal_are_refused() { } } +#[test] +fn fresh_sessions_protecting_identical_documents_produce_different_salts() { + // Characterization only: two sessions' salts differ. A per-call RandomState already did + // that before Protect drew from the platform CSPRNG, so this does not prove the source. + // `protect_fails_closed_when_randomness_is_unavailable` is the guard. It does not compile + // against a Protect with no CSPRNG hook, and it fails closed when that hook errors or + // returns zeros. Comparing the stored function with `getrandom::fill` is not reliable: + // one function can have different addresses in different codegen units. + let raw = Arc::new(fixture(1)); + let encrypt_bytes = |saved: Arc>, key: &[u8]| -> Vec { + let doc = pdfcraft_cos::Document::open_with_password(saved, Some("secret")).unwrap(); + let enc = doc.trailer().get(b"Encrypt").map(|o| doc.resolve(o)).unwrap(); + enc.as_dict().unwrap().get(key).map(|o| doc.resolve(o)).unwrap().as_string().unwrap().bytes.clone() + }; + let protect_once = |p: Protection| -> Arc> { + let mut s = Session::new(); + let id = s.open("same.pdf", None, raw.clone(), None).unwrap(); + s.apply(id, Edit::Protect(p)).unwrap(); + s.save_bytes(id).unwrap() + }; + let a = protect_once(protection(Some("secret"), None)); + let b = protect_once(protection(Some("secret"), None)); + let (u_a, u_b) = (encrypt_bytes(a.clone(), b"U"), encrypt_bytes(b.clone(), b"U")); + let (o_a, o_b) = (encrypt_bytes(a.clone(), b"O"), encrypt_bytes(b.clone(), b"O")); + let (ue_a, ue_b) = (encrypt_bytes(a.clone(), b"UE"), encrypt_bytes(b.clone(), b"UE")); + let (oe_a, oe_b) = (encrypt_bytes(a, b"OE"), encrypt_bytes(b, b"OE")); + assert_eq!((u_a.len(), u_b.len(), o_a.len(), o_b.len()), (48, 48, 48, 48)); + assert_ne!(&u_a[32..48], &u_b[32..48], "user validation and key salts must differ across fresh sessions"); + assert_ne!(&o_a[32..48], &o_b[32..48], "owner validation and key salts must differ across fresh sessions"); + assert_ne!(ue_a, ue_b, "wrapped user file keys must differ across fresh sessions"); + assert_ne!(oe_a, oe_b, "wrapped owner file keys must differ across fresh sessions"); + + // Generated owner passwords in R4 (AES-128) must also come from CSPRNG entropy, even when the + // document already carries a fixed /ID and only an open password is set. + let mut with_id = pdfcraft_cos::Document::open(raw).unwrap(); + let fixed_id = pdfcraft_cos::Object::String(pdfcraft_cos::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true }); + with_id.trailer_mut().set(b"ID".to_vec(), pdfcraft_cos::Object::Array(vec![fixed_id.clone(), fixed_id])); + let raw_with_id = Arc::new(pdfcraft_cos::write_full(&with_id, &Default::default()).unwrap()); + let protect_r4 = || -> Vec { + let mut s = Session::new(); + let id = s.open("same.pdf", None, raw_with_id.clone(), None).unwrap(); + let p = Protection { algorithm: pdfcraft_cos::Algorithm::Aes128, ..protection(Some("secret"), None) }; + s.apply(id, Edit::Protect(p)).unwrap(); + encrypt_bytes(s.save_bytes(id).unwrap(), b"O") + }; + assert_ne!(protect_r4(), protect_r4(), "generated owner passwords must differ across fresh sessions"); +} + +#[test] +fn protect_fails_closed_when_randomness_is_unavailable() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + fn fail_rng(_: &mut [u8]) -> Result<(), getrandom::Error> { + Err(getrandom::Error::UNSUPPORTED) + } + fn zero_rng(out: &mut [u8]) -> Result<(), getrandom::Error> { + out.fill(0); + Ok(()) + } + fn fail_second_rng(out: &mut [u8]) -> Result<(), getrandom::Error> { + if CALLS.fetch_add(1, Ordering::SeqCst) == 0 { + out.fill(0xA5); + Ok(()) + } else { + Err(getrandom::Error::UNSUPPORTED) + } + } + CALLS.store(0, Ordering::SeqCst); + for rng in [fail_rng, zero_rng, fail_second_rng] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let mut cx = EditCtx::new(None, 0); + cx.rng = rng; + let err = run_edit(&mut doc, &Edit::Protect(protection(Some("secret"), None)), &mut cx); + assert!(matches!(err, Err(EditError::Protection(_))), "Protect must fail closed when CSPRNG fails: {err:?}"); + assert!(doc.output_handler().is_none(), "document must stay unprotected when CSPRNG fails"); + } +} + /// A one-page form: a text field and a check box (with appearance states). fn form_fixture() -> Vec { let objs = [ @@ -1756,6 +2495,173 @@ fn form_fixture() -> Vec { out } +/// Whether `needle` appears in `bytes`, or in any stream of the PDF they hold once decoded (so +/// compressed object streams are searched too), as literal or hex string bytes. +fn appears_in_pdf(bytes: &Arc>, needle: &str) -> bool { + let hex: String = needle.bytes().map(|b| format!("{b:02x}")).collect(); + let forms = [needle.as_bytes().to_vec(), hex.clone().into_bytes(), hex.to_uppercase().into_bytes()]; + let found = |hay: &[u8]| forms.iter().any(|f| hay.windows(f.len()).any(|w| w == f.as_slice())); + if found(bytes) { + return true; + } + let doc = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + doc.object_numbers().into_iter().any(|num| match &*doc.get(pdfcraft_cos::ObjRef { num, generation: 0 }) { + pdfcraft_cos::Object::Stream(st) => st.decoded().is_ok_and(|d| found(&d)), + _ => false, + }) +} + +/// #3926: a value typed into a text field with the Password flag stays in the open document but +/// is written to no file: Save, a full rewrite, Reduce File Size, a recovery snapshot, or a new +/// file made from its pages (Extract, Split, Combine). ISO +/// 32000-2 §12.7.5.3, Table 231, note to the Password flag: PDF processors should never store +/// the value in the PDF file. +#[test] +fn password_field_values_are_kept_in_memory_but_written_to_no_file() { + let fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + // Offsets shift by the inserted text; the parser rebuilds the cross-reference table. + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(fixture.into_bytes()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + let field = s.get(id).unwrap().form.iter().find(|f| f.name == "name").cloned().unwrap(); + assert!(field.has(pdfcraft_forms::flags::PASSWORD)); + assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value"); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes do hold it (the test can see it)"); + + let saved = s.save_bytes(id).unwrap(); + assert!(!appears_in_pdf(&saved, "hunter2"), "Save"); + // As Acrobat saves it (#3943 review): no default value, so an empty field whose appearance + // no longer shows how long the typed password was. + assert!(!appears_in_pdf(&saved, "*******"), "the typed length is not kept in the appearance"); + assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), "hunter2"), "a full rewrite"); + assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, "hunter2"), "Reduce File Size"); + let snaps = s.autosave_snapshots(); + assert_eq!(snaps.len(), 1); + assert!(!appears_in_pdf(&snaps[0].bytes, "hunter2"), "the recovery snapshot"); + // New files built from its pages too (#3943 review): Extract copies the fields of the pages it + // takes, and Split writes each part the same way. + let extracted = s.extract(id, &[0]).unwrap(); + assert!(!appears_in_pdf(&extracted, "hunter2"), "Extract Pages"); + assert!(!appears_in_pdf(&extracted, "*******"), "nor on the extracted page"); + for (_, _, part) in s.split_by_size(id, 1).unwrap() { + assert!(!appears_in_pdf(&part, "hunter2"), "Split"); + } + assert!(!appears_in_pdf(&s.combine(&[("form.pdf".into(), s.get(id).unwrap().bytes.clone())]).unwrap(), "hunter2"), "Combine"); + // Reopened, the field is empty, as in any reader. + let mut t = Session::new(); + let reopened = t.open("saved.pdf", None, saved, None).unwrap(); + assert!(t.get(reopened).unwrap().form.iter().find(|f| f.name == "name").unwrap().value.iter().all(|v| v.is_empty())); +} + +/// #3943 review: recording a save rebases editing on the saved file, which carries no Password +/// value; the typed value stays in the open document all the same, and a later save still omits it. +#[test] +fn a_typed_password_value_survives_save_and_mark_saved() { + let fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(fixture.into_bytes()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, Some("saved.pdf".into())).unwrap(); + let value = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "name").unwrap().value.clone(); + assert_eq!(value(&s), ["hunter2"], "the open document keeps the value after Save"); + assert!(!s.get(id).unwrap().dirty, "restoring it is not an unsaved change"); + assert!(!appears_in_pdf(&s.save_bytes(id).unwrap(), "hunter2"), "the next save still omits it"); +} + +/// #3926 / #3943 review: signing writes the signed file from the same copy a save does, so a +/// typed Password-field value is not signed into the file either. +#[test] +fn a_signed_file_carries_no_password_field_value() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let fixture = String::from_utf8(form_fixture()).unwrap().replace("/T (name)", "/T (name) /Ff 8192"); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("form.pdf", None, Arc::new(fixture.into_bytes()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold it"); + let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); + assert!(!appears_in_pdf(&signed, "hunter2"), "the signed file"); + assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length"); +} + +/// #3944: a value typed into an XFA `` field stays in the open document (both the +/// widget and the live XFA datasets, even across multiple edits and across Save + `mark_saved`), +/// but is written to no file — neither on the widget nor in the XFA datasets stream. +#[test] +fn xfa_password_field_values_are_not_saved_in_datasets_or_widgets() { + let xfa_pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template("")); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("xfa.pdf", None, Arc::new(xfa_pdf), None).unwrap(); + let pin_name = s.get(id).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone(); + + // Edit the password twice so an earlier edit's datasets stream could not linger in the overlay. + s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret1".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + + let field = s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).cloned().unwrap(); + assert!(field.has(pdfcraft_forms::flags::PASSWORD)); + assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value"); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold the live datasets"); + assert!(!appears_in_pdf(&s.get(id).unwrap().bytes, "secret1"), "the first edit's datasets stream was replaced in place"); + + let saved = s.save_bytes(id).unwrap(); + for leak in ["hunter2", "secret1", "*******"] { + assert!(!appears_in_pdf(&saved, leak), "Save leaked {leak}"); + assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), leak), "save_full_bytes leaked {leak}"); + assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, leak), "reduced_bytes leaked {leak}"); + } + let snaps = s.autosave_snapshots(); + assert_eq!(snaps.len(), 1); + for leak in ["hunter2", "secret1", "*******"] { + assert!(!appears_in_pdf(&snaps[0].bytes, leak), "autosave snapshot leaked {leak}"); + } + + // Reopening the saved file in a new session leaves the password field empty (it does not + // repopulate from the XFA datasets on open). + let mut t = Session::new(); + let reopened = t.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert!( + t.get(reopened).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value.iter().all(|v| v.is_empty()), + "reopened XFA form must have an empty password field" + ); + + // Recording the save keeps the typed password in the open document (and its live datasets), + // while a subsequent save still omits it. + s.mark_saved(id, saved, Some("saved.pdf".into())).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value, ["hunter2"]); + assert!(!s.get(id).unwrap().dirty); + for leak in ["hunter2", "secret1", "*******"] { + assert!(!appears_in_pdf(&s.save_bytes(id).unwrap(), leak), "save after mark_saved leaked {leak}"); + } + + // Undo past `mark_saved` and edit again: the datasets stream in the restored overlay is + // overwritten in place rather than left behind. + s.undo(id).unwrap(); + s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret3".into()) }).unwrap(); + let saved_after_undo = s.save_bytes(id).unwrap(); + for leak in ["hunter2", "secret1", "secret3", "*******"] { + assert!(!appears_in_pdf(&saved_after_undo, leak), "save after undo leaked {leak}"); + } + + // A relayout (`rerender`) after typing a password frees the replaced generated widget and its + // masked appearance stream so neither remains in the overlay when saving. + let relayout_xml = pdfcraft_xfa::fixtures::fields_template("").replace( + r#""#, + r#""#, + ); + let id2 = s.open("relayout.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&relayout_xml)), None).unwrap(); + let pin2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone(); + let grow2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("grow")).unwrap().name.clone(); + s.apply(id2, Edit::SetFieldValue { name: pin2, value: FieldValue::Text("s3cr3t!".into()) }).unwrap(); + s.apply(id2, Edit::SetFieldValue { name: grow2, value: FieldValue::Text("line1\nline2\nline3\nline4\nline5".into()) }).unwrap(); + let saved_relayout = s.save_bytes(id2).unwrap(); + for leak in ["s3cr3t!", "*******"] { + assert!(!appears_in_pdf(&saved_relayout, leak), "save after relayout leaked {leak}"); + } +} + /// A value its field's font can't draw is kept, and the edit that drew it reports it in /// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a /// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other @@ -1768,10 +2674,10 @@ fn a_value_its_font_cannot_draw_is_reported_by_the_edit_that_drew_it() { fill(&mut s, "name", FieldValue::Text("张三".into())).unwrap(); let d = s.get(id).unwrap(); assert_eq!(d.form[0].value, ["张三"]); - let n = &d.edit_notices; - assert_eq!(n.len(), 1, "{n:?}"); - assert_eq!((n[0].field.as_deref(), n[0].page, n[0].ch, n[0].redrawn_elsewhere), (Some("name"), 0, '张', true)); - assert!(n[0].to_string().contains("\"张\" (U+5F20)") && n[0].to_string().contains("other PDF viewers redraw"), "{}", n[0]); + let [EditNotice::Undrawn(n)] = d.edit_notices.as_slice() else { panic!("{:?}", d.edit_notices) }; + assert_eq!((n.field.as_deref(), n.page, n.ch, n.redrawn_elsewhere), (Some("name"), 0, '张', true)); + let said = d.edit_notices[0].to_string(); + assert!(said.contains("\"张\" (U+5F20)") && said.contains("other PDF viewers redraw"), "{said}"); fill(&mut s, "ok", FieldValue::Check(true)).unwrap(); assert!(s.get(id).unwrap().edit_notices.is_empty(), "an unrelated edit reports nothing"); fill(&mut s, "name", FieldValue::Text("李四".into())).unwrap(); @@ -1940,15 +2846,61 @@ fn preparing_a_form_adds_renames_and_deletes_fields() { assert_eq!(names(&s), ["Text1", "Check Box1", "email"]); } +/// Setting the calculation order is one undoable edit that recalculates: values that ran too +/// early come out right at once, a script calculation included, and undo brings back the old +/// order and values together. +#[test] +fn setting_the_calculation_order_recalculates_and_undoes() { + use form_scripts::{CalcOp, Calculate}; + let (mut s, id) = session_with(1); + for (i, name) in ["qty", "sub", "total", "tax"].into_iter().enumerate() { + let y = 250.0 - 40.0 * i as f64; + let kind = NewField::Text { multiline: false }; + s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: Some(name.into()) }).unwrap(); + } + let calculate = |name: &str, c: Calculate| Edit::SetFieldProps { + name: name.into(), + props: Box::new(FieldProps { calculate: Some(c), ..Default::default() }), + }; + // Defined backwards: total, which reads sub, calculates first. + s.apply(id, calculate("total", Calculate::Simple { op: CalcOp::Sum, fields: vec!["sub".into()] })).unwrap(); + s.apply(id, calculate("sub", Calculate::Notation("qty * 2".into()))).unwrap(); + let script = Some("event.value = this.getField(\"total\").value / 10;".to_string()); + s.apply(id, Edit::SetFieldScript { name: "tax".into(), event: "calculate".into(), script }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("5".into()) }).unwrap(); + let values = |s: &Session| { + let form = &s.get(id).unwrap().form; + ["sub", "total", "tax"].map(|n| form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()).unwrap_or_default()) + }; + assert_eq!(values(&s), ["10", "0", "0"], "total and tax ran before sub"); + let order = s.get(id).unwrap().calculation_order(); + assert_eq!(order.fields.iter().map(|f| f.name.as_str()).collect::>(), ["total", "sub", "tax"]); + assert_eq!(order.fields[0].issues, [CalculationIssue::RunsTooEarly { field: "sub".into() }]); + assert_eq!(order.fixed(), ["sub", "total", "tax"]); + s.apply(id, Edit::SetCalculationOrder { names: order.fixed() }).unwrap(); + assert_eq!(values(&s), ["10", "10", "1"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Set calculation order")); + assert!(s.get(id).unwrap().calculation_order().fields.iter().all(|f| f.issues.is_empty())); + s.undo(id).unwrap(); + assert_eq!(values(&s), ["10", "0", "0"]); + assert_eq!(s.get(id).unwrap().calculation_order().fixed(), ["sub", "total", "tax"], "the old order is back"); + assert!(matches!(s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into()] }), Err(EditError::Form(_)))); +} + /// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what /// the page's /Resources holds. fn one_line_page(resources: &str, body: &str) -> Vec { + one_line_page_with(resources, "", body) +} + +/// [`one_line_page`] with `stream_entries` added to the content stream's dictionary. +fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec { let objs = [ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(), format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), - format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -1979,31 +2931,76 @@ fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) { s.apply(id, mark).unwrap(); } -/// Redaction removes text by the positions its own interpreter works out. Here the font and size -/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does -/// not model, so it places no glyph under the mark the user drew over the word as displayed: it -/// used to report success and leave the word in the file. The renderer is asked now, and the edit -/// is refused. +/// Redaction removes text by the positions its own interpreter works out, and the renderer has +/// the last word: where the two disagree, a page that still shows a glyph mostly under a mark +/// fails the edit. The check is run here on a page nothing was removed from, as if the +/// interpreter had placed the word elsewhere. Only the overlay streams an apply added are left +/// out: a content stream of the input labelled like one (`/PCMark /Redaction`) is checked too. #[test] -fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() { +fn the_renderer_check_refuses_text_still_shown_under_a_mark() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page_with("/Font << /F1 4 0 R >>", "/PCMark /Redaction", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET"); + let id = s.open("shown.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET42"); + let doc = s.get(id).unwrap(); + let mut cos = doc.editor.as_ref().unwrap().cos.clone(); + let marks = pdfcraft_redact::marks(&cos); + let refused = |cos: &pdfcraft_cos::Document| { + let shown = shown_under_redactions(cos, &marks, &[], &doc.info.pages, None); + matches!(shown, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0) + }; + assert!(refused(&cos)); + // The page's contents as an array, as they are once an apply has added its overlay. + let page = pdfcraft_model::pages(&cos).swap_remove(0); + let contents = page.dict.get(b"Contents").cloned().unwrap(); + cos.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Array(vec![contents]))).unwrap(); + assert!(refused(&cos), "the labelled input stream was not checked"); +} + +/// Inline image data `7FEI 1 0 0 1 200 0 cm` holds an `EI` the renderer ends the image at, so it +/// runs the `cm` and shows SECRET 200 pt to the right. Redaction reads the image the same way, so +/// a mark drawn over the word as shown takes it out of the file. +#[test] +fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let page = one_line_page( + "/Font << /F1 4 0 R >>", + "BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET", + ); + let id = s.open("inline.pdf", None, Arc::new(page), None).unwrap(); + mark_shown_text(&mut s, id, "SECRET"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + assert!(!page_texts(&s, id).concat().contains("SECRET")); + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(6).any(|w| w == b"SECRET"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" + ); +} + +/// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer +/// draws it, so a mark drawn over it as displayed takes it out of the file. +#[test] +fn a_word_in_a_font_set_by_an_extgstate_is_redacted() { let line = "Public words then SECRET42 and more"; let mut s = Session::new().with_clock(|| 1_700_000_000); let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET")); let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap(); mark_shown_text(&mut s, id, "SECRET42"); - let refused = s.apply(id, Edit::ApplyRedactions { pages: None }); - assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}"); - // Nothing was changed: the mark is still pending and the page still shows the line. - assert_eq!(s.get(id).unwrap().redaction_marks(), 1); - assert_eq!(page_texts(&s, id), [line]); - - // The same line with its font set by `Tf`: the mark is applied and the word is gone. - let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET")); - let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap(); - mark_shown_text(&mut s, id, "SECRET42"); s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); let shown = page_texts(&s, id).swap_remove(0); assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}"); + let saved = s.save_bytes(id).unwrap(); + let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42"); + assert!(!held(&saved), "the saved file holds the redacted word"); + let reopened = pdfcraft_cos::Document::open(saved).unwrap(); + assert!( + !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))), + "a saved stream holds the redacted word" + ); } #[test] @@ -2035,6 +3032,84 @@ fn redaction_marks_apply_for_good_and_undo() { assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_)))); } +/// What [`mark_with_reply`] writes that must not reach a file once the mark is removed. +const MARK_TRACES: [&str; 5] = ["/Redact", "MARKED OVERLAY", "Mark Author", "REPLY TO THE MARK", "Replier"]; + +/// A redaction mark on page 1 (the first annotation there) and a reply to it. +fn mark_with_reply(s: &mut Session, id: DocId) { + let shape = Shape::Redact { + quads: vec![pdfcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])], + overlay: "MARKED OVERLAY".into(), + look: Default::default(), + }; + let mark = Edit::AddAnnotation(NewAnnotation { + page: 0, + style: Style::default_for(&shape), + shape, + contents: String::new(), + author: "Mark Author".into(), + }); + s.apply(id, mark).unwrap(); + let reply = Edit::ReplyToAnnotation { page: 0, index: 0, text: "REPLY TO THE MARK".into(), author: "Replier".into() }; + s.apply(id, reply).unwrap(); +} + +/// Marks removed before they are applied, all at once or one by one, are not saved, nor are +/// replies to them: the file would otherwise record where they were, their overlay text and who +/// made them, and the page is left as it was. +#[test] +fn removed_redaction_marks_are_not_saved() { + for clear in [true, false] { + let (mut s, id) = session_with(1); + mark_with_reply(&mut s, id); + let removal = if clear { Edit::ClearRedactions } else { Edit::DeleteAnnotation { page: 0, index: 0 } }; + s.apply(id, removal).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_marks(), 0); + let saved = s.save_bytes(id).unwrap(); + for trace in MARK_TRACES { + assert!(!saved.windows(trace.len()).any(|w| w == trace.as_bytes()), "clear: {clear}, {trace:?}"); + } + let mut reopened = Session::new(); + let back = reopened.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert_eq!(page_texts(&reopened, back), ["Page 1"]); + let page = pdfcraft_model::pages(&reopened.get(back).unwrap().editor.as_ref().unwrap().cos).swap_remove(0); + assert!(!page.dict.contains(b"Annots"), "clear: {clear}"); + + // Undo past that save brings the mark back, under the number the saved file left free. + s.mark_saved(id, saved, None).unwrap(); + s.undo(id).unwrap(); + let again = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let back = reopened.open("again.pdf", None, again.clone(), None).unwrap(); + let d = reopened.get(back).unwrap(); + assert!(d.info.warnings.is_empty() && d.editor.as_ref().unwrap().cos.repair_log().is_empty(), "{:?}", d.info.warnings); + assert_eq!(d.redaction_marks(), 1, "clear: {clear}"); + let marks = pdfcraft_redact::marks(&d.editor.as_ref().unwrap().cos); + assert_eq!(marks.first().map(|m| m.overlay.as_str()), Some("MARKED OVERLAY")); + } +} + +/// Removed marks are not in a signed file either, nor in one with revocation evidence embedded: +/// no later save could take them out of those. +#[test] +fn removed_redaction_marks_are_not_signed() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + mark_with_reply(&mut s, id); + s.apply(id, Edit::ClearRedactions).unwrap(); + let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); + let evidence = sign::dss::Evidence { certs: vec![digital_id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }; + let ltv = s.embed_ltv(id, &evidence).unwrap(); + for (what, out) in [("signed", signed), ("LTV", ltv)] { + for trace in MARK_TRACES { + assert!(!out.windows(trace.len()).any(|w| w == trace.as_bytes()), "{what}: {trace:?}"); + } + } +} + #[test] fn oversized_redaction_overlay_preserves_the_document_and_undo() { let (mut s, id) = session_with(1); @@ -2226,6 +3301,98 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background")); } +/// One 100 × 100 page under a background (`/PCMark /Background`), its own content painting a +/// white backdrop first. With `copy`, that content is a backdrop copy whose tag is `copy`. +fn backdrop_pdf(copy: Option<&str>) -> Vec { + let bg = "q 1 0 0 rg 0 0 100 100 re f Q"; + let (tag, fill) = match copy { + Some(tag) => (format!("/PCBackdrop {tag} "), "n"), + None => (String::new(), "f"), + }; + let content = format!("1 g 0 0 100 100 re {fill} 0 g BT /F1 12 Tf 10 50 Td (Hi) Tj ET"); + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >> endobj\n\ + 4 0 obj << /PCMark /Background /Length {} >> stream\n{bg}\nendstream endobj\n\ + 5 0 obj << {tag}/Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + bg.len(), + content.len() + ) + .into_bytes() +} + +/// Remove background says which pages keep their own white backdrop off (it couldn't be turned +/// back on safely), and says nothing when every backdrop comes back. +#[test] +fn remove_background_names_the_pages_whose_own_backdrop_stays_off() { + let mut s = Session::new(); + // A tag whose hash doesn't match the bytes before the fill: something changed them since. + let tag = format!("<< /Index 2 /Op (f) /Before <{}> >>", "00".repeat(32)); + let id = s.open("a.pdf", None, Arc::new(backdrop_pdf(Some(&tag))), None).unwrap(); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.edit_notices, [EditNotice::BackdropsLeftOff { pages: vec![0] }]); + let said = d.edit_notices[0].to_string(); + assert!(said.starts_with("page 1 changed after the background was added"), "{said}"); + // An unrelated edit after it says nothing. + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + + // Added and removed here: the backdrop comes back, and nothing is said. + let id = s.open("b.pdf", None, Arc::new(backdrop_pdf(None)), None).unwrap(); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + let background = Background { color: [1.0, 0.0, 0.0], ..Background::default() }; + s.apply(id, Edit::AddBackground { pages: vec![0], settings: background, replace: false, file: None }).unwrap(); + assert_eq!(s.get(id).unwrap().page_backdrops(&[0]), [None], "replaced"); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + assert_eq!(s.get(id).unwrap().page_backdrops(&[0]), [Some(Backdrop::Fill)], "turned back on"); + assert_eq!(page_runs(&[0, 1, 2, 5, 7, 8]), "1-3, 6, 8-9"); + assert_eq!(page_runs(&[]), ""); +} + +/// Update previews its marks on the page without the ones it replaces: a yellow background +/// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one. +/// The other kinds of mark and the page's own content stay, every page is prepared at once (the +/// dialog steps through them), a document without that kind has nothing to prepare, and +/// preparing changes nothing in the document. +#[test] +fn update_previews_on_the_page_without_the_marks_it_replaces() { + let (mut s, id) = session_with(2); + let yellow = Background { color: [1.0, 1.0, 0.0], ..Background::default() }; + s.apply(id, Edit::AddBackground { pages: vec![0, 1], settings: yellow, replace: false, file: None }).unwrap(); + let red = Watermark { text: "SECRET".into(), color: [1.0, 0.0, 0.0], opacity: 1.0, ..Watermark::default() }; + s.apply(id, Edit::AddWatermark { pages: vec![0], settings: red, replace: false, file: None }).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 3] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..3].try_into().unwrap() + }; + let reds = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r > 200 && *g < 80 && *b < 80).count(); + let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count(); + assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background"); + + let mut unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background"); + let page = unmarked.render_page(0, 1.0).unwrap(); + assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white"); + assert!(reds(&page) > 50, "the watermark stays"); + assert!(dark(&page) > 10, "the page text stays"); + + let page = doc.without_marks(MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_page(0, 1.0).unwrap(); + assert_eq!(reds(&page), 0, "without the watermark"); + assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays"); + + assert_eq!(px(&unmarked.render_page(1, 1.0).unwrap(), 5, 5), [255, 255, 255], "made once, for every page: page 2 is white too"); + assert_eq!(unmarked.parser_builds(), 1, "parsed once for both pages"); + assert!(doc.without_marks(MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); + assert!(unmarked.render_page(9, 1.0).is_err(), "no page 10"); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add watermark")), "the document is unchanged"); +} + #[test] fn marker_indexes_match_after_open_and_save_when_requested() { let (mut source, source_id) = session_with(1); @@ -2292,7 +3459,12 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { assert!(doc.links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list"); } + metadata_and_comment_edits_keep_the_indexes(&mut session, id); + link_edits_rebuild_only_the_link_list(&mut session, id); + content_and_marker_edits_rebuild_the_markers(&mut session, id); +} +fn metadata_and_comment_edits_keep_the_indexes(session: &mut Session, id: DocId) { session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert!(session.get(id).unwrap().marks().is_empty()); @@ -2301,7 +3473,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index"); +} +fn link_edits_rebuild_only_the_link_list(session: &mut Session, id: DocId) { session .apply( id, @@ -2325,7 +3499,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap(); assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes"); assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes"); +} +fn content_and_marker_edits_rebuild_the_markers(session: &mut Session, id: DocId) { session .apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } }) .unwrap(); @@ -2460,6 +3636,116 @@ fn comment_text(s: &Session, id: DocId) -> Vec { t } +/// A stamp whose reply is locked can still be dragged, so its layers are still prepared: the page +/// beneath is drawn on a copy, where locks don't count. Deleting the stamp is what they refuse. +#[test] +fn a_stamp_with_a_locked_reply_still_has_its_drag_layers() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "Approved".into(), author: "Bob".into() }).unwrap(); + let reply = s.get(id).unwrap().info.annotations.iter().find(|a| a.parent == Some((0, 0))).expect("the reply").index; + s.apply(id, Edit::LockAnnotation { page: 0, index: reply, locked: true }).unwrap(); + let doc = s.get(id).unwrap(); + let background = doc.stamp_preview(0, 0).unwrap().expect("the line is a stamp").render_background(1.0).unwrap(); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + assert!((30..=130).all(|x| px(&background, x, 63) == [255, 255, 255, 255]), "the page beneath has no line"); + assert!(s.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).is_err(), "the locked reply keeps the stamp"); +} + +/// A dragged stamp's layers: the page without it (its text and other comments kept) and the +/// stamp alone on a transparent page. Preparing them changes nothing in the document. +#[test] +fn stamp_preview_layers_split_the_stamp_from_its_page() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let preview = doc.stamp_preview(0, 0).unwrap().expect("the line is a stamp"); + let (stamp, background) = (preview.render_stamp(1.0).unwrap(), preview.render_background(1.0).unwrap()); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + let line = |x: u32, y: u32| (30..=130).contains(&x) && (59..=67).contains(&y); + let (mut drawn, mut stray) = (0, Vec::new()); + for y in 1..300 { + for x in 0..200 { + match (line(x, y), px(&stamp, x, y)) { + (true, [.., 255]) => drawn += 1, + (false, v) if v != [0, 0, 0, 0] => stray.push((x, y)), + _ => {} + } + } + } + assert!(drawn > 100, "the line is drawn opaque: {drawn} pixels"); + assert!(stray.is_empty(), "only the line is drawn on its layer: {:?}", &stray[..stray.len().min(8)]); + assert!((30..=130).all(|x| px(&background, x, 63) == [255, 255, 255, 255]), "the page beneath has no line"); + assert!((138..=142).any(|x| px(&background, x, 65) != [255, 255, 255, 255]), "other comments stay"); + assert!((20..120).any(|x| (150..170).any(|y| px(&background, x, y)[0] < 128)), "the page text stays"); + assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a rectangle isn't a stamp"); + assert!(doc.stamp_preview(0, 9).unwrap().is_none()); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged"); + + // Another app's comments without appearances: beneath a dragged stamp they are drawn as on + // screen, and such a stamp has nothing to lay over the page. + let bare = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj +5 0 obj << /Type /Annot /Subtype /Stamp /Rect [20 20 80 40] /Name /Draft >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new(); + let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap(); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + let doc = s.get(id).unwrap(); + let background = doc.stamp_preview(0, 2).unwrap().expect("the line is a stamp").render_background(1.0).unwrap(); + assert!((138..=142).any(|x| px(&background, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn beneath"); + assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance"); +} + +/// A moved comment's layer: the comment alone on a transparent page (no page text, no other +/// comment), drawn as on screen, another app's comment without an appearance too. Preparing it +/// changes nothing in the document. +#[test] +fn comment_layer_draws_the_comment_alone() { + let (mut s, id) = session_with(1); + s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap(); + s.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let layer = doc.comment_layer(0, &[0]).unwrap().expect("a rectangle").render(1.0).unwrap(); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + assert_eq!(px(&layer, 165, 65), [255, 0, 0, 255], "the rectangle is drawn"); + let near = |x: u32, y: u32| (137..=193).contains(&x) && (37..=93).contains(&y); + let stray: Vec<(u32, u32)> = + (1..300).flat_map(|y| (0..200).map(move |x| (x, y))).filter(|&(x, y)| !near(x, y) && px(&layer, x, y) != [0, 0, 0, 0]).collect(); + assert!(stray.is_empty(), "only the rectangle is on its layer: {:?}", &stray[..stray.len().min(8)]); + assert!(doc.comment_layer(0, &[9]).unwrap().is_none(), "no such comment"); + let both = doc.comment_layer(0, &[0, 1]).unwrap().expect("both").render(1.0).unwrap(); + assert_eq!((px(&both, 165, 65), px(&both, 40, 40)), ([255, 0, 0, 255], [255, 0, 0, 255]), "moved together, drawn together"); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged"); + + let bare = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new(); + let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap(); + let layer = s.get(id).unwrap().comment_layer(0, &[0]).unwrap().expect("drawn as on screen").render(1.0).unwrap(); + assert!((138..=142).any(|x| px(&layer, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn"); +} + #[test] fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() { let (mut s, id) = session_with(1); @@ -2580,6 +3866,70 @@ fn pages_export_as_png_jpeg_and_tiff() { assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor"); } +/// Export ▸ Image draws several pages at once: each file is the one exporting that page alone +/// writes, handed over in the order asked for, whatever the number of pages drawn at once. +#[test] +fn pages_exported_at_once_are_the_files_each_page_exports_alone_in_order() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(12); + let pages = [3, 0, 11, 5, 5, 7, 1, 2, 10, 9, 8, 6, 4]; + for format in [ImageFormat::Png, ImageFormat::Jpeg { quality: 85 }, ImageFormat::Tiff] { + let alone: Vec<(usize, Vec)> = pages.iter().map(|&p| (p, Exporter::new(s.get(id).unwrap()).image(p, 96.0, format).unwrap())).collect(); + for workers in [1, 2, 4, 8] { + let mut got = Vec::new(); + let mut ex = Exporter::new(s.get(id).unwrap()); + ex.images_on(workers, &pages, 96.0, format, |p, img| { + got.push((p, img)); + Ok(()) + }) + .unwrap(); + assert!(got == alone, "{} with {workers} workers", format.label()); + } + let mut got = Vec::new(); + Exporter::new(s.get(id).unwrap()) + .images(&pages, 96.0, format, |p, img| { + got.push((p, img)); + Ok(()) + }) + .unwrap(); + assert!(got == alone, "{}", format.label()); + } +} + +/// A page that can't be exported, or a file that can't be written, stops the export there with +/// its error: every page before it is handed over, none after it. +#[test] +fn exporting_pages_at_once_stops_at_the_first_error() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(6); + for workers in [1, 4] { + let mut ex = Exporter::new(s.get(id).unwrap()); + let mut got = Vec::new(); + let result = ex.images_on(workers, &[0, 1, 99, 2, 3], 72.0, ImageFormat::Png, |p, _| { + got.push(p); + Ok(()) + }); + assert_eq!(result, Err("page 100 does not exist".to_string()), "{workers} workers"); + assert_eq!(got, [0, 1], "{workers} workers"); + + let mut got = Vec::new(); + let result = ex.images_on(workers, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| { + got.push(p); + if p == 2 { Err("disk full".to_string()) } else { Ok(()) } + }); + assert_eq!(result, Err("disk full".to_string()), "{workers} workers"); + assert_eq!(got, [0, 1, 2], "{workers} workers"); + // The exporter is still usable afterwards. + assert!(ex.image(5, 72.0, ImageFormat::Png).is_ok()); + } + // A panicking sink stops the workers rather than leaving them waiting for it. + let mut ex = Exporter::new(s.get(id).unwrap()); + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ex.images_on(4, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| if p == 1 { panic!("sink") } else { Ok(()) }) + })); + assert!(panicked.is_err()); +} + /// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution. fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) { use tiff::{decoder::ifd::Value, tags::Tag}; @@ -2646,6 +3996,144 @@ fn added_text_and_images_render_and_stay_editable() { assert_eq!(s.get(id).unwrap().added().len(), 2); } +/// Edit ▸ Copy and Paste of added content (#2636): a text box comes back with all its style, an +/// image with its picture and transforms, in the same document and in another. +#[test] +fn pasted_added_content_keeps_its_style_and_picture() { + let (mut s, id) = session_with(2); + let text = AddedText { + rect: [20.0, 250.0, 180.0, 264.4], + text: "Styled note".into(), + family: FontFamily::Times, + bold: true, + italic: true, + size: 12.0, + color: [0.8, 0.1, 0.2], + align: TextAlign::Center, + aligns: Vec::new(), + char_spacing: 0.0, + }; + s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap(); + let copied = s.get(id).unwrap().added()[0].content.clone(); + let moved = copied.with_rect([40.0, 100.0, 200.0, 114.4]); + s.apply(id, Edit::PasteContent { page: 1, content: moved.clone(), file: None }).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Paste")); + assert_eq!(doc.added()[1].page, 1); + assert_eq!(doc.added()[1].content, moved, "every part of the style, in the new box"); + + // An image, turned, flipped and cropped. + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 20, 10); + enc.set_color(png::ColorType::Rgb); + let px: Vec = (0..200).flat_map(|i| [(i % 256) as u8, 40, 200]).collect(); + enc.write_header().unwrap().write_image_data(&px).unwrap(); + } + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 150.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap(); + let AddedContent::Image(mut img) = s.get(id).unwrap().added()[1].content.clone() else { panic!("the image is the page's second item") }; + (img.rotation, img.flip_h, img.crop) = (1, true, [0.1, 0.0, 0.2, 0.05]); + s.apply(id, Edit::UpdateContent { page: 0, index: 1, content: AddedContent::Image(img.clone()) }).unwrap(); + let (ext, file) = s.get(id).unwrap().added_image_file(0, 1).unwrap(); + assert_eq!(ext, "png"); + let file = Some(("Pasted image.png".to_string(), Arc::new(file))); + assert!(s.get(id).unwrap().added_image_file(0, 0).is_err(), "the first item is text"); + let pasted = AddedImage { rect: [60.0, 90.0, 160.0, 140.0], ..img.clone() }; + s.apply(id, Edit::PasteContent { page: 0, content: AddedContent::Image(pasted.clone()), file: file.clone() }).unwrap(); + let AddedContent::Image(got) = s.get(id).unwrap().added()[2].content.clone() else { panic!("an image was pasted") }; + assert_eq!(got, pasted, "the same picture object, box, turn, flip and crop"); + + // Into another document: the picture comes along as a file of its own. + let other = s.open("other.pdf", None, Arc::new(fixture(1)), None).unwrap(); + s.apply(other, Edit::PasteContent { page: 0, content: AddedContent::Image(pasted.clone()), file: file.clone() }).unwrap(); + s.apply(other, Edit::PasteContent { page: 0, content: copied.clone(), file: None }).unwrap(); + let d = s.get(other).unwrap(); + let AddedContent::Image(theirs) = &d.added()[0].content else { panic!("an image was pasted") }; + assert_eq!(AddedImage { image: img.image, ..theirs.clone() }, pasted, "the same box and transforms"); + assert_eq!(d.added_image_file(0, 0).unwrap().1, file.as_ref().unwrap().1.as_slice(), "the same picture"); + assert_eq!(d.added()[1].content, copied); + // Pasted there again, it draws the first paste's picture object rather than storing a third. + let first = theirs.image; + s.apply(other, Edit::PasteContent { page: 0, content: AddedContent::Image(pasted.clone()), file: file.clone() }).unwrap(); + let AddedContent::Image(again) = s.get(other).unwrap().added()[2].content.clone() else { panic!("an image was pasted") }; + assert_eq!(again.image, first, "one picture object for both pastes"); + // The object holds another picture of the same size (another document's numbering, an undone + // add): the file is embedded instead of drawing the wrong picture. + let mut other_png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut other_png, 20, 10); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[7u8; 600]).unwrap(); + } + let other_file = Some(("other.png".to_string(), Arc::new(other_png))); + s.apply(id, Edit::PasteContent { page: 0, content: AddedContent::Image(pasted.clone()), file: other_file }).unwrap(); + let AddedContent::Image(fresh) = s.get(id).unwrap().added()[3].content.clone() else { panic!("an image was pasted") }; + assert_ne!(fresh.image, img.image, "a picture object of its own"); + assert_ne!(s.get(id).unwrap().added_image_file(0, 3).unwrap().1, file.as_ref().unwrap().1.as_slice()); + assert!(matches!( + s.apply(id, Edit::PasteContent { page: 0, content: AddedContent::Image(pasted), file: None }), + Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(_))) + )); + // Four items on page 1 and the pasted text on page 2. + assert_eq!(s.get(id).unwrap().added().len(), 5, "refused pastes add nothing"); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().added().len(), 4, "a paste is one undo step"); +} + +/// An added JPEG 2000 picture is copied as the file it was added from: pasted into its own +/// document it draws the same object, and into another one it is embedded once. +#[test] +fn an_added_jpeg_2000_image_is_copied_as_its_file() { + // A JP2 file (30 × 20, three components) with a stub codestream, as Add Image takes it. + let bx = |ty: &[u8], payload: &[u8]| [((payload.len() + 8) as u32).to_be_bytes().as_slice(), ty, payload].concat(); + let ihdr = [20u32.to_be_bytes().as_slice(), &30u32.to_be_bytes(), &[0, 3, 7, 7, 0, 0]].concat(); + let signature = [0, 0, 0, 0x0C, b'j', b'P', b' ', b' ', 0x0D, 0x0A, 0x87, 0x0A]; + let file = [signature.as_slice(), &bx(b"ftyp", b"jp2 \0\0\0\0jp2 "), &bx(b"jp2h", &bx(b"ihdr", &ihdr)), &bx(b"jp2c", &[0xFF, 0x4F, 0xFF, 0x51])] + .concat(); + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 110.0, 140.0]), name: "photo.jp2".into(), bytes: Arc::new(file.clone()) }) + .unwrap(); + let (ext, copied) = s.get(id).unwrap().added_image_file(0, 0).unwrap(); + assert_eq!((ext, &copied), ("jp2", &file), "the file as it was added"); + let AddedContent::Image(img) = s.get(id).unwrap().added()[0].content.clone() else { panic!("an image was added") }; + let copied = Arc::new(copied); + let file = Some(("Pasted image.jp2".to_string(), copied.clone())); + let pasted = AddedContent::Image(AddedImage { rect: [70.0, 80.0, 130.0, 120.0], ..img.clone() }); + s.apply(id, Edit::PasteContent { page: 0, content: pasted.clone(), file: file.clone() }).unwrap(); + assert!(matches!(&s.get(id).unwrap().added()[1].content, AddedContent::Image(i) if i.image == img.image), "the same picture object"); + let other = s.open("other.pdf", None, Arc::new(fixture(1)), None).unwrap(); + for _ in 0..2 { + s.apply(other, Edit::PasteContent { page: 0, content: pasted.clone(), file: file.clone() }).unwrap(); + } + let d = s.get(other).unwrap(); + let images: Vec<_> = d + .added() + .iter() + .filter_map(|a| match &a.content { + AddedContent::Image(i) => Some(i.image), + _ => None, + }) + .collect(); + assert_eq!(images.len(), 2); + assert_eq!(images[0], images[1], "embedded once"); + assert_eq!(d.added_image_file(0, 0).unwrap().1, copied.as_slice()); +} + +#[test] +fn a_batch_of_content_edits_refreshes_the_added_content_and_marks() { + let (mut s, id) = session_with(1); + let text = |t: &str, y: f64| AddedText { rect: [20.0, y, 180.0, y + 14.4], text: t.into(), ..AddedText::default() }; + let edits = vec![Edit::AddText { page: 0, text: text("One", 200.0) }, Edit::AddText { page: 0, text: text("Two", 150.0) }]; + s.apply(id, Edit::Batch { label: "Script".into(), edits }).unwrap(); + assert_eq!(s.get(id).unwrap().added().len(), 2); + s.apply(id, Edit::Batch { label: "Script".into(), edits: vec![Edit::DeleteContent { page: 0, index: 0 }] }).unwrap(); + assert_eq!(s.get(id).unwrap().added().len(), 1); + let watermark = + Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "Draft".into(), ..Watermark::default() }, file: None, replace: false }; + s.apply(id, Edit::Batch { label: "Script".into(), edits: vec![watermark] }).unwrap(); + assert_eq!(s.get(id).unwrap().marks().len(), 1); +} + #[test] fn added_images_rotate_flip_and_crop_as_drawn() { // A 20×10 image: left half red, right half blue. @@ -2686,8 +4174,12 @@ fn added_images_rotate_flip_and_crop_as_drawn() { assert!(blue(colour_at(&s, 70, 150)) && red(colour_at(&s, 130, 150)), "flipped"); img.flip_h = false; img.crop = [0.0, 0.0, 0.5, 0.0]; - s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap(); + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box"); + // More than half from one side: only the blue right part is left. + img.crop = [0.7, 0.0, 0.0, 0.0]; + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap(); + assert!(blue(colour_at(&s, 53, 150)) && blue(colour_at(&s, 130, 150)), "70 % trimmed from the left: blue fills the box to its left edge"); } #[test] @@ -2709,6 +4201,47 @@ fn reload_reads_the_new_file_into_the_same_document() { assert_eq!(s.docs().len(), 2); } +/// #3443: reloading the file (it changed on disk) keeps how the reader shows it. Reading colours +/// reached the new renderer, not just the setting; hidden comments stay hidden. Each setting is +/// checked alone, so neither one rides on the other being changed. +#[test] +fn reload_keeps_reading_colours_and_hidden_comments() { + let dark_share = |s: &Session, id: DocId| { + let doc = s.get(id).unwrap(); + doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let page = loop { + if let Some(page) = doc.renderer.try_recv() { + break page; + } + assert!(std::time::Instant::now() < deadline, "page render did not finish"); + std::thread::yield_now(); + }; + let (px, _) = page.rgba.as_chunks::<4>(); + px.iter().filter(|p| p[0] < 64 && p[1] < 64 && p[2] < 64).count() as f64 / px.len() as f64 + }; + let (mut s, id) = session_with(1); + assert!(dark_share(&s, id) < 0.1, "a white page as the file draws it"); + assert!(s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK)); + assert!(dark_share(&s, id) > 0.9, "white on black"); + s.reload(id, Arc::new(fixture(2))).unwrap(); + assert_eq!(s.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK)); + assert!(dark_share(&s, id) > 0.9, "the reloaded page is drawn white on black too"); + assert!(!s.get(id).unwrap().comments_hidden()); + + let (mut s, id) = session_with(1); + assert!(s.set_hide_comments(id, true)); + s.reload(id, Arc::new(fixture(2))).unwrap(); + assert!(s.get(id).unwrap().comments_hidden(), "hidden comments stay hidden"); + assert_eq!(s.reading_colors(id), Some(ReadingColors::default())); + assert!(dark_share(&s, id) < 0.1); +} + #[test] fn revert_goes_back_to_the_saved_version() { let (mut s, id) = session_with(2); @@ -2735,8 +4268,8 @@ fn split_by_size_and_bookmarks_and_page_filters() { assert_eq!(parts.last().unwrap().1, 6, "every page is in a part"); assert_eq!(s.split_by_size(id, 1).unwrap().len(), 6, "pages larger than the limit stand alone"); // Top-level bookmarks name the parts. - s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Intro".into(), page: 0 }).unwrap(); - s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Results".into(), page: 3 }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Intro".into(), page: 0, left: None, top: None }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Results".into(), page: 3, left: None, top: None }).unwrap(); assert_eq!(s.bookmark_splits(id), [(0, "Intro".to_string()), (3, "Results".to_string())]); // Filters: page numbers 2, 4, 6 are even. let info = &s.get(id).unwrap().info; @@ -2869,6 +4402,78 @@ fn comments_lock_take_checkmarks_hide_and_summarize() { assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n"); } +/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every +/// page `fitz-A0`, which used to file every reply under page 1's comment. An `/IRT` to the +/// annotation itself, or to a link (never listed as a comment), leaves it a comment of its own. Both readers (inspect on open, and the +/// comment list the engine refreshes after an edit) must agree. +#[test] +fn replies_thread_by_reference_when_names_repeat_across_pages() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 8 0 R 9 0 R 10 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 8 0 R /T (Delta) /Contents (SELF) >> endobj +9 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 260 40] /Border [0 0 0] /Dest [3 0 R /Fit] >> endobj +10 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (fitz-A2) /IRT 9 0 R /T (Echo) /Contents (LINKED) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fitz.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens"); + let check = |s: &Session, replies: usize| { + let all = &s.get(id).unwrap().info.annotations; + let answer = all.iter().find(|a| a.contents.as_deref() == Some("ANSWER")).unwrap(); + assert_eq!(answer.parent, Some((1, 0)), "{answer:?}"); + let looped = all.iter().find(|a| a.contents.as_deref() == Some("SELF")).unwrap(); + assert!(!looped.is_reply(), "{looped:?}"); + let linked = all.iter().find(|a| a.contents.as_deref() == Some("LINKED")).unwrap(); + assert!(!linked.is_reply(), "{linked:?}"); + let text = comment_summary("fitz.pdf", all, SummarySort::Page); + let (cover, second) = text.split_once("SECOND").unwrap(); + assert!(!cover.contains("ANSWER"), "page 1's comment has no replies: {text}"); + assert!(cover.contains("Number: 2 Author: Delta"), "the self-reply is a comment of its own: {text}"); + assert!(cover.contains("Number: 3 Author: Echo"), "so is the reply to a link: {text}"); + assert_eq!(second.matches(" Author:").count(), replies, "{text}"); + }; + check(&s, 1); + s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "LATER".into(), author: "Delta".into() }).unwrap(); + check(&s, 2); +} + +/// A comment listed through a reference chain (`/Annots [5 0 R]`, `5 0 obj 6 0 R`) keeps its +/// replies after an edit, whether their `/IRT` names the comment or the chain, as on open. +#[test] +fn replies_thread_through_a_reference_chain_after_an_edit() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 7 0 R 8 0 R 9 0 R] >> endobj +5 0 obj 6 0 R endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (one) /Contents (CHAINED) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 130] /IRT 6 0 R /Contents (TO THE COMMENT) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 110] /IRT 5 0 R /Contents (THROUGH THE CHAIN) >> endobj +9 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (two) /Contents (PLAIN) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("chain.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens"); + let check = |s: &Session| { + let all = &s.get(id).unwrap().info.annotations; + let comment = all.iter().find(|a| a.contents.as_deref() == Some("CHAINED")).expect("listed"); + assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false), "{all:?}"); + for reply in ["TO THE COMMENT", "THROUGH THE CHAIN"] { + let a = all.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed"); + assert_eq!((a.parent, a.in_reply_to.as_deref()), (Some((0, 0)), Some("one")), "{reply}: {all:?}"); + } + }; + check(&s); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 3, text: "LATER".into(), author: "Delta".into() }).unwrap(); + check(&s); +} + /// #820: the generated summary includes replies to replies, indented one level deeper. #[test] fn comment_summary_includes_replies_to_replies() { @@ -3557,9 +5162,70 @@ fn recognize_text_reads_a_picture_on_a_page_that_has_text() { assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1)); } +/// Edit reads a scan that only has comments on it, not a page with real text and a picture +/// (`native_text_only`): a text box over the picture is not the page's text, page content is. +#[test] +fn native_text_only_ignores_comments_but_not_page_text() { + if !ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); + let comment_only = |s: &mut Session| { + let id = s.open("scan.pdf", None, scan.clone(), None).unwrap(); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let shape = pdfcraft_annot::Shape::TextBox { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], font_size: 18.0 }; + s.apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style: pdfcraft_annot::Style::default_for(&shape), + shape, + contents: "Invoice reference 4711".into(), + author: "Test".into(), + }), + ) + .unwrap(); + id + }; + let native = ocr::OcrSettings { native_text_only: true, ..Default::default() }; + + // A comment over the picture: the default run counts it as text and skips the page, the + // native-text run reads the picture. + let id = comment_only(&mut s); + let skipped = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); + assert!(skipped[0].skipped.is_some(), "a comment is text for the default run"); + let found = s.recognize_text(id, &[], native.clone()).unwrap(); + assert_eq!(found[0].skipped, None, "{:?}", found[0]); + assert!(found[0].text().to_lowercase().contains("brown"), "{:?}", found[0]); + + // Page content over the picture is the page's own text: skipped, whatever the setting. + let id = comment_only(&mut s); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let note = AddedText { rect: [w * 0.1, h * 0.6, w * 0.9, h * 0.7], text: "Page text".into(), size: 18.0, ..Default::default() }; + s.apply(id, Edit::AddText { page: 0, text: note }).unwrap(); + let skipped = s.recognize_text(id, &[], native).unwrap(); + assert!(skipped[0].skipped.is_some(), "{:?}", skipped[0]); +} + /// A form whose scripts are custom JavaScript: total = price × qty (calculate, through a /// document-level function), shown with a custom format; qty is validated; a button script. fn scripted_form() -> Vec { + scripted_form_with("function times\\(a, b\\) { console.println('times'); return a * b; }") +} + +/// [`scripted_form`] with `helpers` (PDF string syntax) as its document-level script. +fn scripted_form_with(helpers: &str) -> Vec { let text = |name: &str, y: u32, aa: &str| { format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [20 {y} 180 {}] /P 3 0 R /F 4 {aa} >>", y + 20) }; @@ -3573,7 +5239,7 @@ fn scripted_form() -> Vec { "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 150 80 170] /P 3 0 R /F 4 >>".to_string(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), "<< >>".to_string(), - "<< /S /JavaScript /JS (function times\\(a, b\\) { console.println('times'); return a * b; }) >>".to_string(), + format!("<< /S /JavaScript /JS ({helpers}) >>"), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); @@ -3590,6 +5256,8 @@ fn scripted_form() -> Vec { out } +/// AcroForm field scripts validate, calculate and format through edits, and a button script's +/// changes, messages and requests come back. #[test] fn form_javascript_validates_calculates_and_formats() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -3608,7 +5276,7 @@ fn form_javascript_validates_calculates_and_formats() { // A button script changes fields in one undoable step, and asks for a page. let o = s.run_javascript(id, "getField('price').value = 3; getField('total').readonly = true; app.alert('Done'); this.print();", Some("go")).unwrap(); - assert_eq!(o.alerts, ["Done"]); + assert_eq!(o.alerts, [crate::js::Message::plain("Done")]); assert_eq!(o.requests, [js::Request::Print]); assert_eq!(value(&s, "total"), ["12"], "recalculated after the change"); assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only()); @@ -3621,6 +5289,113 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +/// XFA forms check `xfa_installed` and `xfa_version` in a document script that runs before +/// each of their scripts, and alert when either is missing. +#[test] +fn scripts_in_an_xfa_form_see_the_xfa_plugin_installed() { + let check = "typeof xfa_installed + ' ' + typeof xfa_version + ' ' + xfa_version"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let xfa = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))), None).unwrap(); + let o = s.run_javascript(xfa, check, None).unwrap(); + assert_eq!((o.error, o.result.as_deref()), (None, Some("boolean number 3.5"))); + let plain = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let o = s.run_javascript(plain, check, None).unwrap(); + assert_eq!((o.error, o.result.as_deref()), (None, Some("undefined number 3.5"))); +} + +/// #3940: a document-level script that defines the helper and then calls a method PdfCraft lacks +/// (an Acrobat form copying a button icon at open) left every calculated field blank. The +/// helper stays defined, the form calculates, and the error is reported once. +#[test] +fn a_document_script_that_throws_still_lets_the_form_calculate() { + let helpers = + "function times\\(a, b\\) { return a * b; } this.getField\\('go'\\).buttonSetIcon\\(this.getField\\('go'\\).buttonGetIcon\\(\\)\\);"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form_with(helpers)), None).unwrap(); + let _ = s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + let total = s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); + assert_eq!(total, ["10"], "calculated through the helper the failing script defined"); + let out = s.take_js_output(id); + assert_eq!(out.errors.len(), 1, "reported once, not once per script: {out:?}"); + assert!(out.errors[0].starts_with("document script 1: TypeError"), "{out:?}"); + // The console runs too, and says which document script failed. + let o = s.run_javascript(id, "times(2, 3)", None).unwrap(); + assert_eq!((o.error, o.result.as_deref(), o.doc_errors.len()), (None, Some("6"), 1)); +} + +#[test] +fn active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts() { + let template = pdfcraft_xfa::fixtures::scripted_template().replace( + r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, + r#"xfa.host.messageBox("Hello " + qty.rawValue); xfa.host.print(1, "0", "0", 0, 0, 0, 0, 0);"#, + ); + let mut cos = pdfcraft_cos::Document::open(Arc::new(pdfcraft_xfa::fixtures::shell(&template))).unwrap(); + let fallback = "app.alert('This PDF form requires a newer version of Adobe Acrobat');"; + pdfcraft_forms::set_document_script(&mut cos, "viewerFallback", Some(fallback)).unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("xfa.pdf", None, bytes, None).unwrap(); + assert!(s.get(id).unwrap().xfa.is_some()); + assert!(s.take_js_output(id).alerts.is_empty()); + let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.alerts, [js::Message::plain("Console message")]); + assert_eq!(s.get(id).unwrap().document_scripts(), [("viewerFallback".into(), fallback.into())]); + let out = s.run_javascript(id, "", Some("hello")).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.alerts, [js::Message::plain("Hello 2")]); + assert_eq!(out.requests, [js::Request::Print]); + // The same context rule applies to format/validate/calculate through JsRunner. + s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some("app.alert('Field message');".into()) }).unwrap(); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap(); + assert_eq!(s.take_js_output(id).alerts, [js::Message::plain("Field message")]); + // Saved XFA keeps its container scripts for inspection, but does not execute them. + let saved = s.get(id).unwrap().bytes.clone(); + let reopened = s.open("saved.pdf", None, saved, None).unwrap(); + assert!(s.run_javascript(reopened, "", None).unwrap().alerts.is_empty()); + // Making the form static restores the ordinary PDF context; undo restores XFA's. + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(s.run_javascript(id, "", None).unwrap().alerts.len(), 1); + s.undo(id).unwrap(); + assert!(s.run_javascript(id, "", None).unwrap().alerts.is_empty()); +} + +#[test] +fn active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts() { + let template = r#""#; + let mut s = Session::new(); + let id = s.open("no-scripts.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(template)), None).unwrap(); + assert!(s.get(id).unwrap().xfa.is_some()); + assert!(s.get(id).unwrap().xfa_template.is_none()); + s.apply(id, Edit::SetDocumentScript { name: "fallback".into(), script: Some("throw new Error('PDF fallback ran');".into()) }).unwrap(); + let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap(); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.alerts, [js::Message::plain("Console message")]); +} + +#[test] +fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { + for bytes in [ + fixture(1), + pdfcraft_xfa::fixtures::static_shell("Test"), + pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()), + ] { + let mut cos = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap(); + pdfcraft_forms::set_document_script(&mut cos, "helper", Some("function twice(n) { return n * 2; } app.alert('PDF message');")).unwrap(); + // The raw dynamic shell has not been laid out: XFA's presence alone is not enough. + let runner = js::JsRunner::new(&cos, "control.pdf"); + let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default()); + assert!(out.error.is_none(), "{out:?}"); + assert_eq!(out.result.as_deref(), Some("6")); + assert_eq!(out.alerts, [js::Message::plain("PDF message")]); + } +} + #[test] fn edit_history_does_not_replay_field_script_side_effects() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -3681,6 +5456,28 @@ fn detecting_fields_on_a_printed_form() { assert!(s.detect_fields(id, &[]).is_empty(), "nothing left to detect"); } +/// #294, #2831: a detected field sits on a line or box the page already draws, so it gets no border +/// and no fill that would cover the printed form. A field drawn by hand keeps the defaults. +#[test] +fn detected_fields_have_no_border_or_fill() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("paper.pdf", None, Arc::new(paper_form()), None).unwrap(); + let added = s.auto_detect_fields(id, &[]).unwrap(); + let d = s.get(id).unwrap(); + let kinds: Vec = d.form.iter().map(|f| f.kind).collect(); + assert!(kinds.contains(&pdfcraft_forms::FieldKind::Text) && kinds.contains(&pdfcraft_forms::FieldKind::CheckBox), "{kinds:?}"); + for name in &added { + let look = d.field_look(name).unwrap(); + assert_eq!((look.border, look.fill), (None, None), "{name}"); + } + assert_eq!(d.can_undo(), Some("Detect form fields"), "still one step"); + + s.apply(id, Edit::AddField { page: 0, rect: [200.0, 10.0, 300.0, 30.0], kind: NewField::Text { multiline: false }, name: Some("drawn".into()) }) + .unwrap(); + let look = s.get(id).unwrap().field_look("drawn").unwrap(); + assert!(look.border.is_some() && look.fill.is_some(), "{look:?}"); +} + /// A printed form, 420 × 300 pt: a title, a label with a rule to write on, a box with a small /// label in its corner, an empty box with its label above, and two check boxes. fn paper_form() -> Vec { @@ -3924,6 +5721,24 @@ fn actions_run_their_steps_on_files() { assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err()); } +/// A removal step that fails (here: a signed file can't be rewritten without its hidden data) +/// fails the action, so the file is not passed on as cleaned. +#[test] +fn actions_fail_when_hidden_information_could_not_be_removed() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap(); + s.apply(id, Edit::SetInfo { key: "Author".into(), value: "Private Name".into() }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + let signed = s.sign(id, &digital_id, SignOptions { page: 1, ..SignOptions::default() }).unwrap(); + let built = actions::builtin(); + let sanitize = built.iter().find(|a| a.name == "Sanitize Documents").unwrap(); + let e = actions::run_on(sanitize, "signed.pdf", signed, |_, _| {}).map(|r| r.log).unwrap_err(); + assert!(e.starts_with("Sanitize document:"), "{e}"); +} + #[test] fn exporting_office_files_keeps_images() { let mut s = Session::new(); @@ -4071,6 +5886,80 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +/// A laid-out XFA field that doesn't scroll (`hScrollPolicy="off"`) opens as a text field with +/// DoNotScroll, and its text box says what fits it: what the editor takes no more than. +#[test] +fn an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box() { + let xdp = r##" +"##; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let doc = s.get(id).unwrap(); + let field = |end: &str| doc.form.iter().find(|f| f.name.ends_with(end)).unwrap_or_else(|| panic!("no field {end}")); + assert!(field("code").has(field_flags::DO_NOT_SCROLL) && !field("note").has(field_flags::DO_NOT_SCROLL)); + let tb = doc.field_text_box(&field("code").name, 0).expect("a text box"); + assert!(tb.fits("A1B2") && !tb.fits(&"W".repeat(40)), "a 1-inch field at 10 pt"); + assert!(doc.field_text_box("no such field", 0).is_none() && doc.field_text_box(&field("code").name, 5).is_none()); +} + +/// A round XFA check button opens drawn round: the appearance its laid-out widget gets draws a +/// circle, the on state with its mark in it, while a square one keeps its box. +#[test] +fn round_xfa_check_buttons_open_drawn_round() { + let xdp = r##" +"##; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + let cos = s.get(id).unwrap().editor.as_ref().unwrap().cos.clone(); + let all = pdfcraft_forms::fields(&cos); + // The decoded appearance of `name`'s widget in `state`. + let drawn = |name: &str, state: &[u8]| { + let w = all.iter().find(|f| f.name.ends_with(name)).unwrap_or_else(|| panic!("no field {name}")).widgets[0].obj; + let wd = cos.get(w).as_dict().cloned().unwrap(); + let n = cos.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap()); + let r = n.as_dict().unwrap().reference(state).unwrap_or_else(|| panic!("{name} has no {} state", String::from_utf8_lossy(state))); + let pdfcraft_cos::Object::Stream(st) = &*cos.get(r) else { panic!("a stream") }; + String::from_utf8_lossy(&st.decoded().unwrap()).into_owned() + }; + for state in [&b"Off"[..], b"1"] { + let round = drawn("round", state); + assert!(round.contains(" c S") && !round.contains(" re S"), "the round one's border is a circle: {round}"); + assert!(drawn("square", state).contains(" re S"), "the square one keeps its box"); + } +} + +#[test] +fn a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided() { + let xdp = pdfcraft_xfa::fixtures::template(3); + let original = Arc::new(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + let warnings = &doc.xfa.as_ref().expect("laid out from the template").warnings; + assert!(warnings.iter().any(|w| w.contains("usage rights")), "the user is told: {warnings:?}"); + assert!(!doc.dirty, "laying out is still not an edit"); + s.apply(id, Edit::SetFieldValue { name: "familyName".into(), value: FieldValue::Text("Singh".into()) }).unwrap(); + let incremental = s.save_bytes(id).unwrap(); + assert!(incremental.starts_with(&original[..]), "still an incremental save"); + // Saved either way, the file no longer carries them, and keeps what was filled in. + for saved in [incremental, s.save_full_bytes(id).unwrap()] { + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let catalog = cos.get(cos.root().unwrap()).as_dict().cloned().unwrap(); + assert!(!catalog.contains(b"Perms"), "{catalog:?}"); + let again = s.open("saved.pdf", None, saved, None).unwrap(); + let d2 = s.get(again).unwrap(); + assert_eq!(d2.form.iter().find(|f| f.name == "familyName").unwrap().value, vec!["Singh".to_string()]); + let warnings = &d2.xfa.as_ref().expect("the layout is remembered").warnings; + assert!(!warnings.iter().any(|w| w.contains("usage rights")), "nothing left to say: {warnings:?}"); + } +} + #[test] fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3))); @@ -4155,6 +6044,43 @@ fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() { assert!(!doc.dirty); } +/// #3180: an XFA form with no fields whose configuration says `dynamicRender` is `forbidden` is a +/// static form: its pages are the form. Opening it used to lay the template out into new pages +/// and write them as a revision, so even a save with no change replaced the pages. +#[test] +fn an_xfa_form_without_fields_that_forbids_dynamic_rendering_keeps_its_pages() { + // The fixture's own config packet gains `acrobat/acrobat7/dynamicRender`. + let template = pdfcraft_xfa::fixtures::template(1); + let forbidden = "forbidden"; + let with_config = template.replacen("", forbidden, 1); + assert_ne!(with_config, template, "the fixture has a config packet"); + // The fixture shell without /NeedsRendering (blanked in place, so the offsets stay right). + let shell = |xdp: &str| { + let bytes = pdfcraft_xfa::fixtures::shell(xdp); + let at = bytes.windows(20).position(|w| w == b"/NeedsRendering true").expect("the fixture sets it"); + let mut bytes = bytes; + bytes.splice(at..at + 20, *b" "); + Arc::new(bytes) + }; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let original = shell(&with_config); + let id = s.open("static.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static)); + assert!(doc.xfa.is_none(), "nothing was laid out"); + assert_eq!(doc.bytes[..], original[..], "no revision was appended on open"); + assert_eq!(doc.info.pages.len(), 1); + // The same form without the config is still taken to be dynamic: laid out from the template + // into an appended revision. + let plain = shell(&template); + let id = s.open("dynamic.pdf", None, plain.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert!(doc.xfa.is_some(), "laid out from the template"); + assert!(doc.bytes.len() > plain.len() && doc.bytes.starts_with(&plain[..]), "the layout is a revision"); +} + +/// A static XFA form's fields take their values from the datasets on opening, and edits are +/// written back there. #[test] fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada1")); @@ -4184,6 +6110,124 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten"); } +/// A static XFA form whose values are only in its datasets takes every one on opening, as +/// filling the fields one at a time would leave it: values, appearances and the note; a +/// read-only field keeps its own value. +#[test] +fn a_static_xfa_form_takes_many_data_values_as_one_by_one() { + let n = 60; + let original = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("many.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static)); + assert!(!doc.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision"); + // The read-only first field keeps its own (empty) value, and is not named. + let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning"); + assert!(note.starts_with("59 form field values") && note.contains("form1[0].page1[0].f1[0]") && note.ends_with("and 54 more"), "{note}"); + assert!(!note.contains("f0[0]"), "{note}"); + // The same values filled one at a time. + let mut one_by_one = pdfcraft_cos::Document::open(original).unwrap(); + for i in 0..n { + let r = pdfcraft_forms::set_value(&mut one_by_one, &format!("form1[0].page1[0].f{i}[0]"), &FieldValue::Text(format!("v{i}"))); + assert_eq!(r.is_err(), i == 0, "field {i}: {r:?}"); + } + let opened = &doc.editor.as_ref().unwrap().cos; + let shown = |d: &pdfcraft_cos::Document| -> Vec<(String, Vec, String)> { + pdfcraft_forms::fields(d) + .into_iter() + .map(|f| { + let w = f.widgets.first().map(|w| w.obj).unwrap(); + let ap = d.get(w).as_dict().and_then(|w| w.get(b"AP").cloned()).map(|a| d.resolve(&a)); + let n = ap.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())).map(|n| d.resolve(&n)); + let look = match n.as_deref() { + Some(pdfcraft_cos::Object::Stream(st)) => String::from_utf8_lossy(&st.decoded().unwrap()).into_owned(), + _ => String::new(), + }; + (f.name, f.value, look) + }) + .collect() + }; + let got = shown(opened); + assert_eq!(got.len(), n); + assert_eq!(got, shown(&one_by_one)); + assert!(got[0].1.is_empty()); + assert!(got.iter().enumerate().skip(1).all(|(i, (_, v, ap))| *v == [format!("v{i}")] && ap.contains(&format!("(v{i})")))); +} + +/// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value +/// read the whole form again (twice), so the time grew with the square of the fields. Eight +/// times the fields now takes at most about eight times as long (it took about fifty). +#[test] +fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() { + let time = |n: usize| { + let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n)); + (0..3) + .map(|_| { + let mut s = Session::new(); + let start = std::time::Instant::now(); + s.open("many.pdf", None, bytes.clone(), None).expect("opens"); + start.elapsed() + }) + .min() + .unwrap_or_default() + }; + let small = time(100); + let large = time(800); + let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6); + assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)"); +} + +/// A date goes into the XFA data in the field's data picture (``), as other +/// viewers write and read it, and comes back from there when the form is opened again. It used +/// to be written as an ISO date whatever the picture said. +#[test] +fn xfa_dates_are_written_and_read_back_in_their_data_picture() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::bound_date_template(""))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + s.apply(id, Edit::SetFieldValue { name: "due".into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "when".into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].due[0]")), Some("20261018"), "in the field's data picture"); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].when[0]")), Some("2026-10-18"), "ISO without one"); + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + let value = |n: &str| d2.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone(); + assert_eq!(value("due"), vec!["10/18/2026".to_string()]); + assert_eq!(value("when"), vec!["10/18/2026".to_string()]); +} + +/// Same-named date fields are each written in their own data picture, and a data picture +/// without a day keeps the ISO date. +#[test] +fn each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::sibling_date_template(""))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + // Two siblings named `due` (laid out as `due` and `due_2`) and a field bound `YYYYMM`. + for name in ["due", "due_2", "month"] { + s.apply(id, Edit::SetFieldValue { name: name.into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap(); + } + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + let at = |som: &str| data.text_at(&pdfcraft_xfa::som_to_path(som)).map(str::to_string); + assert_eq!(at("form[0].page[0].due[0]").as_deref(), Some("20261018"), "the first sibling's data picture"); + assert_eq!(at("form[0].page[0].due[1]").as_deref(), Some("18102026"), "the second sibling's own"); + // A data picture without a day would drop the one the field shows: the data keeps ISO. + assert_eq!(at("form[0].page[0].month[0]").as_deref(), Some("2026-10-18")); + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + for name in ["due", "due_2", "month"] { + let f = d2.form.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}")); + assert_eq!(f.value, vec!["10/18/2026".to_string()], "{name} reads back"); + } +} + +/// Data no field binds to (other namespaces, history) survives an edit and a save. #[test] fn xfa_data_no_field_binds_to_survives_an_edit_and_save() { // Regression: editing a field replaced the whole data element with the fields' values. @@ -4217,6 +6261,8 @@ fn xfa_data_no_field_binds_to_survives_an_edit_and_save() { assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content"))); } +/// An XFA form's choice lists, signature and picture fields open as their kinds of field, take +/// values that scripts read, and are saved into the datasets. #[test] fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { let data = "
JapanEnglish\nSpanish
"; @@ -4236,7 +6282,8 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { assert_eq!(langs.value, vec!["English".to_string(), "Spanish".to_string()]); assert!(field(&s, "other").has(pdfcraft_forms::flags::EDIT)); assert!(field(&s, "pin").has(pdfcraft_forms::flags::PASSWORD)); - assert_eq!(field(&s, "sign").kind, K::Signature); + // The signature field is named by its SOM path, as another viewer finds a signature. + assert_eq!(field(&s, "form[0].page1[0].sign[0]").kind, K::Signature); // Scripts read a choice list's saved value. assert_eq!(field(&s, "summary").value, vec!["Country: JP".to_string()], "{:?}", s.take_js_output(id)); // Choosing another item recalculates; the list takes several values; a typed value is kept @@ -4279,6 +6326,8 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]); } +/// A form's data exports, imports and takes pictures through the session, and what it holds +/// comes back the same. #[test] fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { use pdfcraft_xfa::som_to_path; @@ -4337,6 +6386,11 @@ fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains("xfa:contentType=\"image/gif\"")); assert_eq!(s.get(id).unwrap().page_images(0).len(), 2, "the field's picture (now the GIF) and the logo"); // Only pictures the form can show; only image fields. + for (data, declared) in [(pdfcraft_xfa::fixtures::tiny_bmp(false), "image/bmp"), (pdfcraft_xfa::fixtures::tiny_tiff(false), "image/tiff")] { + s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(data) }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains(&format!("xfa:contentType=\"{declared}\""))); + } assert!(s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(b"BM\0\0\0\0".to_vec()) }).is_err()); assert!(s.apply(id, Edit::SetFieldImage { name: "country".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).is_err()); // One datasets stream per edit, however many steps it took. @@ -4360,6 +6414,8 @@ fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { assert_eq!(d2.page_images(0).len(), 2); } +/// Importing data into a static form replaces every value, emptying the fields the file +/// doesn't name, and pictures chosen for it reach its data. #[test] fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_data() { let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada1")); @@ -4380,10 +6436,13 @@ fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_ assert!(xml.contains("Bob") && !xml.contains("1"), "{xml}"); } +/// Importing data into a dynamic form adds and removes the rows of its repeating subform to +/// match, and the calculations over them run again. #[test] fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); - let mut s = Session::new().with_clock(|| 1_700_000_000); + // Raised time limits: a busy machine does not stop the calculations this checks. + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); let id = s.open("rows.pdf", None, bytes, None).expect("opens"); let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); @@ -4412,14 +6471,19 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { assert!(String::from_utf8_lossy(&out).contains("only")); } +/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine, and a +/// change recalculates them in one more. The time limits are raised so that a busy machine +/// does not stop them; the next test checks that the limits stop them. #[test] -fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { +fn hundreds_of_xfa_calculations_run_in_one_engine() { let (sections, rows) = (10, 20); let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))); - let mut s = Session::new().with_clock(|| 1_700_000_000); + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + let engines = pdfcraft_js::xfa_session::engines_started(); let started = std::time::Instant::now(); let id = s.open("many.pdf", None, bytes, None).expect("opens"); let elapsed = started.elapsed(); + assert_eq!(pdfcraft_js::xfa_session::engines_started() - engines, 1, "the scripts of the opening share one engine"); let out = s.take_js_output(id); assert!(!out.errors.iter().any(|e| e.contains("were skipped") || e.contains("stopped")), "{:?}", out.errors); let form = &s.get(id).unwrap().form; @@ -4432,9 +6496,10 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { let expect = (rows * (rows + 1)).to_string(); assert!(sums.iter().all(|f| f.value == vec![expect.clone()]), "{:?}", sums.iter().map(|f| f.value.clone()).collect::>()); eprintln!("{} calculate scripts on open in {elapsed:?}", sections * rows + sections); - // A change recalculates them all again, well within the event budget. + // A change recalculates them all again, in one more engine. let started = std::time::Instant::now(); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap(); + assert_eq!(pdfcraft_js::xfa_session::engines_started() - engines, 2, "the scripts of the change share one engine"); let out = s.take_js_output(id); assert!(!out.errors.iter().any(|e| e.contains("were skipped")), "{:?}", out.errors); assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, vec!["200".to_string()]); @@ -4442,11 +6507,37 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { eprintln!("recalculated after a change in {:?}", started.elapsed()); } +/// The time the scripts of an opening share stops the calculations it has no time for, and +/// says how many were skipped; a change's scripts are stopped the same way. +#[test] +fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() { + let (sections, rows) = (10, 20); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))); + // A time scale of 0 leaves no time at all: every script is skipped before it starts. + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(0); + let engines = pdfcraft_js::xfa_session::engines_started(); + let id = s.open("many.pdf", None, bytes, None).expect("opens"); + let skipped = sections * rows + sections; + let stopped = format!("The form's scripts were stopped: the scripts took more than 0 seconds; {skipped} more were skipped"); + assert_eq!(s.take_js_output(id).errors, vec![stopped.clone()]); + let totals = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("total")).map(|f| f.value.clone()).collect::>(); + assert_eq!(totals(&s).len(), sections * rows); + assert!(totals(&s).iter().all(Vec::is_empty), "no total was calculated: {:?}", totals(&s)); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap(); + assert!(s.take_js_output(id).errors.iter().any(|e| e.starts_with("The form's scripts were stopped: the scripts took more than 0 seconds"))); + assert!(totals(&s).iter().all(Vec::is_empty), "no total was calculated: {:?}", totals(&s)); + assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started"); +} + +/// The scripts of one event share the form as the engine holds it: a failed script's +/// half-done writes reach nobody, and a script reads the saved value a choice list was given +/// by its shown text. #[test] fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() { // In document order: `a` holds 1; `b`'s calculate writes `a` then fails; `c`'s calculate - // reads `a`. A failed script's half-done changes reach nobody. And `d` sets a choice list - // by its shown text: the next script reads the saved value the engine kept. + // reads `a`. A failed script keeps what it changed before the error, and the next script + // sees it. And `d` sets a choice list by its shown text: the next script reads the saved + // value the engine kept. let tpl = pdfcraft_xfa::fixtures::fields_template("
JP
").replace( r#""#, r#"1 @@ -4462,13 +6553,155 @@ fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() { let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); let out = s.take_js_output(id); assert!(out.errors.iter().any(|e| e.contains("boom")), "{out:?}"); - assert_eq!(value(&s, "a"), vec!["1".to_string()], "the failed script's write reached nothing"); - assert_eq!(value(&s, "c"), vec!["2".to_string()]); + assert_eq!(value(&s, "a"), vec!["99".to_string()], "the failed script's write before the error stands"); + assert_eq!(value(&s, "c"), vec!["100".to_string()]); assert_eq!(value(&s, "country"), vec!["CA".to_string()], "shown text mapped to the saved value"); assert_eq!(value(&s, "e"), vec!["CA".to_string()], "the next script reads the saved value"); assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]); } +/// A row of dates worked out the way leave forms do it: the calculation checks that the raw value +/// is a whole ISO date, then adds days to the date as `formattedValue` shows it (`YYYYMMDD`). +#[test] +fn xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_row_template())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("row.pdf", None, bytes, None).expect("opens"); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); + s.apply(id, Edit::SetFieldValue { name: "from".into(), value: FieldValue::Text("20261020".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "days".into(), value: FieldValue::Text("3".into()) }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(value(&s, "to"), ["20261022"], "{:?}", out.errors); +} + +#[test] +fn filling_growable_xfa_text_reflows_and_undo_restores_the_layout() { + let text: String = (0..150).map(|i| format!("Row{i:03} {}sample\n", "sample ".repeat(8))).collect(); + assert_eq!(text.len(), 10_500); + for scripted in [false, true] { + let template = pdfcraft_xfa::fixtures::growable_text_template(scripted); + let mut s = Session::new(); + let id = s.open("growing.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 2); + s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.clone()) }).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 3, "scripted={scripted}"); + let data = pdfcraft_xfa::data_of(&s.get(id).unwrap().editor.as_ref().unwrap().cos).unwrap(); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].Page3[0].ACTIVIDADES[0]")), Some(text.as_str())); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 2); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value, vec!["Short"]); + s.redo(id).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 3); + } +} + +#[test] +fn xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets() { + for fixed in [false, true] { + let size = if fixed { r#"h="30pt""# } else { r#"minH="20pt""# }; + let template = pdfcraft_xfa::fixtures::growable_text_template(false).replace(r#"minH="207.017mm""#, size); + let mut s = Session::new(); + let id = s.open("geometry.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap(); + let field = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().clone(); + let before = field(&s); + s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text("Line\n".repeat(10)) }).unwrap(); + let after = field(&s); + assert_eq!(s.get(id).unwrap().info.pages.len(), 2); + if fixed { + assert_eq!(before.obj, after.obj, "fixed-height fields must not trigger layout"); + assert_eq!(before.widgets[0].rect, after.widgets[0].rect); + } else { + assert_ne!(before.obj, after.obj); + assert!(after.widgets[0].rect[3] - after.widgets[0].rect[1] > before.widgets[0].rect[3] - before.widgets[0].rect[1]); + assert_eq!( + s.get(id).unwrap().info.fields.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value.as_deref(), + Some("Line\n".repeat(10).trim_end()) + ); + } + s.undo(id).unwrap(); + assert_eq!(field(&s).widgets[0].rect, before.widgets[0].rect); + s.redo(id).unwrap(); + assert_eq!(field(&s).widgets[0].rect, after.widgets[0].rect); + } +} + +#[test] +fn xfa_text_that_cannot_sync_to_structured_data_is_not_replaced_by_a_relayout() { + let template = pdfcraft_xfa::fixtures::growable_text_template(false) + .replace("Short", "Short"); + let mut s = Session::new(); + let id = s.open("structured.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap(); + let text = "Keep this accepted value\n".repeat(150); + s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.clone()) }).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert_eq!(doc.form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value, vec![text]); + assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content"))); +} + +#[test] +fn xfa_structured_data_whitespace_edits_survive_without_stale_relayout() { + let template = pdfcraft_xfa::fixtures::growable_text_template(false) + .replace("Short", "Short"); + for text in ["Short\n", "\nShort", " Short ", "\tShort\t"] { + let mut s = Session::new(); + let id = s.open("structured.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap(); + let field = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().clone(); + let before = field(&s); + s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.into()) }).unwrap(); + assert_eq!(field(&s).value, vec![text], "the accepted whitespace must not be replaced from stale datasets"); + assert_eq!(field(&s).obj, before.obj, "a skipped data write must not rebuild the widgets"); + let doc = s.get(id).unwrap(); + assert!(doc.dirty); + assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.xfa_warnings); + let cos = pdfcraft_cos::Document::open(doc.bytes.clone()).unwrap(); + let xdp = pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp; + assert!(xdp.contains("Short")); + s.undo(id).unwrap(); + assert_eq!(field(&s).value, before.value); + s.redo(id).unwrap(); + assert_eq!(field(&s).value, vec![text]); + } +} + +/// A script that fails partway keeps what it did before the error, as JavaScript does: a form +/// whose check box opens the next boxes and then trips over an object the form no longer has +/// still opens them (DA Form 31's leave types). +#[test] +fn an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::partly_failing_script_template())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned(); + assert!(field(&s, "leave").is_some_and(|f| f.flags & 1 != 0), "protected until `kind` is checked"); + assert!(field(&s, "details").is_none(), "invisible: not laid out"); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "kind".into(), value: FieldValue::Check(true) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.errors.iter().any(|e| e.contains("kind") && e.contains("change")), "the error is still reported: {out:?}"); + assert!(field(&s, "leave").is_some_and(|f| f.flags & 1 == 0), "opened before the error"); + assert!(field(&s, "details").is_some(), "shown before the error"); + s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Check(true) }).expect("can be checked now"); +} + +/// A scripted form end to end: initialize and calculate on open, recalculation after a change, +/// a failing validation's message, buttons that add and remove rows and show messages, a check +/// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all. +#[test] +fn repeated_xfa_print_buttons_run_their_click_script() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::repeated_print_button_template())); + let mut s = Session::new(); + let id = s.open("print.pdf", None, bytes, None).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 3); + for name in ["PrintButton1", "PrintButton1_2", "PrintButton1_3"] { + let out = s.run_javascript(id, "throw new Error('AcroForm fallback ran')", Some(name)).unwrap(); + assert_eq!(out.error, None, "{name}: {out:?}"); + assert_eq!(out.requests, vec![pdfcraft_js::Request::Print], "{name}: {out:?}"); + } +} + +/// An XFA form's scripts initialize, calculate, validate, show subforms and add and remove rows, +/// with undo and save/reopen. #[test] fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); @@ -4493,7 +6726,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { // A failing validate script shows its message; the value stays, as in Acrobat. s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); let out = s.take_js_output(id); - assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}"); + assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}"); assert_eq!(value(&s, "total"), vec!["2500".to_string()]); // A button adds a row: new fields, same page, one undo step. let before = names(&s).len(); @@ -4508,7 +6741,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id)); // Message boxes reach the viewer. let o = s.run_javascript(id, "", Some("hello")).unwrap(); - assert_eq!(o.alerts, vec!["Hello 500".to_string()]); + assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]); // The check box shows the hidden subform; undo hides it again. s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap(); assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id)); @@ -4523,7 +6756,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { // FormCalc scripts run too. let o = s.run_javascript(id, "", Some("legacy")).unwrap(); assert_eq!(o.error, None); - assert_eq!(o.alerts, vec!["FormCalc".to_string()]); + assert_eq!(o.alerts, vec![crate::js::Message::plain("FormCalc")]); // Saved and reopened: the visible subform, the values and the rows are what they were. let saved = s.save_bytes(id).unwrap(); let id2 = s.open("again.pdf", None, saved, None).unwrap(); @@ -4744,6 +6977,7 @@ fn an_xfa_form_converts_to_a_static_form_that_keeps_its_fields() { assert_eq!(value(&s, id, "total"), vec!["15".to_string()]); } +/// A dynamic XFA form scripted in FormCalc runs as the JavaScript one does. #[test] fn dynamic_xfa_forms_run_formcalc_scripts() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template())); @@ -4762,7 +6996,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() { assert_eq!(value(&s, "words"), vec!["Thirty-Five".to_string()]); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); let out = s.take_js_output(id); - assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}"); + assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}"); assert_eq!(value(&s, "qty"), vec!["500".to_string()]); // Buttons: a row is added, summed and removed; the message box reaches the viewer. let before = names(&s).len(); @@ -4773,7 +7007,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() { s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap(); assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id)); let o = s.run_javascript(id, "", Some("hello")).unwrap(); - assert_eq!(o.alerts, vec!["Hello 500".to_string()]); + assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]); s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap(); assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id)); s.run_javascript(id, "", Some("removeRow")).unwrap(); @@ -4908,6 +7142,8 @@ fn datasets_streams(bytes: &Arc>) -> usize { .count() } +/// Hostile scripts run on open are merged, capped, kept from printing, saving and opening links, +/// and noted. #[test] fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() { // On open: a calculate setting its own value 50 000 times, an initialize script showing a @@ -4939,6 +7175,36 @@ fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() { assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console); } +/// An XFA button's message boxes reach the viewer with their titles, icons and buttons, from +/// JavaScript (`xfa.host.messageBox`, `app.alert` in its own order) and FormCalc alike, every +/// one of them and in order. +#[test] +fn xfa_message_boxes_keep_their_title_icon_and_buttons() { + use crate::js::{Message, MessageButtons, MessageIcon}; + let tpl = pdfcraft_xfa::fixtures::scripted_template() + .replace( + r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, + r#"xfa.host.messageBox("Clear the rows?", "Order form", 2, 2); app.alert("Saved", 3, 0, "Order form");"#, + ) + .replace(r#"$host.messageBox("FormCalc")"#, r#"$host.messageBox("Too many", "Limits", 1, 1)"#); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("boxes.pdf", None, bytes, None).expect("opens"); + let o = s.run_javascript(id, "", Some("hello")).unwrap(); + assert_eq!(o.error, None); + let titled = |text: &str, title: &str, icon, buttons| Message { text: text.into(), title: Some(title.into()), icon, buttons }; + assert_eq!( + o.alerts, + vec![ + titled("Clear the rows?", "Order form", MessageIcon::Question, MessageButtons::YesNo), + titled("Saved", "Order form", MessageIcon::Status, MessageButtons::Ok), + ] + ); + let o = s.run_javascript(id, "", Some("legacy")).unwrap(); + assert_eq!(o.error, None); + assert_eq!(o.alerts, vec![titled("Too many", "Limits", MessageIcon::Warning, MessageButtons::OkCancel)]); +} + #[test] fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() { // Nested loops in one frame stop at the loop limit; loops in nested calls (that limit is per @@ -5036,6 +7302,149 @@ fn comments_without_appearances_are_drawn_but_not_saved() { } } +/// ISO 32000-2 Table 178: with `/LL`, `/L` gives the ends of the leader lines, and the line itself +/// is drawn `/LL` from it (above, for a positive `/LL` on a line drawn left to right). The page +/// view shows the line there, with its leaders down to `/L`, and nothing along `/L` itself. +#[test] +fn a_line_without_an_appearance_is_drawn_at_its_leader_length() { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + b"<< /Type /Annot /Subtype /Line /Rect [0 0 200 300] /L [40 100 160 100] /LL 30 /C [1 0 0] /BS << /W 4 >> >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(o); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("line.pdf", None, Arc::new(bytes), None).expect("opens"); + let p = shown(&s, id, 0); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + // y down: /L is row 200, the line itself row 170. + assert_eq!(px(100, 170), vec![255, 0, 0, 255], "the line, 30 above /L"); + assert_eq!(px(40, 185), vec![255, 0, 0, 255], "the leader line at the start"); + assert_eq!(px(160, 185), vec![255, 0, 0, 255], "the leader line at the end"); + assert_eq!(px(100, 200), vec![255, 255, 255, 255], "nothing along /L"); +} + +/// A stamp's `/Rect` and the colour family expected inside it. +type StampRect = ([f64; 4], char); + +/// Rubber stamps without an appearance (ISO 32000-2 Table 184): the fourteen standard names, one +/// without `/Name`, one custom name, and last a Draft stamp with artwork of its own (magenta). +/// Each sits in a 120 × 60 `/Rect` on a 612 × 792 page; the name and the expected colour family +/// (`'r'`, `'g'`, `'b'`, or `'n'` for the neutral placeholder) of each, in page order. +fn stamps_without_appearances() -> (Arc>, Vec) { + let stamps: [(Option<&str>, char); 16] = [ + (Some("Approved"), 'g'), + (Some("Experimental"), 'b'), + (Some("NotApproved"), 'r'), + (Some("AsIs"), 'b'), + (Some("Expired"), 'r'), + (Some("NotForPublicRelease"), 'r'), + (Some("Confidential"), 'r'), + (Some("Final"), 'g'), + (Some("Sold"), 'g'), + (Some("Departmental"), 'b'), + (Some("ForComment"), 'b'), + (Some("TopSecret"), 'r'), + (Some("Draft"), 'b'), + (Some("ForPublicRelease"), 'b'), + (None, 'b'), + (Some("MyCustomStamp"), 'n'), + ]; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), String::new()]; + let mut expected = Vec::new(); + for (i, (name, family)) in stamps.iter().enumerate() { + let (x, y) = (30 + (i % 4) * 145, 660 - (i / 4) * 150); + let rect = [x as f64, y as f64, x as f64 + 120.0, y as f64 + 60.0]; + let name = name.map(|n| format!("/Name /{n}")).unwrap_or_default(); + objs.push(format!("<< /Type /Annot /Subtype /Stamp /Rect [{} {} {} {}] {name} /F 4 >>", rect[0], rect[1], rect[2], rect[3])); + expected.push((rect, *family)); + } + let own = objs.len() + 2; + objs.push(format!("<< /Type /Annot /Subtype /Stamp /Rect [30 60 150 120] /Name /Draft /F 4 /AP << /N {own} 0 R >> >>")); + expected.push(([30.0, 60.0, 150.0, 120.0], 'm')); + objs.push("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 23 >>\nstream\n1 0 1 rg 0 0 10 10 re f\nendstream".into()); + let annots: Vec = (4..own).map(|n| format!("{n} 0 R")).collect(); + objs[2] = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" ")); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + (Arc::new(bytes), expected) +} + +/// The colour family of an RGBA pixel that isn't white: `'r'`, `'g'` or `'b'` for the dominant +/// channel, `'n'` for a grey, `'m'` for magenta. +fn colour_family(p: &[u8]) -> Option { + let (r, g, b) = (i32::from(p[0]), i32::from(p[1]), i32::from(p[2])); + if r.min(g).min(b) > 200 { + return None; + } + Some(if (r - g).abs() < 24 && (g - b).abs() < 24 { + 'n' + } else if r > 200 && b > 200 && g < 80 { + 'm' + } else if r > g && r > b { + 'r' + } else if g > r && g > b { + 'g' + } else { + 'b' + }) +} + +/// Rubber stamps without an appearance are drawn from their `/Name` (ISO 32000-2 Table 184), in +/// the colour of the stamp they name; an unknown name gets a grey placeholder; a stamp's own +/// artwork is drawn as it is. Display only: Save writes the file without new appearances. +#[test] +fn stamps_without_appearances_draw_the_stamp_they_name() { + let (original, expected) = stamps_without_appearances(); + let mut s = Session::new(); + let id = s.open("stamps.pdf", None, original.clone(), None).expect("opens"); + let p = shown(&s, id, 0); + for (i, (rect, family)) in expected.iter().enumerate() { + let mut counts = std::collections::HashMap::new(); + for y in (792.0 - rect[3]) as u32..(792.0 - rect[1]) as u32 { + for x in rect[0] as u32..rect[2] as u32 { + let px = &p.rgba[((y * p.width + x) * 4) as usize..][..4]; + if let Some(f) = colour_family(px) { + *counts.entry(f).or_insert(0) += 1; + } + } + } + let total: u32 = counts.values().sum(); + let main = counts.iter().max_by_key(|(_, n)| **n).map(|(f, _)| *f); + assert!(total > 300, "stamp {i} draws ink inside its /Rect: {counts:?}"); + assert_eq!(main, Some(*family), "stamp {i} is drawn in its colour: {counts:?}"); + } + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + for n in 4..20 { + assert!(saved.get(pdfcraft_cos::ObjRef::new(n, 0)).as_dict().is_some_and(|d| !d.contains(b"AP")), "{n} is saved without /AP"); + } +} + /// A page with a FreeText comment that has no `/AP` stream: the smallest case behind #2371. fn free_text_without_appearance() -> Arc> { let objs: Vec<&[u8]> = vec![ @@ -5521,6 +7930,8 @@ fn content_edit_keeps_pages_annotations_fields_and_links() { size: 12.0, color: [0.0, 0.0, 0.0], align: TextAlign::Left, + aligns: Vec::new(), + char_spacing: 0.0, }; session.apply(id, Edit::AddText { page: 1, text }).unwrap(); let after = session.get(id).unwrap(); @@ -5560,6 +7971,8 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() { size: 12.0, color: [0.0, 0.0, 0.0], align: TextAlign::Left, + aligns: Vec::new(), + char_spacing: 0.0, }, }, Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Added)], offset: [1.0, 0.0] }, @@ -5828,11 +8241,14 @@ fn marking_a_figure_decorative_keeps_other_page_streams() { let page = &pdfcraft_model::pages(&doc)[0]; let contents = page.dict.get(b"Contents").and_then(|c| c.as_array()).unwrap(); assert_eq!(contents.len(), 3, "the other streams stay in the page's content list"); + let figure_ref = contents[0].as_ref().unwrap(); + assert_eq!(contents[1].as_ref(), Some(pdfcraft_cos::ObjRef::new(5, 0))); + assert_eq!(contents[2].as_ref(), Some(pdfcraft_cos::ObjRef::new(6, 0))); let stream = |doc: &pdfcraft_cos::Document, n: u32| match &*doc.get(pdfcraft_cos::ObjRef::new(n, 0)) { pdfcraft_cos::Object::Stream(s) => s.clone(), other => panic!("object {n} is {other:?}"), }; - let figure = stream(&doc, 4); + let figure = stream(&doc, figure_ref.num); assert_eq!(figure.dict.get(b"VendorKey").and_then(pdfcraft_cos::Object::as_int), Some(7)); let drawn = String::from_utf8(figure.decoded().unwrap()).unwrap(); assert!(drawn.contains("/Artifact BMC") && !drawn.contains("/MCID 0"), "{drawn}"); @@ -5855,7 +8271,7 @@ fn marking_a_figure_decorative_keeps_other_page_streams() { .collect(); assert!(!text.contains("HeaderSample")); assert_eq!(pdfcraft_edit::scan_markers(&doc).added[0].content, before.added[0].content); - assert_eq!(stream(&doc, 4).dict.get(b"VendorKey").and_then(pdfcraft_cos::Object::as_int), Some(7)); + assert_eq!(stream(&doc, figure_ref.num).dict.get(b"VendorKey").and_then(pdfcraft_cos::Object::as_int), Some(7)); } /// Print as image (Acrobat's Advanced Print Setup): the print-ready PDF carries each page as a @@ -5953,7 +8369,7 @@ fn exporting_a_jpeg_logo_to_word_keeps_the_image() { assert!(jpeg.starts_with(&[0xFF, 0xD8])); let docx = d.export_office(compare::OfficeFormat::Docx); assert!(docx.windows(21).any(|w| w == b"word/media/image1.jpg"), "overlap {overlaps}"); - assert!(docx.windows(jpeg.len()).any(|w| w == jpeg.as_slice()), "the JPEG is stored unchanged (overlap {overlaps})"); + assert!(docx.windows(jpeg.len()).any(|w| w == &jpeg[..]), "the JPEG is stored unchanged (overlap {overlaps})"); } } @@ -6079,6 +8495,18 @@ fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() { assert!(Arc::ptr_eq(&redone, &blocks(&session))); } +/// #4199, #3797: the spaces typed into a paragraph are all there after saving and reopening. +#[test] +fn edited_text_keeps_every_space_typed_through_save_and_reopen() { + let (mut s, id) = session_with(1); + let typed = " A b c"; + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: typed.into(), style: pdfcraft_edit::BlockStyle::default() }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let reopened = s.open("saved.pdf", None, saved, None).unwrap(); + let blocks = s.get(reopened).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap(); + assert_eq!(blocks[0].editor_text, typed); +} + /// A new document, or one restored from recovery, has never been written where it belongs: undo /// back to the bytes it was opened from must not make it clean, or closing it would discard the /// whole document without asking. Saving makes it clean; Revert of a saved one too. @@ -6106,3 +8534,878 @@ fn undo_never_makes_an_unsaved_document_clean() { s.undo(new).unwrap(); assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); } + +#[test] +fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { + let (mut s, id) = session_with(1); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let (bytes_90, gen_90) = { + let d = s.get(id).unwrap(); + (d.bytes.clone(), d.generation) + }; + + // 1. A panic during undo's refresh catches the unwind and rolls back cos, undo/redo, info, and working bytes. + panic_on_refresh(1); + let err = s.undo(id).unwrap_err(); + assert_eq!(err, EditError::Reopen("injected refresh panic".into())); + { + let d = s.get(id).unwrap(); + let ed = d.editor.as_ref().unwrap(); + assert_eq!(d.info.pages[0].rotation, 90); + assert_eq!(d.bytes, bytes_90); + assert_eq!(d.generation, gen_90); + assert!(d.dirty); + assert!(ed.cos.is_modified()); + assert_eq!(ed.undo.len(), 1); + assert!(ed.redo.is_empty()); + } + let _ = shown(&s, id, 0); + + // 2. If the rollback refresh also panics, `show_released` restores the renderer so the page still draws. + panic_on_refresh(2); + assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert_eq!(s.get(id).unwrap().editor.as_ref().unwrap().undo.len(), 1); + let _ = shown(&s, id, 0); + + // Undo now succeeds cleanly. + assert_eq!(s.undo(id).unwrap().as_str(), "Rotate page"); + let (bytes_0, gen_0) = { + let d = s.get(id).unwrap(); + assert_eq!(d.info.pages[0].rotation, 0); + assert!(!d.dirty); + (d.bytes.clone(), d.generation) + }; + + // 3. A panic during redo's refresh catches the unwind and rolls back to the undone state. + panic_on_refresh(1); + let err = s.redo(id).unwrap_err(); + assert_eq!(err, EditError::Reopen("injected refresh panic".into())); + { + let d = s.get(id).unwrap(); + let ed = d.editor.as_ref().unwrap(); + assert_eq!(d.info.pages[0].rotation, 0); + assert_eq!(d.bytes, bytes_0); + assert_eq!(d.generation, gen_0); + assert!(!d.dirty); + assert!(!ed.cos.is_modified()); + assert!(ed.undo.is_empty()); + assert_eq!(ed.redo.len(), 1); + } + let _ = shown(&s, id, 0); + + // 4. If redo's rollback refresh also panics, `show_released` restores the renderer. + panic_on_refresh(2); + assert_eq!(s.redo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert_eq!(s.get(id).unwrap().editor.as_ref().unwrap().redo.len(), 1); + let _ = shown(&s, id, 0); + + // Redo now succeeds cleanly. + assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page"); + assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); + rolls_back_metadata_scope(&mut s, id); + rolls_back_protection_keys(&mut s, id); +} + +/// Undo/redo of a metadata edit rolled back after a refresh panic. +fn rolls_back_metadata_scope(s: &mut Session, id: DocId) { + // 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`. + s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap(); + let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false }; + s.doc_mut(id).unwrap().edit_notices.push(EditNotice::Undrawn(notice.clone())); + panic_on_refresh(1); + assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); + assert_eq!(s.get(id).unwrap().edit_notices, [EditNotice::Undrawn(notice)]); + assert_eq!(s.undo(id).unwrap().as_str(), "Change Title"); + assert_eq!(s.get(id).unwrap().info.title, None); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + + panic_on_refresh(1); + assert_eq!(s.redo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert_eq!(s.get(id).unwrap().info.title, None); + assert_eq!(s.redo(id).unwrap().as_str(), "Change Title"); + assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); +} + +/// Undo/redo of a protection edit rolled back after a refresh panic. +fn rolls_back_protection_keys(s: &mut Session, id: DocId) { + // 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting. + let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() }; + s.apply(id, Edit::Protect(protect)).unwrap(); + assert!(s.get(id).unwrap().info.encrypted); + panic_on_refresh(1); + assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert!(s.get(id).unwrap().info.encrypted); + assert_eq!(s.get(id).unwrap().password.as_deref(), Some("u")); + assert_eq!(s.undo(id).unwrap().as_str(), "Protect with password"); + assert!(!s.get(id).unwrap().info.encrypted); + assert_eq!(s.get(id).unwrap().password, None); + + panic_on_refresh(1); + assert_eq!(s.redo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert!(!s.get(id).unwrap().info.encrypted); + assert_eq!(s.get(id).unwrap().password, None); + assert_eq!(s.redo(id).unwrap().as_str(), "Protect with password"); + assert!(s.get(id).unwrap().info.encrypted); + assert_eq!(s.get(id).unwrap().password.as_deref(), Some("u")); +} + +#[test] +fn a_bookmark_added_at_a_point_goes_there_through_undo_and_save() { + use pdfcraft_render::DestView; + let (mut s, id) = session_with(3); + let view = |s: &Session, i: usize| s.get(id).unwrap().info.outline.get(i).map(|o| (o.page, o.view)); + let add = |s: &mut Session, title: &str, page: usize, left: Option, top: Option| { + s.apply(id, Edit::AddBookmark { parent: vec![], index: usize::MAX, title: title.into(), page, left, top }) + }; + add(&mut s, "Plain", 0, None, None).unwrap(); + add(&mut s, "Section", 1, None, Some(500.5)).unwrap(); + add(&mut s, "Table", 2, Some(72.0), Some(300.0)).unwrap(); + // The page top (no position) is what it always was; the others carry their place. + assert_eq!(view(&s, 0), Some((Some(0), DestView::Xyz { left: None, top: None, zoom: None }))); + assert_eq!(view(&s, 1), Some((Some(1), DestView::Xyz { left: None, top: Some(500.5), zoom: None }))); + assert_eq!(view(&s, 2), Some((Some(2), DestView::Xyz { left: Some(72.0), top: Some(300.0), zoom: None }))); + + // A position that is not a number fails the edit, which then changes nothing. + for (left, top) in [(Some(f64::NAN), None), (None, Some(f64::INFINITY))] { + assert!(matches!(add(&mut s, "Bad", 0, left, top), Err(EditError::Bookmark(_))), "{left:?} {top:?}"); + } + assert_eq!(s.get(id).unwrap().info.outline.len(), 3); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add bookmark")); + + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().info.outline.len(), 2); + s.redo(id).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut again = Session::new(); + let id2 = again.open("again.pdf", None, saved, None).unwrap(); + let outline = &again.get(id2).unwrap().info.outline; + assert_eq!(outline[2].view, DestView::Xyz { left: Some(72.0), top: Some(300.0), zoom: None }); + assert_eq!(outline[1].view, DestView::Xyz { left: None, top: Some(500.5), zoom: None }); +} + +/// #3470: a filled form whose writer left the appearances to the viewer (`/NeedAppearances`): +/// field A has an empty appearance, B none. The page as displayed (and printed) shows both +/// values, and a comment without an appearance beside them still gets its own; the file itself +/// is not changed, so saving writes it as it was. +#[test] +fn fields_left_to_the_viewer_are_displayed_from_their_values() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 10 0 R] >> endobj\n\ + 10 0 obj << /Type /Annot /Subtype /Square /Rect [360 20 390 50] /C [1 0 0] /BS << /W 4 >> >> endobj\n\ + 5 0 obj << /Fields [6 0 R 7 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (a) /V (MMMMMMMM) /DA (/Helv 12 Tf 0 g) /Rect [50 120 350 140] /F 4 /P 3 0 R /AP << /N 9 0 R >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (b) /V (MMMMMMMM) /DA (/Helv 12 Tf 0 g) /Rect [50 60 350 80] /F 4 /P 3 0 R >> endobj\n\ + 8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 20] /Length 4 >> stream\nq\nQ\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let red = |s: &Session, id: DocId| { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone()); + let page = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + page.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] > 200 && p[1] < 80 && p[2] < 80).count() + }; + // Dark pixels inside a field's box (user-space rectangle, page drawn at scale 1). + let ink = |s: &Session, id: DocId, y0: u32, y1: u32| { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone()); + let page = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (55..345).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128)) + .count() + }; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let (a, b) = (ink(&s, id, 122, 138), ink(&s, id, 62, 78)); + assert!(a > 100 && b > 100, "both values are shown: a {a}, b {b}"); + assert!(red(&s, id) > 100, "the comment is drawn too"); + // The file is saved as it was: B still has no appearance, A its empty one. + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let fields = pdfcraft_forms::fields(&saved); + let widget = |name: &str| saved.get(fields.iter().find(|f| f.name == name).unwrap().widgets[0].obj); + assert!(widget("b").as_dict().is_some_and(|d| !d.contains(b"AP")), "B has no appearance"); + let n = widget("a").as_dict().and_then(|d| d.get(b"AP").and_then(|ap| saved.resolve(ap).as_dict().and_then(|ap| ap.reference(b"N")))).unwrap(); + let pdfcraft_cos::Object::Stream(n) = &*saved.get(n) else { panic!("A's appearance is a stream") }; + assert_eq!(n.decoded().unwrap().trim_ascii_end(), b"q\nQ", "A keeps its empty appearance"); + // Typing in one field redraws that field only, not the whole form, on each keystroke. + let builds = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().field_appearances.builds(); + let before = builds(&s); + s.apply(id, Edit::SetFieldValue { name: "a".into(), value: pdfcraft_forms::FieldValue::Text("Typed".into()) }).unwrap(); + assert_eq!(builds(&s), before + 1); + + // Without the flag, the empty appearance is the file's own, and only B is drawn. + let id = s.open("form.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let (a, b) = (ink(&s, id, 122, 138), ink(&s, id, 62, 78)); + assert!(a == 0 && b > 100, "a {a}, b {b}"); +} +/// Reproducible workload with 3,129 fields and 190 ordered custom calculations. +/// No external PDF assets are needed; only in-memory contributor-original objects. +fn synthetic_acroform(field_count: usize, calculation_count: usize) -> Vec { + use pdfcraft_cos::{Dict, Object, PdfString}; + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(6))).unwrap(); + let mut refs = Vec::new(); + let mut order = Vec::new(); + for i in 0..field_count { + let name = match i { + 0 => "ranks".to_string(), + 1 => "trained".to_string(), + 2 => "bonus".to_string(), + _ => format!("field{i}"), + }; + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"FT".to_vec(), Object::name(if i == 1 { "Btn" } else { "Tx" })); + d.set(b"T".to_vec(), PdfString::text(&name)); + d.set(b"V".to_vec(), if i == 1 { Object::name("Off") } else { Object::String(PdfString::text("0")) }); + d.set(b"Rect".to_vec(), Object::Array([20, 20, 100, 40].map(Object::Int).to_vec())); + d.set(b"P".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(4, 0))); + if (2..calculation_count + 2).contains(&i) { + let js = if i == 2 { + "var r = Number(getField('ranks').value); event.value = r + (r > 0 && getField('trained').isBoxChecked(0) ? 3 : 0);".to_string() + } else { + format!("event.value = Number(getField('{}').value) + 1;", if i == 3 { "bonus".to_string() } else { format!("field{}", i - 1) }) + }; + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::text(&js)); + let mut aa = Dict::new(); + aa.set(b"C".to_vec(), Object::Dict(a)); + d.set(b"AA".to_vec(), Object::Dict(aa)); + } + let r = doc.add(Object::Dict(d)); + refs.push(Object::Ref(r)); + if (2..calculation_count + 2).contains(&i) { + order.push(Object::Ref(r)); + } + } + doc.update_dict(pdfcraft_cos::ObjRef::new(4, 0), |d| d.set(b"Annots".to_vec(), Object::Array(refs.clone()))).unwrap(); + let mut af = Dict::new(); + af.set(b"Fields".to_vec(), Object::Array(refs)); + af.set(b"CO".to_vec(), Object::Array(order)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), Object::Dict(af))).unwrap(); + pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap() +} + +#[test] +#[ignore = "manual performance measurement: 3,129 fields, 190 scripts"] +fn acroform_interaction_benchmark() { + let bytes = Arc::new(synthetic_acroform(3129, 190)); + // Optional local export for comparing CLI build profiles. Never overwrite an input. + if let Some(path) = std::env::var_os("PDFCRAFT_BENCH_EXPORT") { + use std::io::Write; + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path).unwrap(); + file.write_all(&bytes).unwrap(); + } + let mut s = Session::new(); + let id = s.open("synthetic-large-form.pdf", None, bytes, None).unwrap(); + let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + for (name, v) in [ + ("ranks", FieldValue::Text("1".into())), + ("trained", FieldValue::Check(true)), + ("ranks", FieldValue::Text("2".into())), + ("trained", FieldValue::Check(false)), + ("ranks", FieldValue::Text("0".into())), + ] { + let started = std::time::Instant::now(); + s.apply(id, Edit::SetFieldValue { name: name.into(), value: v }).unwrap(); + eprintln!("BENCH {name}: {:.3} ms", started.elapsed().as_secs_f64() * 1000.0); + assert!(s.take_js_output(id).errors.is_empty()); + } + assert_eq!(value(&s, "bonus"), ["0"]); + assert_eq!(value(&s, "field191"), ["189"]); + assert_eq!(value(&s, "field3128"), ["0"]); +} + +#[test] +fn checkbox_and_rank_changes_recalculate_ordered_dependents() { + let mut s = Session::new(); + let id = s.open("skills.pdf", None, Arc::new(synthetic_acroform(8, 4)), None).unwrap(); + for (name, input, bonus) in [ + ("trained", FieldValue::Check(true), "0"), + ("ranks", FieldValue::Text("1".into()), "4"), + ("trained", FieldValue::Check(false), "1"), + ("ranks", FieldValue::Text("2".into()), "2"), + ("trained", FieldValue::Check(true), "5"), + ("ranks", FieldValue::Text("0".into()), "0"), + ] { + s.apply(id, Edit::SetFieldValue { name: name.into(), value: input }).unwrap(); + let fields = &s.get(id).unwrap().form; + let value = |name: &str| fields.iter().find(|f| f.name == name).unwrap().value.clone(); + assert_eq!(value("bonus"), [bonus]); + assert_eq!(value("field5"), [(bonus.parse::().unwrap() + 3).to_string()]); + assert_eq!(value("field7"), ["0"]); + assert!(s.take_js_output(id).errors.is_empty()); + } + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "bonus").unwrap().value, ["5"]); + let saved = s.save_bytes(id).unwrap(); + let reopened = s.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "bonus").unwrap().value, ["5"]); + let appearance = |s: &Session| { + let doc = s.get(id).unwrap(); + let widget = doc.form.iter().find(|f| f.name == "bonus").unwrap().widgets.first().unwrap().obj; + doc.editor.as_ref().unwrap().cos.get(widget).as_dict().unwrap().get(b"AP").cloned() + }; + let before = appearance(&s); + assert!(before.is_some()); + s.apply(id, Edit::SetFieldValue { name: "field7".into(), value: FieldValue::Text("9".into()) }).unwrap(); + assert_eq!(appearance(&s), before, "unchanged calculated values keep their appearance streams"); +} + +#[test] +fn dynamic_calculation_reads_and_side_effects_reach_later_fields() { + use pdfcraft_cos::{Dict, Object, PdfString}; + let mut cos = pdfcraft_cos::Document::open(Arc::new(synthetic_acroform(8, 4))).unwrap(); + let fields = pdfcraft_forms::fields(&cos); + let bonus = fields.iter().find(|f| f.name == "bonus").unwrap(); + let selector = fields.iter().find(|f| f.name == "field7").unwrap(); + cos.update_dict(selector.obj, |d| d.set(b"V".to_vec(), PdfString::text("ranks"))).unwrap(); + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::text( + "var key = getField('field7').value; var v = Number(getField(key).value); getField('field6').value = v * 2; getField('field6').readonly = true; event.value = v + 1;" + )); + let mut aa = Dict::new(); + aa.set(b"C".to_vec(), Object::Dict(a)); + cos.update_dict(bonus.obj, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap(); + let bytes = pdfcraft_cos::write_incremental(&cos, &Default::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("dynamic.pdf", None, Arc::new(bytes), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "ranks".into(), value: FieldValue::Text("4".into()) }).unwrap(); + let fields = &s.get(id).unwrap().form; + let field = |name: &str| fields.iter().find(|f| f.name == name).unwrap(); + assert_eq!(field("bonus").value, ["5"]); + assert_eq!(field("field5").value, ["8"], "later calculations see the ordered result"); + assert_eq!(field("field6").value, ["8"], "side-effect value applied"); + assert!(field("field6").read_only(), "side-effect property applied"); + assert_eq!(field("field7").value, ["ranks"]); + assert!(s.take_js_output(id).errors.is_empty()); +} + +#[test] +fn scripted_display_changes_survive_warm_cache_and_new_edit() { + use pdfcraft_forms::{FieldEvent, Scripts}; + let mut cos = pdfcraft_cos::Document::open(Arc::new(synthetic_acroform(2, 0))).unwrap(); + for (display, flags) in [(1, 6), (3, 36), (2, 0), (0, 4)] { + let fields = pdfcraft_forms::fields(&cos); + let target = fields.iter().find(|f| f.name == "ranks").unwrap(); + let other = fields.iter().find(|f| f.name == "trained").unwrap(); + let old_display = crate::js::field_state(&cos, other).display; + let mut runner = crate::js::JsRunner::new(&cos, "synthetic.pdf"); + // Populate the display cache before a script writes the same field. + let warm = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields); + assert_eq!(warm.value, old_display.to_string()); + let result = runner.run( + FieldEvent::Calculate, + &format!("getField('trained').display = {display}; event.value = getField('trained').display;"), + target, + "", + &fields, + ); + assert_eq!(result.value, display.to_string()); + assert_eq!(result.changes.len(), 1); + assert_eq!(result.changes.first().unwrap().display, Some(display)); + pdfcraft_forms::apply_script_changes(&mut cos, &result.changes, &mut pdfcraft_forms::NoScripts).unwrap(); + assert_eq!(cos.get(other.widgets.first().unwrap().obj).as_dict().unwrap().int(b"F").unwrap() & 38, flags); + // Within an edit the original implementation reads an immutable pre-edit COS snapshot. + let same_edit = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields); + assert_eq!(same_edit.value, old_display.to_string()); + // The next edit must rebuild its cache from the newly written annotation flags. + let mut next = crate::js::JsRunner::new(&cos, "synthetic.pdf"); + let read = next.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &pdfcraft_forms::fields(&cos)); + assert_eq!(read.value, display.to_string()); + assert!(read.changes.is_empty()); + assert!(runner.output.errors.is_empty()); + assert!(next.output.errors.is_empty()); + } +} + +/// #2992: a page coloured with Edit a PDF ▸ Background exports to Word with that page colour, +/// behind its text. +#[test] +fn exporting_to_word_keeps_the_page_background() { + let mut s = Session::new(); + let text = s.create_from_text("t", "Blue text on teal").unwrap(); + let id = s.open("t.pdf", None, text, None).unwrap(); + assert_eq!(s.get(id).unwrap().export_pages()[0].background, None, "no background yet"); + s.apply( + id, + Edit::AddBackground { + pages: vec![0], + settings: Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + let d = s.get(id).unwrap(); + let page = &d.export_pages()[0]; + assert!(page.background.is_some_and(|c| c.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6)), "{:?}", page.background); + assert!(page.blocks.iter().any(|b| b.text.contains("Blue text")), "the text is still there"); + let docx = d.export_office(compare::OfficeFormat::Docx); + assert!(docx.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "Word is told to show the page colour"); +} + +#[test] +fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { + let mut content = String::new(); + for (title, page, y) in [("Art.1. Scope", "3", 200), ("Art.2. Eligibility", "7", 180), ("Art.3. Application", "12", 160)] { + content.push_str(&format!( + "BT /F1 12 Tf 72 {y} Td ({title}) Tj ET \ + BT /F1 12 Tf 250 {y} Td (....................) Tj ET \ + BT /F1 12 Tf 500 {y} Td ({page}) Tj ET\n" + )); + } + let pdf = one_line_page("/Font << /F1 4 0 R >>", &content); + let mut session = Session::new(); + let id = session.open("contents.pdf", None, Arc::new(pdf), None).unwrap(); + let doc = session.get(id).unwrap(); + let html = String::from_utf8(doc.export_office(compare::OfficeFormat::Html)).unwrap(); + assert!(!html.contains(""), "contents entries are paragraphs: {html}"); + let entries = ["Art.1. Scope .................... 3", "Art.2. Eligibility .................... 7", "Art.3. Application .................... 12"]; + let positions: Vec<_> = entries.iter().map(|entry| html.find(&format!("

{entry}

")).expect("one paragraph per entry")).collect(); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); +} + +/// A date typed in another of an XFA date field's edit patterns is kept in its first: yymmdd and +/// yyyymd are rewritten as yyyymmdd, a date none of them reads is refused, and the data holds the date. +#[test] +fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() { + let mut s = Session::new(); + let id = s + .open("dates.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_alternatives_template())), None) + .expect("opens"); + let value = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "leave").unwrap().value.clone(); + s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("261018".into()) }).expect("yymmdd is one of its patterns"); + assert_eq!(value(&s), ["20261018"]); + s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2026118".into()) }).expect("so is yyyymd"); + assert_eq!(value(&s), ["20261108"]); + let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2618".into()) }); + assert!(refused.is_err_and(|e| e.to_string().contains("yyyymmdd")), "none of its patterns reads it"); + assert_eq!(value(&s), ["20261108"]); + // The data holds the date. + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet"); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].leave[0]")), Some("2026-11-08")); +} + +/// A date the field's own scripts refuse is not tried again in another of an XFA date field's +/// patterns: only a date its format refuses is, which happens before any script runs, so the +/// scripts run once. This validate script counts its runs in `name` and accepts on its second. +#[test] +fn a_date_its_validate_script_refuses_is_not_tried_again() { + let mut s = Session::new(); + let id = s + .open("dates.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_alternatives_template())), None) + .expect("opens"); + let script = Some("var n = Number(getField('name').value || 0) + 1; getField('name').value = String(n); event.rc = n >= 2;".to_string()); + s.apply(id, Edit::SetFieldScript { name: "leave".into(), event: "validate".into(), script }).unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + // Its format reads 18OCT26, and so does its DDMMMYY pattern. + let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("18OCT26".into()) }); + assert!(refused.is_err(), "the validate script refused it, once"); + assert!(value(&s, "name").is_empty(), "nothing the refused run changed is kept: {:?}", value(&s, "name")); +} + +/// An XFA button's click script may show the attachments panel; a text field's exit script +/// asking the same is noted on the console and ignored. +#[test] +fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("files.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + s.apply(id, Edit::XfaEvent { som: "form[0].files[0]".into(), activity: "click".into() }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [pdfcraft_js::Request::ShowPanel(pdfcraft_js::Panel::Attachments)], "{out:?}"); + s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "only a click shows a panel: {out:?}"); + assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console); +} + +/// A validate script that asks for Save As and a side panel gets neither, with a console note +/// for each; a button's script may still open Save As. +#[test] +fn a_fields_own_script_may_not_save_as_or_show_a_panel() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.apply( + id, + Edit::SetFieldScript { + name: "qty".into(), + event: "validate".into(), + script: Some("app.execMenuItem('SaveAs'); app.execMenuItem('ShowHideFileAttachment');".into()), + }, + ) + .unwrap(); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "a validate script runs unasked: {out:?}"); + assert_eq!(out.console.iter().filter(|c| c.contains("only a button may")).count(), 2, "{:?}", out.console); + // A button's script may. + let o = s.run_javascript(id, "app.execMenuItem('SaveAs');", Some("go")).unwrap(); + assert_eq!(o.requests, [js::Request::SaveAs]); +} + +/// A page whose forms go past the reading limit and whose picture goes past the image budget is +/// listed once among the truncated pages. +#[test] +fn a_page_truncated_for_forms_and_pictures_is_listed_once() { + let depth = compare::MAX_FORM_DEPTH + 1; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), + String::new(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\0\x20\x40\x7f\nendstream".into(), + "<< /Length 34 >>\nstream\nq 10 0 0 10 0 0 cm /Im Do Q /D1 Do\nendstream".into(), + ]; + // Forms D1..D{depth}, objects 6 onwards, each drawing the next. + let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", 5 + i)).collect(); + let resources = format!("<< /XObject << /Im 4 0 R {xobjects}>> >>"); + for i in 1..=depth { + let body = if i == depth { "q Q".to_string() } else { format!("/D{} Do", i + 1) }; + objs.push(format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream", + body.len() + )); + } + objs[2] = format!("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources {resources} >>"); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("both.pdf", None, Arc::new(bytes), None).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.export_pages_checked(false, 0).1, [0], "the forms alone truncate the page"); + let (exported, _) = doc.export_pages_checked(true, 1 << 20); + assert_eq!(exported[0].images.len(), 1, "the picture exports within a sufficient budget"); + let (pages, truncated) = doc.export_pages_checked(true, 0); + assert!(pages[0].images.is_empty(), "the picture is past the budget"); + assert_eq!(truncated, [0]); +} + +/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb +/// field the file leaves to the viewer. The page as displayed and printed as an image shows the +/// check box's caption symbol in its text colour, the button's grey face and the comb's cells in +/// the border colour, and saving writes the file as it was. +#[test] +fn buttons_and_combs_left_to_the_viewer_are_displayed() { + let pdf = b"%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (k) /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [20 150 60 190] /F 4 /P 3 0 R >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (o) /DA (/Helv 12 Tf 0 g) /MK << /CA (Press) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [100 150 220 190] /F 4 /P 3 0 R >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /Ff 16777216 /MaxLen 4 /T (d) /V (AB) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [20 80 220 110] /F 4 /P 3 0 R >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + .to_vec(); + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Pixels of the page (drawn at scale 1, 400 x 200) in user-space box x0..x1, y0..y1 that `keep`. + let count = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32), keep: &dyn Fn(&[u8; 4]) -> bool| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(keep)) + .count() + }; + let red = |p: &[u8; 4]| p[0] > 200 && p[1] < 80 && p[2] < 80; + let grey = |p: &[u8; 4]| (180..=230).contains(&p[0]) && p[0] == p[1] && p[1] == p[2]; + // Bluish: a 1 pt line on a pixel edge is half blue on each side. + let blue = |p: &[u8; 4]| p[2] > 200 && p[0] < 180 && p[1] < 180 && p[0] + 40 < p[2]; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + assert!(count(&shown, (24, 154, 56, 186), &red) > 60, "the check box's red cross"); + assert!(count(&shown, (104, 154, 216, 186), &grey) > 1000, "the push button's grey face"); + // The comb's three inner lines at x = 70, 120 and 170, between its cells. + for x in [70, 120, 170] { + assert!(count(&shown, (x - 1, 84, x + 1, 106), &blue) >= 20, "a cell line at {x}"); + } + assert_eq!(count(&shown, (95, 84, 97, 106), &blue), 0, "no line inside a cell"); + // Print as Image: the page as a picture, at 72 dpi so it is the size of the page here. + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + assert!(count(&printed, (24, 154, 56, 186), &red) > 60, "the check box prints"); + assert!(count(&printed, (104, 154, 216, 186), &grey) > 1000, "and the push button"); + // The file is saved as it was: no widget has an appearance. + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + for f in pdfcraft_forms::fields(&saved) { + assert!(saved.get(f.widgets[0].obj).as_dict().is_some_and(|d| !d.contains(b"AP")), "{} has no appearance", f.name); + } +} + +/// ISO 32000-2 Tables 166 and 183, drawn on the page: a square without an appearance whose +/// `/Border [0 0 3 [6 3]]` names its width before a dash array is stroked 3 pt wide and dashed +/// (it was 1 pt and solid), and a caret with `/Sy /P` shows a ¶ to its right. +#[test] +fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() { + let original = Arc::new(pdf_with_annots(&[ + b"<< /Type /Annot /Subtype /Square /Rect [20 180 120 280] /Border [0 0 3 [6 3]] /C [1 0 0] >>", + b"<< /Type /Annot /Subtype /Caret /Rect [20 20 80 80] /Sy /P /C [0 0 1] >>", + ])); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("noap.pdf", None, original, None).expect("opens"); + let p = shown(&s, id, 0); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + // y down: the bottom edge's stroke is centred 1.5 pt inside /Rect, on row 118. + let row: Vec> = (24..116).map(|x| px(x, 118)).collect(); + let red = row.iter().filter(|c| c[0] > 200 && c[1] < 80).count(); + let white = row.iter().filter(|c| c[1] > 200).count(); + assert!(red > 40 && white > 15, "3 pt and dashed: {red} red, {white} white"); + // The ¶ sits in the right part of the caret's /Rect, where the caret no longer reaches. + let blue = (250..270).flat_map(|y| (68..80).map(move |x| (x, y))).filter(|&(x, y)| { + let c = px(x, y); + c[2] > 150 && c[0] < 100 + }); + assert!(blue.count() > 20, "the pilcrow is drawn beside the caret"); +} + +/// Import comments with a PDF chosen copies that PDF's comments in one undoable step, and refuses +/// a PDF that has none. +#[test] +fn importing_a_pdf_copies_its_comments() { + let mut s = Session::new(); + let id = s.open("doc.pdf", None, Arc::new(pdf_with_annots(&[])), None).unwrap(); + let review = pdf_with_annots(&[ + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>", + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>", + ]); + s.apply(id, Edit::ImportData { name: "review.pdf".into(), bytes: Arc::new(review.clone()) }).unwrap(); + let contents = |s: &Session| s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect::>(); + assert_eq!(contents(&s), [Some("from the review".to_string())], "the comment, not the link"); + assert_eq!(s.undo(id).unwrap().to_string(), "Import review.pdf"); + assert!(s.get(id).unwrap().info.annotations.is_empty()); + + // Bytes before the header, as the reader allows them. + let prefixed = [b"\r\n \n".as_slice(), &review].concat(); + s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap(); + assert_eq!(contents(&s), [Some("from the review".to_string())]); + s.undo(id).unwrap(); + + let links = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>"]); + let err = s.apply(id, Edit::ImportData { name: "links.pdf".into(), bytes: Arc::new(links.clone()) }).unwrap_err(); + assert_eq!(err.to_string(), pdfcraft_xfdf::DataError::NothingImported.to_string()); + // A PDF that needs a password is refused with that reason, with or without bytes before its header. + let mut locked = pdfcraft_cos::Document::open(Arc::new(review)).unwrap(); + locked + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "u", + owner_password: "o", + permissions: 0, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + let locked = pdfcraft_cos::write_full(&locked, &Default::default()).unwrap(); + for bytes in [locked.clone(), [b"\n".as_slice(), &locked].concat()] { + let err = s.apply(id, Edit::ImportData { name: "locked.pdf".into(), bytes: Arc::new(bytes) }).unwrap_err(); + assert_eq!(err.to_string(), EditError::Source("locked.pdf: it is password-protected".into()).to_string()); + } + + // An XFDF file that mentions the PDF header is still read as XFDF. + let xfdf = r#"from the data file"#; + s.apply(id, Edit::ImportData { name: "notes.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap(); + assert_eq!(contents(&s), [Some("from the data file".to_string())]); +} + +/// An XFA form imports the comments of a PDF with binary bytes before its header, instead of +/// refusing it as a data file in an unknown encoding. +#[test] +fn an_xfa_form_imports_comments_from_a_pdf_with_binary_bytes_before_its_header() { + let data = "
JP"; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fields.pdf", None, bytes, None).expect("opens"); + let review = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>"]); + let prefixed = [b"\xC0\xC1\xF5\n".as_slice(), &review].concat(); + s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap(); + let contents: Vec<_> = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect(); + assert!(contents.contains(&Some("from the review".to_string())), "{contents:?}"); +} + +/// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …). +fn pdf_with_annots(annots: &[&[u8]]) -> Vec { + let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{list}] >>"); + let mut objs: Vec<&[u8]> = + vec![b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", page.as_bytes()]; + objs.extend_from_slice(annots); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(o); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} + +#[test] +fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() { + let white = "1 g"; + let rectangle = "0 0 100 100 re f"; + let artwork = "q 0 g 0.25 0 0 0.25 20 20 cm"; + let raw = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents [4 0 R 5 0 R 6 0 R 5 0 R 7 0 R] >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{white}\nendstream endobj\n\ + 5 0 obj << /Length {} >> stream\n{rectangle}\nendstream endobj\n\ + 6 0 obj << /Length {} >> stream\n{artwork}\nendstream endobj\n\ + 7 0 obj << /Length 1 >> stream\nQ\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + white.len(), + rectangle.len(), + artwork.len() + ); + // Start with a conventional xref, so the first save can exercise incremental output. + let source = pdfcraft_cos::Document::open(Arc::new(raw.into_bytes())).unwrap(); + let bytes = pdfcraft_cos::write_full(&source, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("shared-backdrop.pdf", None, Arc::new(bytes), None).unwrap(); + let black = |s: &Session, id| { + let doc = s.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let out = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + assert_eq!((out.width, out.height), (100, 100)); + // PDF point (30, 30), inside the later black square, in top-down raster coordinates. + let i = ((70 * out.width + 30) * 4) as usize; + let rgba = &out.rgba[i..i + 4]; + assert!(rgba[0] < 20 && rgba[1] < 20 && rgba[2] < 20, "later artwork survives: {rgba:?}"); + }; + black(&s, id); + s.apply( + id, + Edit::AddBackground { pages: vec![0], settings: Background { color: [1.0, 0.0, 0.0], ..Background::default() }, replace: false, file: None }, + ) + .unwrap(); + black(&s, id); + s.undo(id).unwrap(); + black(&s, id); + s.redo(id).unwrap(); + black(&s, id); + let saved = s.save_bytes(id).unwrap(); + let reopened = s.open("incremental.pdf", None, saved.clone(), None).unwrap(); + black(&s, reopened); + let doc = pdfcraft_cos::Document::open(saved).unwrap(); + let full = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let rewritten = s.open("full.pdf", None, Arc::new(full), None).unwrap(); + black(&s, rewritten); + s.apply(rewritten, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + black(&s, rewritten); +} diff --git a/crates/engine/src/text_fonts.rs b/crates/engine/src/text_fonts.rs index f4945f7563..859bc1a6f8 100644 --- a/crates/engine/src/text_fonts.rs +++ b/crates/engine/src/text_fonts.rs @@ -74,5 +74,5 @@ pub fn discover(roots: &[PathBuf]) -> Vec { files } fn font_extension(path: &Path) -> bool { - path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")) + path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")) } diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index d533faf7e0..f2e60dac07 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -1,4 +1,5 @@ -//! The page beneath an inline text editor, with the source glyphs removed on a COS clone. +//! The page beneath an inline text editor, with the source glyphs removed on a COS clone; and, +//! the same way, the page beneath a dragged added item or the marks an Update replaces. use std::sync::Arc; @@ -18,18 +19,75 @@ impl TextEditPreview { } pub fn render_background(&self, scale: f32) -> Result { + render_one(&self.bytes, &self.config, self.page, scale) + } +} + +/// A document's pages without its marks of one kind ([`Document::without_marks`]). +pub struct UnmarkedPages { + bytes: Arc>, + config: RenderConfig, + /// Parsed on the first [`Self::render_page`], once (a copy that can't be parsed isn't tried + /// again), and kept: parsing costs about as much as reading the document. + parsed: Option, + /// How many times it was parsed. + #[cfg(test)] + builds: u64, +} + +impl UnmarkedPages { + pub fn renderer(&self) -> RenderPool { + RenderPool::new(self.bytes.clone(), 1, self.config.clone()) + } + + /// `page` at `scale`, on this thread. + pub fn render_page(&mut self, page: usize, scale: f32) -> Result { if !scale.is_finite() || scale <= 0.0 { return Err("preview scale must be finite and positive".into()); } - guard(|| { - let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone()); - let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() }); - if let Some(e) = &out.error { - return Err(e.clone()); + if self.parsed.is_none() { + self.parsed = Some(PageRenderer::new(self.bytes.clone(), self.config.clone())); + #[cfg(test)] + { + self.builds += 1; } - Ok(out) - })? + } + let Some(renderer) = self.parsed.as_mut() else { return Err("the document could not be parsed".into()) }; + let out = guard(|| renderer.render(RenderRequest { page, scale, ..Default::default() })); + let out = match out { + Ok(out) => out, + Err(e) => { + // A panic may have left it half-way: parse afresh next time. + self.parsed = None; + return Err(e); + } + }; + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) } + + /// How many times [`Self::render_page`] has parsed the document. + #[cfg(test)] + pub(crate) fn parser_builds(&self) -> u64 { + self.builds + } +} + +/// `page` of `bytes` at `scale`, on this thread. +fn render_one(bytes: &Arc>, config: &RenderConfig, page: usize, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let mut renderer = PageRenderer::new(bytes.clone(), config.clone()); + let out = renderer.render(RenderRequest { page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? } impl Document { @@ -89,4 +147,44 @@ impl Document { Ok(TextEditPreview { page, bytes, config: self.config.clone() }) })? } + + /// The document without its marks of `kind`, on every page: what Update replaces them on, so + /// its preview doesn't draw the new marks over the old ones (or tint the old background). + /// Made once for all the pages a dialog steps through, as cloning and writing the document + /// costs about a save. `None` when the document has none, and the thumbnails already are + /// those pages. Read-only, like [`Self::text_edit_preview`]. + pub fn without_marks(&self, kind: crate::MarkKind) -> Result, String> { + if !self.marks().contains(&kind) { + return Ok(None); + } + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + let n = pdfcraft_model::pages(&cos).len(); + if pdfcraft_edit::remove_marks(&mut cos, &(0..n).collect::>(), kind).map_err(|e| e.to_string())? == 0 { + return Ok(None); + } + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(Some(UnmarkedPages { + bytes, + config: self.config.clone(), + parsed: None, + #[cfg(test)] + builds: 0, + })) + })? + } + + /// An added item alone, on a transparent page: what follows the pointer while it is dragged, + /// so the page under its first place doesn't come along. Read-only, like + /// [`Self::added_content_preview`]. + pub fn added_content_alone(&self, page: usize, index: usize) -> Result { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + pdfcraft_edit::isolate_content(&mut cos, page, index).map_err(|e| e.to_string())?; + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(TextEditPreview { page, bytes, config: RenderConfig { transparent: true, ..self.config.clone() } }) + })? + } } diff --git a/crates/engine/src/text_style_bookmarks.rs b/crates/engine/src/text_style_bookmarks.rs new file mode 100644 index 0000000000..5969188d67 --- /dev/null +++ b/crates/engine/src/text_style_bookmarks.rs @@ -0,0 +1,229 @@ +//! Bookmarks from text styles (#1693): a document's headings are often set apart only by how +//! they look (chapter titles in one font at one size, sections in another), not tagged as +//! headings, so New Bookmarks from Structure finds nothing. [`styles`] lists the styles the +//! text is set in; [`headings`] finds the lines in the styles chosen for each bookmark level. +//! +//! A style is a font (without its subset tag: `ABCDEF+Garamond` and `GHIJKL+Garamond` are +//! subsets of one font), the size the text is shown at, and whether it is bold and italic. +//! Lines are taken in reading order down each page: top to bottom, then left to right. + +use pdfcraft_cos::Document; +use pdfcraft_organize::OutlineEntry; + +/// How a line of text looks. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyle { + /// The font's name, without a subset tag. + pub font: String, + /// The size the text is shown at, in points (rounded to a tenth). + pub size: f64, + pub bold: bool, + pub italic: bool, +} + +impl TextStyle { + /// Whether `other` is this style. Sizes within a quarter point match, as the same heading + /// size is often written with rounding differences (12 and 11.96). + pub(crate) fn matches(&self, other: &TextStyle) -> bool { + self.font == other.font && self.bold == other.bold && self.italic == other.italic && (self.size - other.size).abs() <= SIZE_TOLERANCE + } +} + +/// How far apart two sizes may be and still be one style. +const SIZE_TOLERANCE: f64 = 0.25; + +/// A style and how much text is set in it. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyleUse { + pub style: TextStyle, + /// Lines in this style. + pub lines: usize, + /// Pages they are on. + pub pages: usize, + /// The first line in it, cut to [`SAMPLE_CHARS`] characters. + pub sample: String, +} + +/// The most pages read: a longer document is listed and searched by its first pages. +pub const MAX_PAGES: usize = 5000; +/// The most bookmarks made at once. +pub const MAX_BOOKMARKS: usize = 10_000; +/// The longest bookmark title kept, in characters (a heading run on into a paragraph). +const MAX_TITLE_CHARS: usize = 300; +/// The longest sample shown for a style. +const SAMPLE_CHARS: usize = 60; +/// The most styles listed, the largest first. +const MAX_STYLES: usize = 200; + +/// One line of text in reading order. +struct Line { + page: usize, + text: String, + style: TextStyle, + /// Top, bottom, left and right of its box in user space. + top: f64, + bottom: f64, + left: f64, + right: f64, +} + +/// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2). +fn without_subset_tag(name: &str) -> &str { + match name.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) && !rest.is_empty() => rest, + _ => name, + } +} + +/// The text lines of `doc`'s first [`MAX_PAGES`] pages, each page's in reading order. Text that +/// is only whitespace, or whose size can't be read, is left out. +fn lines(doc: &Document) -> Vec { + let pages = pdfcraft_organize::page_count(doc).unwrap_or(0).min(MAX_PAGES); + let mut out = Vec::new(); + for page in 0..pages { + let Ok(found) = pdfcraft_edit::text_lines(doc, page) else { continue }; + let mut on_page: Vec = found + .into_iter() + .filter_map(|l| { + let text = l.text.split_whitespace().collect::>().join(" "); + // `size` is the size the line is shown at in user space (the `Tf` size times the + // text rendering matrix), so `1 Tf` with a 14× text matrix is 14. + let size = l.size; + let [x0, y0, x1, y1] = l.rect; + let ok = !text.is_empty() && size.is_finite() && size > 0.0 && [x0, y0, x1, y1].iter().all(|v| v.is_finite()); + ok.then(|| Line { + page, + text, + style: TextStyle { + font: without_subset_tag(&l.base_font).to_string(), + size: (size * 10.0).round() / 10.0, + bold: l.bold, + italic: l.italic, + }, + top: y0.max(y1), + bottom: y0.min(y1), + left: x0.min(x1), + right: x0.max(x1), + }) + }) + .collect(); + // Down the page; along each row from the left. Boxes of one row differ by a fraction + // of a point, so rows are compared at a tenth of a point. + on_page.sort_by(|a, b| (b.top * 10.0).round().total_cmp(&(a.top * 10.0).round()).then(a.left.total_cmp(&b.left))); + out.extend(on_page); + } + out +} + +/// The styles `doc`'s text is set in, the largest size first (then the most used): headings +/// are usually larger than the text around them. +pub fn styles(doc: &Document) -> Vec { + let mut uses: Vec<(TextStyleUse, Option)> = Vec::new(); + // The styles by font, weight, slant and size in quarter points, so a line is matched against + // the few styles near its size rather than every style found (a page can hold thousands). + // A size matches within a quarter point: the bucket's neighbours are looked at too. + let mut index: std::collections::HashMap<(String, bool, bool, i64), Vec> = std::collections::HashMap::new(); + let bucket = |size: f64| (size / SIZE_TOLERANCE).floor() as i64; + for line in lines(doc) { + let key = |b: i64| (line.style.font.clone(), line.style.bold, line.style.italic, b); + let b = bucket(line.style.size); + let found = [b.saturating_sub(1), b, b.saturating_add(1)] + .into_iter() + .filter_map(|b| index.get(&key(b))) + .flatten() + .copied() + .find(|&i| uses.get(i).is_some_and(|(u, _)| u.style.matches(&line.style))); + match found.and_then(|i| uses.get_mut(i)) { + Some((u, last_page)) => { + u.lines = u.lines.saturating_add(1); + if *last_page != Some(line.page) { + u.pages = u.pages.saturating_add(1); + *last_page = Some(line.page); + } + } + None => { + let sample = line.text.chars().take(SAMPLE_CHARS).collect(); + index.entry(key(b)).or_default().push(uses.len()); + uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page))); + } + } + } + let mut uses: Vec = uses.into_iter().map(|(u, _)| u).collect(); + uses.sort_by(|a, b| b.style.size.total_cmp(&a.style.size).then(b.lines.cmp(&a.lines))); + uses.truncate(MAX_STYLES); + uses +} + +/// The bookmarks for the lines in `levels`' styles: a line in `levels[0]`'s style is a level-1 +/// bookmark, `levels[1]`'s a level-2 one under the level-1 bookmark before it, and so on. A +/// heading set on several lines (one after another in the same style, close below each other) +/// is one bookmark. Each goes to the top of its heading. +pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec { + let mut entries: Vec = Vec::new(); + // The heading the previous line belonged to: its level, and the top, bottom, left and right + // of its last line. + let mut open: Option<(usize, f64, f64, f64, f64)> = None; + for line in lines(doc) { + let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else { + open = None; + continue; + }; + // A heading goes on to the next row down, close below its last line and under it. A line + // on the same row (two headings side by side), or in another column, is a heading of + // its own. + let continues = open.is_some_and(|(l, top, bottom, left, right)| { + let size = line.style.size; + let under = line.left < right && left < line.right; + l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 && under + }) && entries.last().is_some_and(|e| e.page == line.page); + if continues { + if let Some(e) = entries.last_mut() + && e.title.chars().count() < MAX_TITLE_CHARS + { + e.title.push(' '); + e.title.push_str(&line.text); + e.title = e.title.chars().take(MAX_TITLE_CHARS).collect(); + } + } else { + if entries.len() >= MAX_BOOKMARKS { + break; + } + // The level counts from 1; the position in `levels` is at most its length, which a + // caller keeps small (a few levels), so it fits a u8. + let level = u8::try_from(level.saturating_add(1)).unwrap_or(u8::MAX); + entries.push(OutlineEntry { + level, + title: line.text.chars().take(MAX_TITLE_CHARS).collect(), + page: line.page, + element: None, + top: Some(line.top), + }); + } + open = Some((level, line.top, line.bottom, line.left, line.right)); + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subset_tags_are_left_out_of_the_font_name() { + assert_eq!(without_subset_tag("ABCDEF+Garamond-Bold"), "Garamond-Bold"); + assert_eq!(without_subset_tag("Garamond-Bold"), "Garamond-Bold"); + assert_eq!(without_subset_tag("ABCDE+Garamond"), "ABCDE+Garamond", "a tag is six letters"); + assert_eq!(without_subset_tag("abcdef+Garamond"), "abcdef+Garamond", "capital letters"); + assert_eq!(without_subset_tag("ABCDEF+"), "ABCDEF+", "nothing after the tag"); + assert_eq!(without_subset_tag("ÄBCDEF+x"), "ÄBCDEF+x"); + } + + #[test] + fn styles_match_within_a_quarter_point() { + let s = |size| TextStyle { font: "Garamond".into(), size, bold: true, italic: false }; + assert!(s(12.0).matches(&s(11.8))); + assert!(!s(12.0).matches(&s(11.7))); + assert!(!s(12.0).matches(&TextStyle { bold: false, ..s(12.0) })); + assert!(!s(12.0).matches(&TextStyle { font: "Times".into(), ..s(12.0) })); + } +} diff --git a/crates/engine/src/word_count.rs b/crates/engine/src/word_count.rs index bd31c27e4c..feeb0a066f 100644 --- a/crates/engine/src/word_count.rs +++ b/crates/engine/src/word_count.rs @@ -110,6 +110,16 @@ fn standard_role(doc: &pdfcraft_cos::Document, map: &Dict, role: &[u8]) -> Vec bool { + let resources = page.get(b"Resources").map(|o| doc.resolve(o)); + let objects = resources.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"XObject")).map(|o| doc.resolve(o)); + objects + .as_ref() + .and_then(|o| o.as_dict()) + .is_some_and(|d| d.iter().any(|(_, value)| matches!(&*doc.resolve(value), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form")))) +} + type Exclusions = HashMap>; /// Resolve page-local MCIDs through the structure hierarchy, including inherited roles and @@ -179,11 +189,7 @@ fn exclusions(source: &ExportSource, options: Options) -> Result<(Exclusions, bo if !out.contains_key(&index) { continue; } - let resources = page.dict.get(b"Resources").map(|o| doc.resolve(o)); - let objects = resources.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"XObject")).map(|o| doc.resolve(o)); - let has_form = objects.as_ref().and_then(|o| o.as_dict()).is_some_and(|d| { - d.iter().any(|(_, value)| matches!(&*doc.resolve(value), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form"))) - }); + let has_form = page_has_form_xobject(&doc, &page.dict); if has_form { out.remove(&index); unclassified = true; diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 0f37933afd..f20c82f6c6 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> { XfaEffect::Print => Some("print"), XfaEffect::SaveAs => Some("save"), XfaEffect::LaunchUrl(_) => Some("open a link"), + XfaEffect::ShowPanel(_) => Some("show a side panel"), XfaEffect::SetFocus(_) => Some("move the focus"), _ => None, } @@ -127,8 +128,13 @@ pub(crate) struct XfaRun<'a> { pub datasets: Option, /// A script ran past its time limit and was abandoned: the form's scripts should stay off. pub ran_away: bool, + /// What every time limit of the scripts is multiplied by (the event's budget, the engine's + /// start and each script's own): 1, unless the session was given another + /// ([`crate::Session::with_xfa_time_scale`]). + pub time_scale: u32, } +/// The live form node `n`, with its descendants, as the script engine's snapshot of it. fn to_js(n: &FormNode) -> XfaNode { XfaNode { name: n.name.clone(), @@ -149,6 +155,7 @@ fn to_js(n: &FormNode) -> XfaNode { occur_max: n.occur_max, index: n.index, children: n.children.iter().map(to_js).collect(), + formatted: n.display_picture.as_deref().and_then(|p| pdfcraft_xfa::formatted_value(p, &n.value)), } } @@ -274,6 +281,7 @@ impl<'a, 'b> Runner<'a, 'b> { fn new(doc: &'a mut pdfcraft_cos::Document, tpl: &'a Template, run: &'a mut XfaRun<'b>, budget_ms: u64) -> Self { let data = pdfcraft_xfa::data_of(doc); let ov = pdfcraft_xfa::overrides(doc); + let budget_ms = budget_ms.saturating_mul(u64::from(run.time_scale)); Runner { doc, tpl, @@ -308,7 +316,8 @@ impl<'a, 'b> Runner<'a, 'b> { /// The script engine for this event. fn session(&mut self) -> &mut XfaSession { let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count }; - self.session.get_or_insert_with(|| XfaSession::new(doc_info)) + let scale = self.run.time_scale; + self.session.get_or_insert_with(|| XfaSession::new(doc_info).with_time_scale(scale)) } /// Give the object at `som` the value `text` in the data and in the live form (refreshed @@ -379,6 +388,7 @@ impl<'a, 'b> Runner<'a, 'b> { } } + /// Keep console `lines`, up to [`MAX_CONSOLE_PER_EVENT`] for one event; the rest are counted. fn console(&mut self, lines: impl IntoIterator) { for l in lines { if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT { @@ -389,7 +399,8 @@ impl<'a, 'b> Runner<'a, 'b> { } } - fn alert(&mut self, m: String) { + /// Show message `m`, up to [`MAX_ALERTS_PER_EVENT`] for one event; the rest are counted. + fn alert(&mut self, m: crate::js::Message) { if self.run.out.alerts.len() < MAX_ALERTS_PER_EVENT { self.run.out.alerts.push(m); } else { @@ -530,7 +541,7 @@ impl<'a, 'b> Runner<'a, 'b> { } else { Some(by_som.iter().filter(|(som, _)| names.iter().any(|n| som_matches(som, n))).map(|(_, f)| f.clone()).collect()) }; - if listed.as_ref().is_some_and(|l| l.is_empty()) { + if listed.as_ref().is_some_and(Vec::is_empty) { return Ok(()); } pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?; @@ -626,6 +637,7 @@ impl<'a, 'b> Runner<'a, 'b> { XfaEffect::Print => self.run.out.requests.push(Request::Print), XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs), XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())), + XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)), XfaEffect::SetFocus(som) => { if let Some(name) = self.by_som().get(som).cloned() { self.run.out.requests.push(Request::Focus(name)); @@ -657,7 +669,7 @@ impl<'a, 'b> Runner<'a, 'b> { event, root, limits: limits_for(&ev.activity), - timeout: timeout_for(&ev.activity), + timeout: timeout_for(&ev.activity).saturating_mul(self.run.time_scale), }; let o = self.session().run(script); if o.abandoned { @@ -669,9 +681,12 @@ impl<'a, 'b> Runner<'a, 'b> { for n in &o.notes { self.error(format!("{} ({}): {n}", ev.som, ev.activity)); } + // A script that fails keeps what it changed before the error, as JavaScript does: forms + // often trip over an object an earlier version had, late in a script whose first lines + // did the work (opening the next fields, showing a subform). An abandoned script, or + // one the engine failed, reports no changes. if let Some(e) = &o.error { self.error(format!("{} ({}): {e}", ev.som, ev.activity)); - return Ok(Some(o)); } if self.apply_effects(ev, &o.effects)? { self.relayout()?; @@ -764,6 +779,49 @@ fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>, } } +/// Reflow a laid-out XFA form when an edited multiline field can grow. The datasets must +/// already hold the final values, including calculation results. Fixed-height fields keep +/// their existing geometry; forms without scripts parse their template only when needed. +/// `Some` means pages/widgets were rebuilt and the caller must fully refresh its snapshot. +pub(crate) fn relayout_changed_text( + doc: &mut pdfcraft_cos::Document, + cached: Option<&Template>, + before: &[pdfcraft_forms::Field], +) -> Result>, String> { + if pdfcraft_xfa::existing_layout(doc).is_none() { + return Ok(None); + } + let current = &*doc; + let mut changed = before + .iter() + .filter(|f| f.kind == pdfcraft_forms::FieldKind::Text && f.has(pdfcraft_forms::flags::MULTILINE)) + .filter_map(|f| { + let object = current.get(f.obj); + let dict = object.as_dict()?; + let value = dict.get(b"V").and_then(|v| current.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default(); + if f.value.first().map_or("", String::as_str) == value { + return None; + } + dict.get(pdfcraft_xfa::SOM_KEY).and_then(|s| current.resolve(s).as_string().map(|s| s.to_text())) + }) + .peekable(); + if changed.peek().is_none() { + return Ok(None); + } + let template = match cached { + Some(tpl) => std::borrow::Cow::Borrowed(tpl), + None => std::borrow::Cow::Owned(pdfcraft_xfa::template_of(current).map_err(|e| e.to_string())?), + }; + let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som)); + drop(changed); + if !reflow { + return Ok(None); + } + let report = pdfcraft_xfa::rerender(doc, &template).map_err(|e| e.to_string())?; + crate::xfa_redraw(doc)?; + Ok(Some(report.warnings)) +} + /// On open: initialize scripts, then calculations. Returns what they changed. pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result { let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS); @@ -791,7 +849,7 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names for ev in r.events_of(&som, "validate") { let Some(o) = r.run(&ev, &value)? else { continue }; if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() { - r.alert(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid"))); + r.alert(crate::js::Message::plain(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid")))); } } } @@ -810,6 +868,28 @@ pub(crate) fn on_data_replaced(doc: &mut pdfcraft_cos::Document, tpl: &Template, r.finish().map(|_| ()) } +/// A text value typed into the laid-out XFA date field `name` by another of its edit picture's +/// patterns, written in the first ([`pdfcraft_xfa::typed_date`]); `None` for anything else. +/// `tpl` is the template the scripts use, when they run; otherwise it is read from `doc`. +pub(crate) fn typed_date( + doc: &pdfcraft_cos::Document, + tpl: Option<&Template>, + name: &str, + value: &pdfcraft_forms::FieldValue, +) -> Option { + let pdfcraft_forms::FieldValue::Text(text) = value else { return None }; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find_map(|(som, n)| (n == name).then_some(som))?; + let read; + let tpl = match tpl { + Some(t) => t, + None => { + read = pdfcraft_xfa::template_of(doc).ok()?; + &read + } + }; + pdfcraft_xfa::typed_date(tpl, &som, text).map(pdfcraft_forms::FieldValue::Text) +} + /// A button's click script (or any event, by SOM path and activity), then calculations. pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> { let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS); @@ -821,15 +901,17 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s r.run(&ev, "")?; } // The calculations that follow get a budget of their own. - r.clock = Clock::start(OPEN_BUDGET_MS); + r.clock = Clock::start(OPEN_BUDGET_MS.saturating_mul(u64::from(r.run.time_scale))); r.calculates()?; r.finish().map(|_| ()) } -/// The SOM path of field `name` when PdfCraft generated it from an XFA template and the -/// template has a click script for it (a button with a native action has none). -pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, name: &str) -> Option { - let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, f)| f == name).map(|(s, _)| s)?; +/// The SOM path of a generated field whose template has a click script. +pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field) -> Option { + // Page-master copies have distinct PDF field names but share a SOM path. Reversing + // fields_by_som would lose every copy except the last: use the clicked field itself. + let object = doc.get(field.obj); + let som = object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string()?.to_text(); let live = pdfcraft_xfa::form_tree(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc)); live.events.iter().any(|e| e.som == som && e.activity == "click").then_some(som) } diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs new file mode 100644 index 0000000000..5ceaba1cd4 --- /dev/null +++ b/crates/engine/src/xfa_form.rs @@ -0,0 +1,297 @@ +//! A document's XFA form: laying it out, keeping its datasets and fields in step, and taking it out. + +use crate::edit::EditCtx; +use crate::{Document, EditError, FieldValue, XfaLayout, guard, xfa}; + +/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. +pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { + let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; + xfa_redraw(doc)?; + // The form's own values: one its field's font can't draw is drawn with `?` and reported. + report.warnings.extend(undrawn_field_warnings(doc)); + Ok(report) +} + +/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the +/// picture its signer's application drew. +pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { + for f in pdfcraft_forms::fields(doc) { + if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { + continue; + } + pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; + } + Ok(()) +} + +/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font +/// can't draw (laying out an XFA form's own values). +pub(crate) fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { + pdfcraft_forms::undrawn_fields(doc) + .into_iter() + .map(|(name, c)| { + format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) + }) + .collect() +} + +/// The form's fields as the XFA data layer wants them. +fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { + use pdfcraft_forms::FieldKind as K; + pdfcraft_forms::fields(doc) + .into_iter() + .map(|f| { + let data = match f.kind { + K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), + K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), + K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), + K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, + }; + pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } + }) + .collect() +} + +/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not +/// be written. +/// `datasets`: the stream this edit already wrote, replaced in place rather than added again +/// (and set to the one written). +/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). +pub(crate) fn xfa_sync_datasets( + doc: &mut pdfcraft_cos::Document, + tpl: Option<&pdfcraft_xfa::model::Template>, + datasets: &mut Option, +) -> Result, String> { + let data = xfa_field_data(doc); + let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; + if r.stream.is_some() { + *datasets = r.stream; + } + Ok(r.warnings) +} + +/// What a document whose form script ran away is told. +pub(crate) const XFA_SCRIPTS_OFF: &str = + "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; + +/// Most XFA warnings kept per document. +const MAX_XFA_WARNINGS: usize = 50; + +/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). +pub(crate) fn note_warnings(list: &mut Vec, new: &[String]) { + for w in new { + if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { + list.push(w.clone()); + } + } +} + +/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or +/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an +/// encoding this can't read. +pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { + if bytes.starts_with(b"%FDF") { + return None; + } + let declared = |text: &str| -> Option { + let head = text.get(..text.len().min(256))?; + let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; + let enc = decl.split_once("encoding=")?.1; + let quote = enc.chars().next()?; + Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) + }; + if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { + match declared(&text).as_deref() { + None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { + // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 + // differs, and is rare in form data). + return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); + } + Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), + } + } + // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. + let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); + match declared(&latin1).as_deref() { + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), + _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), + } +} + +/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, +/// buttons that did nothing but run scripts, and fields whose value differs from the form's +/// data (data PdfCraft could not read into them, now lost). +pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { + let fields = pdfcraft_forms::fields(doc); + let data = xfa_field_data(doc); + let differ = pdfcraft_xfa::read_values(doc, &data) + .into_iter() + .filter(|(name, value)| { + let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; + match value { + pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), + pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, + pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), + pdfcraft_xfa::FieldData::None => false, + } + }) + .count(); + let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_removed.extend(r.notes()); + if differ > 0 { + cx.xfa_removed.push(format!( + "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" + )); + } + Ok(()) +} + +/// Keep the document's XFA state in step with its file after an edit: a script may have laid +/// the form out again (rows added, subforms shown), an edit may have taken the XFA out +/// (flattened, or made static), or an undo put it back. +pub(crate) fn sync_xfa_state(doc: &mut Document) { + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; + match pdfcraft_xfa::existing_layout(cos) { + Some(report) => { + if doc.xfa.is_none() { + // Back after an undo: the XFA's own warnings apply again, the strip's don't. + doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); + if doc.xfa_template.is_none() && !doc.xfa_scripts_off { + doc.xfa_template = xfa::template(cos); + } + } + doc.xfa = Some(report); + } + None => { + doc.xfa = None; + doc.xfa_template = None; + // Only what the strip said stays (the XFA's own warnings no longer apply). + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + } + } +} + +/// How the warnings of a strip start, so they can be told from the XFA's own. +pub(crate) const XFA_REMOVED_PREFIX: &str = "XFA removed: "; + +/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), +/// rather than XFDF, Acrobat's `` XML or a table? By its root element. +pub(crate) fn is_xfa_data_file(text: &str) -> bool { + let text = text.get(..text.len().min(65_536)).unwrap_or(text); + let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + // Past the XML declaration, comments and processing instructions. + let mut guard = 0; + while guard < 100 { + guard += 1; + if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); + } else { + break; + } + } + let Some(tag) = rest.strip_prefix('<') else { return false }; + let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); + let local = name.rsplit(':').next().unwrap_or(name); + !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") +} + +/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. +pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { + match pdfcraft_xfa::image::kind(bytes)? { + pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"), + pdfcraft_xfa::image::Kind::Png => Some("image/png"), + pdfcraft_xfa::image::Kind::Gif => Some("image/gif"), + pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"), + pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"), + } +} + +/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is +/// laid out again from the data (then, when `calculate`, its calculations run), and the +/// fields take the data's values. +pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { + if let Some(tpl) = cx.xfa.clone() { + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) + .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = r { + cx.xfa_out.errors.push(e); + } + } + let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; + if !taken.is_empty() { + cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); + } + // Not laid out again (a static form, or scripts off): fields the data no longer holds are + // emptied, so the data replaced is the data shown. + if cx.xfa.is_none() && calculate { + let data = xfa_field_data(doc); + let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); + for f in pdfcraft_forms::fields(doc) { + if present.contains(&f.name) || f.value.is_empty() { + continue; + } + use pdfcraft_forms::FieldKind as K; + let empty = match f.kind { + K::Text => FieldValue::Text(String::new()), + K::CheckBox => FieldValue::Check(false), + K::Radio => FieldValue::Radio(None), + K::Combo | K::List => FieldValue::Choice(Vec::new()), + K::PushButton | K::Signature => continue, + }; + if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { + cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); + } + } + } + Ok(()) +} + +/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns +/// the names of the fields that changed. +pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { + let data = xfa_field_data(doc); + let fields = pdfcraft_forms::fields(doc); + // The first field of each name, as a lookup by name would find it. + let mut by_name = std::collections::HashMap::new(); + for f in &fields { + by_name.entry(f.name.as_str()).or_insert(f); + } + let mut updates = Vec::new(); + for (name, value) in pdfcraft_xfa::read_values(doc, &data) { + let Some(f) = by_name.get(name.as_str()) else { continue }; + let new = match value { + pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { + // Saved values, one per line for a multi-select list; shown text stands for + // its saved value (an older viewer may have written it). + let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); + let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { + t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() + } else { + vec![saved(t.trim())] + }; + (f.value != picked).then_some(FieldValue::Choice(picked)) + } + pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), + pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), + pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), + pdfcraft_xfa::FieldData::None => None, + }; + if let Some(v) = new { + updates.push((name, v)); + } + } + // All at once: one by one, each value would read the whole form again, which took minutes + // on a form with thousands of fields. + let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; + Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) +} diff --git a/crates/engine/tests/document_fonts.rs b/crates/engine/tests/it/document_fonts.rs similarity index 99% rename from crates/engine/tests/document_fonts.rs rename to crates/engine/tests/it/document_fonts.rs index ebeff24ae5..e4212f4a3a 100644 --- a/crates/engine/tests/document_fonts.rs +++ b/crates/engine/tests/it/document_fonts.rs @@ -1,7 +1,5 @@ //! Document font selection and contextual glyph save/reopen acceptance on synthetic PDFs. -mod support { - pub mod font_fixture; -} +use crate::support; use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; use pdfcraft_engine::{BlockStyle, Edit, Session}; use pdfcraft_render::{PageRenderer, RenderRequest}; diff --git a/crates/engine/tests/form_rotation.rs b/crates/engine/tests/it/form_rotation.rs similarity index 100% rename from crates/engine/tests/form_rotation.rs rename to crates/engine/tests/it/form_rotation.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 0000000000..8d9026600d --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,13 @@ +//! The crate's integration tests, linked into one test binary. + +mod support { + pub mod font_fixture; +} + +mod document_fonts; +mod form_rotation; +mod marked_content_isolation; +mod ocr_edit; +mod office_export_images; +mod private_image_resize; +mod private_text_glyphs; diff --git a/crates/engine/tests/marked_content_isolation.rs b/crates/engine/tests/it/marked_content_isolation.rs similarity index 100% rename from crates/engine/tests/marked_content_isolation.rs rename to crates/engine/tests/it/marked_content_isolation.rs diff --git a/crates/engine/tests/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs similarity index 100% rename from crates/engine/tests/ocr_edit.rs rename to crates/engine/tests/it/ocr_edit.rs diff --git a/crates/engine/tests/it/office_export_images.rs b/crates/engine/tests/it/office_export_images.rs new file mode 100644 index 0000000000..4f2ff618fb --- /dev/null +++ b/crates/engine/tests/it/office_export_images.rs @@ -0,0 +1,43 @@ +//! Office export of pages that draw one image many times. + +use std::sync::Arc; + +use pdfcraft_engine::Session; + +fn pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +/// One image drawn many times is prepared once: every placement shares its encoded bytes. +#[test] +fn an_image_drawn_many_times_is_encoded_once() { + let placements = 500; + let content = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(placements); + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{40}\u{80}\u{ff}\nendstream".into(), + ]); + let mut s = Session::new(); + let id = s.open("many.pdf", None, Arc::new(bytes), None).unwrap(); + let pages = s.get(id).unwrap().export_pages(); + let images = &pages[0].images; + assert_eq!(images.len(), placements); + let first = images[0].bytes.as_ptr(); + assert!(!images[0].bytes.is_empty()); + assert!(images.iter().all(|im| im.bytes.as_ptr() == first), "each placement holds its own copy of the image"); +} diff --git a/crates/engine/tests/private_image_resize.rs b/crates/engine/tests/it/private_image_resize.rs similarity index 100% rename from crates/engine/tests/private_image_resize.rs rename to crates/engine/tests/it/private_image_resize.rs diff --git a/crates/engine/tests/private_text_glyphs.rs b/crates/engine/tests/it/private_text_glyphs.rs similarity index 100% rename from crates/engine/tests/private_text_glyphs.rs rename to crates/engine/tests/it/private_text_glyphs.rs diff --git a/crates/engine/tests/support/font_fixture.rs b/crates/engine/tests/it/support/font_fixture.rs similarity index 100% rename from crates/engine/tests/support/font_fixture.rs rename to crates/engine/tests/it/support/font_fixture.rs diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs new file mode 100644 index 0000000000..ab7451dc0e --- /dev/null +++ b/crates/export/src/as_docx.rs @@ -0,0 +1,364 @@ +use super::Zip; +use super::as_html::esc; +use super::layout::{Image, Item, Page, Table, body_size, items}; + +/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). +pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { + // The value is clamped to 0..=255 before the cast, so it cannot truncate. + c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) +} + +/// The colour as `RRGGBB`, or `None` for black (the default every format already uses). +pub(crate) fn hex(c: [f64; 3]) -> Option { + let [r, g, b] = rgb8(c); + (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) +} + +/// A formatted text run (shared by paragraphs and table cells). +fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { + let mut rpr = String::new(); + if bold { + rpr.push_str(""); + } + if italic { + rpr.push_str(""); + } + if let Some(h) = hex(color) { + rpr.push_str(&format!("")); + } + let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; + rpr.push_str(&format!("")); + format!("{rpr}{}", esc(text)) +} + +/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized +/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes. +pub(crate) fn docx_table(t: &Table) -> String { + let mut edges = t.cols.clone(); + edges.push(t.rect[2]); + let widths: Vec = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect(); + let mut s = String::from(""); + for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] { + s.push_str(&format!("")); + } + s.push_str(""); + for w in &widths { + s.push_str(&format!("")); + } + s.push_str(""); + for row in &t.rows { + s.push_str(""); + let mut column = 0usize; + for c in row { + let span = if c.span > 1 { format!("", c.span) } else { String::new() }; + let w: i64 = widths.iter().skip(column).take(c.span).sum(); + s.push_str(&format!( + "{span}{}", + run_xml(&c.text, c.size, c.bold, c.italic, c.color) + )); + column = column.saturating_add(c.span); + } + // Pad the grid so every row covers all columns (Word rejects short rows). + for _ in column..t.cols.len() { + s.push_str(""); + } + s.push_str(""); + } + s.push_str(""); + s +} + +/// Pages with rotated text, or text that actually overlaps an image or an oversized +/// (watermark-like) text block, cannot be put in paragraph flow without changing their layout. +/// Keep those pages as editable, absolutely positioned text boxes. Every other page, including +/// one with a logo or a big title that sits clear of the text, flows as paragraphs and tables. +fn positioned(page: &Page) -> bool { + if page.blocks.iter().any(|b| b.rotation.abs() > 0.1) { + return true; + } + let body = body_size(std::slice::from_ref(page)).max(1.0); + let overlays = page + .images + .iter() + .map(|im| (im.rect, None)) + .chain(page.blocks.iter().enumerate().filter(|(_, b)| b.size >= 2.0 * body).map(|(i, b)| (b.rect, Some(i)))); + // Bound the pairwise test on hostile pages (many overlays times many blocks): past the + // budget, keep the default flowing layout. + let mut budget: usize = 1_000_000; + for (r, own) in overlays { + for (i, b) in page.blocks.iter().enumerate() { + if budget == 0 { + return false; + } + budget -= 1; + if Some(i) != own && overlaps(r, b.rect) { + return true; + } + } + } + false +} + +/// Whether two [x0, y0, x1, y1] boxes share more than a hairline (0.5 pt each way); boxes that +/// only touch, or hostile non-finite coordinates, do not count. +fn overlaps(a: [f64; 4], b: [f64; 4]) -> bool { + let w = a[2].min(b[2]) - a[0].max(b[0]); + let h = a[3].min(b[3]) - a[1].max(b[1]); + w.is_finite() && h.is_finite() && w > 0.5 && h > 0.5 +} + +/// Bound every value written into a shape's style, including hostile PDF coordinates. +fn layout_number(n: f64) -> f64 { + if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 } +} + +/// Most images a Word export embeds, and most image bytes (the zip writer has 32-bit sizes and +/// a 16-bit entry count; a hostile PDF must not be able to overflow them). +pub(crate) const MAX_MEDIA: usize = 1000; + +const MAX_MEDIA_BYTES: usize = 512 << 20; + +/// Whether `im` can still be embedded: it has data, and the package stays within its limits. +fn room_for(media: &[(String, &Image)], im: &Image) -> bool { + !im.bytes.is_empty() + && media.len() < MAX_MEDIA + && media.iter().map(|(_, m)| m.bytes.len()).fold(im.bytes.len(), usize::saturating_add) <= MAX_MEDIA_BYTES +} + +/// Points as EMU (12700 per point); non-finite values are 0. +fn emu(pt: f64) -> i64 { + if pt.is_finite() { (pt * 12700.0).round() as i64 } else { 0 } +} + +/// The `a:graphic` of one picture (DrawingML), rotated by `rotation` degrees when given. +fn picture_xml(n: usize, name: &str, (cx, cy): (i64, i64), rotation: Option) -> String { + let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64)); + format!( + "\ +\ +\ +\ +\ +" + ) +} + +fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String { + // One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a + // page-sized watermark cannot push the invoice footer onto another page. + let mut s = String::from(""); + let body = body_size(std::slice::from_ref(page)).max(1.0); + for b in &page.blocks { + *shape_id = shape_id.saturating_add(1); + let rotation = layout_number(-b.rotation).rem_euclid(360.0); + let (sin, cos) = rotation.to_radians().sin_cos(); + let (bw, bh) = (layout_number(b.rect[2] - b.rect[0]).max(1.0), layout_number(b.rect[3] - b.rect[1]).max(1.0)); + let h = if b.rotation.abs() > 0.1 { layout_number(b.size).max(1.0) } else { bh }; + // Recover the unrotated width from the axis-aligned box. Use the larger axis so + // quarter turns and 45-degree watermarks do not divide by (nearly) zero. + let w = if b.rotation.abs() <= 0.1 { + bw + } else if cos.abs() >= sin.abs() { + (bw - h * sin.abs()) / cos.abs() + } else { + (bh - h * cos.abs()) / sin.abs() + }; + let w = layout_number(w).max(1.0); + let (x, y) = (layout_number(b.rect[0] + (bw - w) / 2.0), layout_number(page.height - b.rect[3] + (bh - h) / 2.0)); + let z = if b.size >= 2.0 * body { -2 } else { 1 }; + let style = format!( + "position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page" + ); + s.push_str(&format!( + "{}", + shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color) + )); + } + for im in &page.images { + if !room_for(media, im) { + continue; + } + *shape_id = shape_id.saturating_add(1); + let n = media.len().saturating_add(1); + let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0)); + let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0)); + let rotation = layout_number(-im.rotation).rem_euclid(360.0); + let name = format!("image{n}.{}", im.ext); + let pic = picture_xml(n, &name, (emu(w), emu(h)), Some(rotation)); + // A DrawingML anchor, the way Word itself stores a picture placed behind text: a VML + // rectangle with an image does not show the picture. + s.push_str(&format!( + "\ +{}{}\ +", + n.saturating_add(1), + emu(x), + emu(y), + emu(w), + emu(h), + )); + s.push_str(&pic); + s.push_str(""); + media.push((name, im)); + } + s.push_str(""); + s +} + +fn word_page_size(page: &Page) -> (i64, i64) { + let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 }; + (twips(page.width), twips(page.height)) +} + +fn section(page: &Page) -> String { + let (pw, ph) = word_page_size(page); + let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440)); + format!( + "", + my / 2, + my / 2 + ) +} + +/// The page colour of a Word document: Word has one background for the whole document, so it +/// is written only when every page has the same, non-white background (#2992). +fn word_background(pages: &[Page]) -> Option { + let first = rgb8(pages.first()?.background?); + let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first)); + (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) +} + +/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; +/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. +pub fn docx(pages: &[Page], title: &str) -> Vec { + let mut body = String::new(); + let mut media: Vec<(String, &Image)> = Vec::new(); + // Word needs a paragraph between adjacent tables and after the last one in the body. + let mut after_table = false; + let mut shape_id = 0usize; + for (page_index, page) in pages.iter().enumerate() { + let (pw, _) = word_page_size(page); + let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0; + if positioned(page) { + body.push_str(&positioned_page(page, &mut media, &mut shape_id)); + after_table = false; + } else { + for it in items(std::slice::from_ref(page)) { + match &it { + Item::Para(runs, lvl) => { + let style = match lvl { + 1 => "", + 2 => "", + _ => "", + }; + body.push_str(""); + body.push_str(style); + for r in runs { + body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); + } + body.push_str(""); + } + Item::Table(t) => { + if after_table { + body.push_str(""); + } + body.push_str(&docx_table(t)); + } + Item::Img(im) => { + if room_for(&media, im) { + let n = media.len() + 1; + let name = format!("image{n}.{}", im.ext); + // Size on the page, at most the text width. + let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); + let k = (text_w / w).min(1.0); + let (cx, cy) = (emu(w * k), emu(h * k)); + body.push_str(&format!("")); + body.push_str(&picture_xml(n, &name, (cx, cy), None)); + body.push_str(""); + media.push((name, im)); + } + } + Item::PageBreak => body.push_str(""), + } + after_table = matches!(it, Item::Table(_)); + } + } + // Section breaks carry this source page's size and create exactly one next page. + // The last section must be a direct child of the body (OOXML 17.6.17). + if page_index + 1 < pages.len() { + body.push_str(&format!( + "{}", + section(page) + )); + } else { + if after_table || page.blocks.is_empty() && page.images.is_empty() { + body.push_str(""); + } + body.push_str(§ion(page)); + } + after_table = false; + } + // Word shows a document background only when its settings say so (`settings.xml` below). + let page_colour = word_background(pages); + let background = page_colour.as_deref().map(|c| format!("")).unwrap_or_default(); + let doc = format!( + "\n\ +{background}{body}\ +" + ); + let mut types = String::from( + "\n\ +\ +\ +\ +\ +", + ); + if page_colour.is_some() { + types.push_str( + "", + ); + } + types.push_str(""); + let rels = "\n\ +\ +"; + let mut doc_rels = String::from( + "\n\ +", + ); + for (i, (name, _)) in media.iter().enumerate() { + doc_rels.push_str(&format!( + "", + i + 1 + )); + } + if page_colour.is_some() { + doc_rels.push_str(""); + } + doc_rels.push_str(""); + let styles = "\n\ +\ +\ +\ +"; + let core = format!( + "\n{}", + esc(title) + ); + let mut z = Zip::default(); + z.add("[Content_Types].xml", types.as_bytes(), true); + z.add("_rels/.rels", rels.as_bytes(), true); + z.add("docProps/core.xml", core.as_bytes(), true); + z.add("word/document.xml", doc.as_bytes(), true); + z.add("word/styles.xml", styles.as_bytes(), true); + z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); + if page_colour.is_some() { + z.add("word/settings.xml", b"\n", true); + } + for (name, im) in &media { + z.add(&format!("word/media/{name}"), &im.bytes, false); + } + z.finish() +} diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs new file mode 100644 index 0000000000..7aeea595a8 --- /dev/null +++ b/crates/export/src/as_html.rs @@ -0,0 +1,103 @@ +use super::as_docx::{hex, rgb8}; +use super::layout::{Item, Page, items}; + +/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, +/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them +/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72). +pub(crate) fn allowed(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') +} + +pub(crate) fn esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '&' => o.push_str("&"), + '<' => o.push_str("<"), + '>' => o.push_str(">"), + '"' => o.push_str("""), + c => o.push(c), + } + } + o +} + +pub(crate) fn base64(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + s.push(A[(n >> 18) as usize & 63] as char); + s.push(A[(n >> 12) as usize & 63] as char); + s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); + s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); + } + s +} + +/// One HTML file: headings and paragraphs, tables as real `
` elements, images inline, a +/// rule between pages. +pub fn html(pages: &[Page], title: &str) -> String { + let mut s = format!( + "\n\n\n\n{}\n\n\n\n", + esc(title) + ); + for it in items(pages) { + match it { + Item::Para(runs, lvl) => { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + let mut t = String::new(); + for r in &runs { + let mut run = esc(&r.text); + if r.block.italic { + run = format!("{run}"); + } + if r.block.bold && lvl == 0 { + run = format!("{run}"); + } + // The paragraph has its first run's colour; a run in another colour says so. + let colour = rgb8(r.block.color); + if colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); + } + let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + match lvl { + 0 => s.push_str(&format!("{t}

\n")), + l => s.push_str(&format!("{t}\n")), + } + } + Item::Table(t) => { + s.push_str("
\n"); + for row in &t.rows { + s.push_str(""); + for c in row { + let mut body = esc(&c.text); + if c.italic { + body = format!("{body}"); + } + if c.bold { + body = format!("{body}"); + } + let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; + let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + s.push_str(&format!("{body}")); + } + s.push_str("\n"); + } + s.push_str("
\n"); + } + Item::Img(im) => { + let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; + s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); + } + Item::PageBreak => s.push_str("
\n"), + } + } + s.push_str("\n\n"); + s +} diff --git a/crates/export/src/as_markdown.rs b/crates/export/src/as_markdown.rs new file mode 100644 index 0000000000..a5c986c6b3 --- /dev/null +++ b/crates/export/src/as_markdown.rs @@ -0,0 +1,151 @@ +use super::as_html::allowed; +use super::layout::{Cell, Item, MAX_COLS, Page, items}; + +/// Markdown's deepest heading. +const MAX_HEADING: u8 = 6; + +/// `s` as Markdown text on one line: line breaks become spaces, control characters are dropped, +/// and every character Markdown would read as markup is escaped with a backslash. +fn md_text(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '\n' | '\r' | '\t' => o.push(' '), + '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '#' | '|' | '~' | '!' | '&' => { + o.push('\\'); + o.push(c); + } + c => o.push(c), + } + } + o +} + +/// `line` with its start escaped where Markdown would read a list item there (`- `, `+ `, +/// `1. `, `1) `); the other block markers (`#`, `>`) are escaped by [`md_text`] already. +fn block_start(line: String) -> String { + let digits = line.bytes().take_while(u8::is_ascii_digit).count(); + match line.as_bytes().get(digits) { + Some(b'.' | b')') if digits > 0 => format!("{}\\{}", line.get(..digits).unwrap_or_default(), line.get(digits..).unwrap_or_default()), + Some(b'-' | b'+') if digits == 0 => format!("\\{line}"), + _ => line, + } +} + +/// `text` (already escaped) in bold and/or italic, its outer spaces kept outside. `open` and +/// `close` say whether `*` markers would be read as emphasis on that side (see [`marks`]); +/// where they would not, as in `foo` + bold `.` + `bar`, the run is wrapped in HTML tags, which +/// CommonMark takes inline. The text's own outer spaces count as a side that marks. +pub(crate) fn emphasis(text: &str, bold: bool, italic: bool, open: bool, close: bool) -> String { + let core = text.trim(); + if core.is_empty() || !(bold || italic) { + return text.to_string(); + } + let lead = text.get(..text.len().saturating_sub(text.trim_start().len())).unwrap_or_default(); + let trail = text.get(text.trim_end().len()..).unwrap_or_default(); + let markers = (open || !lead.is_empty()) && (close || !trail.is_empty()); + let (before, after) = match (markers, bold, italic) { + (true, true, true) => ("***", "***"), + (true, true, false) => ("**", "**"), + (true, false, _) => ("*", "*"), + (false, true, true) => ("", ""), + (false, true, false) => ("", ""), + (false, false, _) => ("", ""), + }; + format!("{lead}{before}{core}{after}{trail}") +} + +/// A paragraph's runs (text, bold, italic) as one line of Markdown. Neighbouring runs in the +/// same style are joined first, so two bold runs never meet as `****`. +pub(crate) fn paragraph(runs: &[(&str, bool, bool)]) -> String { + let mut merged: Vec<(String, bool, bool)> = Vec::new(); + for &(text, bold, italic) in runs { + match merged.last_mut() { + Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(text), + _ => merged.push((text.to_string(), bold, italic)), + } + } + let texts: Vec = merged.iter().map(|(text, ..)| md_text(text)).collect(); + let mut out = String::new(); + for (k, ((_, bold, italic), text)) in merged.iter().zip(&texts).enumerate() { + let core = text.trim(); + let open = marks(core.chars().next(), out.chars().last()); + // Markers of a styled neighbour right next to these would join them into one run. + let close = match (merged.get(k.saturating_add(1)), texts.get(k.saturating_add(1))) { + (Some((_, b, i)), Some(next)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false, + (_, next) => marks(core.chars().last(), next.and_then(|n| n.chars().next())), + }; + out.push_str(&emphasis(text, *bold, *italic, open, close)); + } + out +} + +/// Whether a `*` marker between the text's `edge` character and the `outside` one is read as +/// emphasis (CommonMark's flanking rules for `*`): always next to a space or the line's edge, +/// never next to another `*`, and otherwise unless the text's edge is punctuation while the +/// outside is a letter or digit. +fn marks(edge: Option, outside: Option) -> bool { + match (edge, outside) { + (_, None) => true, + (_, Some(o)) if o.is_whitespace() => true, + (_, Some('*')) => false, + (Some(e), Some(o)) => e.is_alphanumeric() || o.is_ascii_punctuation(), + (None, Some(_)) => false, + } +} + +fn cell(c: &Cell) -> String { + // A cell sits between `| ` and ` |`, so its markers always have a space outside. + emphasis(&md_text(&c.text), c.bold, c.italic, true, true) +} + +/// Markdown (CommonMark, with GitHub's pipe tables): headings, paragraphs with bold and italic, +/// tables with their first row as the header, and a rule between pages. Images are left out. +pub fn markdown(pages: &[Page]) -> String { + let mut blocks: Vec = Vec::new(); + for it in items(pages) { + match it { + Item::Para(runs, lvl) => { + // A heading's weight is the heading's own, as in the HTML export. + let runs: Vec<(&str, bool, bool)> = runs.iter().map(|r| (r.text.as_str(), r.block.bold && lvl == 0, r.block.italic)).collect(); + let text = paragraph(&runs); + let text = text.trim(); + if text.is_empty() { + continue; + } + blocks.push(match lvl { + 0 => block_start(text.to_string()), + l => format!("{} {text}", "#".repeat(usize::from(l.min(MAX_HEADING)))), + }); + } + Item::Table(t) => { + // Each row as wide as the widest; a cell spanning columns is followed by empty ones. + // Detected tables have at most MAX_COLS columns, and a span never counts for more. + let span = |c: &Cell| c.span.clamp(1, MAX_COLS); + let width = t.rows.iter().map(|r| r.iter().map(span).fold(0usize, usize::saturating_add)).max().unwrap_or(0); + if width == 0 { + continue; + } + let mut lines = Vec::with_capacity(t.rows.len().saturating_add(1)); + for (i, row) in t.rows.iter().enumerate() { + let mut cells: Vec = Vec::with_capacity(width); + for c in row { + cells.push(cell(c)); + cells.extend(std::iter::repeat_n(String::new(), span(c).saturating_sub(1))); + } + cells.resize(width, String::new()); + lines.push(format!("| {} |", cells.join(" | "))); + if i == 0 { + lines.push(format!("|{}", " --- |".repeat(width))); + } + } + blocks.push(lines.join("\n")); + } + Item::Img(_) => {} + Item::PageBreak => blocks.push("---".into()), + } + } + let mut s = blocks.join("\n\n"); + s.push('\n'); + s +} diff --git a/crates/export/src/as_rtf.rs b/crates/export/src/as_rtf.rs new file mode 100644 index 0000000000..61585d82fb --- /dev/null +++ b/crates/export/src/as_rtf.rs @@ -0,0 +1,127 @@ +use super::as_docx::rgb8; +use super::as_html::allowed; +use super::layout::{Block, Item, Page, count_lookup_steps, items}; +use std::collections::HashMap; + +fn rtf_text(s: &str) -> String { + let mut o = String::new(); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '\\' | '{' | '}' => { + o.push('\\'); + o.push(c); + } + c if (c as u32) < 128 => o.push(c), + c => { + // \uN with a ? fallback; UTF-16 units as signed 16-bit numbers. + let mut buf = [0u16; 2]; + for u in c.encode_utf16(&mut buf) { + o.push_str(&format!("\\u{}?", *u as i16)); + } + } + } + } + o +} + +/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks +/// between pages (images are left out). +pub fn rtf(pages: &[Page]) -> String { + let items = items(pages); + // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. + let mut colors: Vec<[u8; 3]> = Vec::new(); + let mut slot: HashMap<[u8; 3], usize> = HashMap::new(); + let mut note = |c: [f64; 3]| { + let c = rgb8(c); + count_lookup_steps(1); + if c != [0, 0, 0] && !slot.contains_key(&c) { + slot.insert(c, colors.len()); + colors.push(c); + } + }; + for it in &items { + match it { + Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)), + Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), + Item::Img(_) | Item::PageBreak => {} + } + } + // `\cfN` for a colour (nothing for black, which keeps the default). + let cf = |c: [f64; 3]| { + let c = rgb8(c); + count_lookup_steps(1); + slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) + }; + let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); + if !colors.is_empty() { + s.push_str("{\\colortbl;"); + for [r, g, b] in &colors { + s.push_str(&format!("\\red{r}\\green{g}\\blue{b};")); + } + s.push_str("}\n"); + } + for it in items { + match it { + Item::Para(runs, _) => { + // The first run's formatting is the paragraph's; each later run is a group that + // turns on or off what differs from it. + let fmt = |b: &Block, later: bool| { + let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); + if b.bold { + fmt.push_str("\\b"); + } else if later { + fmt.push_str("\\b0"); + } + if b.italic { + fmt.push_str("\\i"); + } else if later { + fmt.push_str("\\i0"); + } + let colour = cf(b.color); + fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour }); + fmt + }; + let mut runs = runs.iter(); + let Some(first) = runs.next() else { continue }; + s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text))); + for r in runs { + s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text))); + } + s.push_str("\\par}\n"); + } + Item::Table(t) => { + let mut edges = t.cols.clone(); + edges.push(t.rect[2]); + let cellx: Vec = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect(); + for row in &t.rows { + s.push_str("\\trowd\\trgaph108"); + // The row's cell boundaries come first: each cell ends at the right edge of + // the last grid column it spans. + let mut column = 0; + for c in row { + column += c.span.max(1); + if let Some(x) = cellx.get(column.min(cellx.len()).saturating_sub(1)) { + s.push_str(&format!("\\cellx{x}")); + } + } + for c in row { + let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64); + if c.bold { + fmt.push_str("\\b"); + } + if c.italic { + fmt.push_str("\\i"); + } + fmt.push_str(&cf(c.color)); + s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); + } + s.push_str("\\row\n"); + } + } + Item::Img(_) => {} + Item::PageBreak => s.push_str("\\page\n"), + } + } + s.push('}'); + s +} diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs new file mode 100644 index 0000000000..d1d074ac61 --- /dev/null +++ b/crates/export/src/layout.rs @@ -0,0 +1,468 @@ +use std::collections::HashMap; + +/// A paragraph of a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Block { + pub text: String, + /// [x0, y0, x1, y1] on the displayed page in points (y up). + pub rect: [f64; 4], + /// Font size in points. + pub size: f64, + pub bold: bool, + pub italic: bool, + /// Text fill colour as sRGB components in 0..=1 (black when unknown). + pub color: [f64; 3], + /// Counterclockwise baseline angle in user space (degrees). + pub rotation: f64, + /// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`, + /// which `text` leaves out. A block that continues this one on its line is a run of the same + /// paragraph, and these say whether a space separates them (#4158). + pub space_around: [bool; 2], +} + +/// An image of a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + /// "png" or "jpg". + pub ext: &'static str, + /// Shared: a picture drawn in many places is held once. + pub bytes: std::sync::Arc<[u8]>, + pub rect: [f64; 4], + /// Width and height before rotation, in points. + pub size: [f64; 2], + /// Counterclockwise rotation in user space (degrees). + pub rotation: f64, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Page { + pub width: f64, + pub height: f64, + pub blocks: Vec, + pub images: Vec, + /// The colour the whole page is painted before its content (a page background), as sRGB + /// components in 0..=1; `None` for a page with no background of its own. + pub background: Option<[f64; 3]>, +} + +/// One cell of a detected table. `span` is how many grid columns the cell covers (a header +/// row that stretches over sub-columns). +#[derive(Clone, Debug, PartialEq)] +pub struct Cell { + pub text: String, + pub size: f64, + pub bold: bool, + pub italic: bool, + /// Text fill colour as sRGB components in 0..=1. + pub color: [f64; 3], + pub span: usize, +} + +/// A table detected from the page's text layout: rows of cells, and the left edge of each +/// grid column (user space, y up) so the writers can size the columns. +#[derive(Clone, Debug, PartialEq)] +pub struct Table { + pub rect: [f64; 4], + pub cols: Vec, + pub rows: Vec>, +} + +/// What goes into the output, in order. +pub(crate) enum Item<'a> { + /// A paragraph's runs (at least one) and its heading level. + Para(Vec>, u8), + /// Owned: tables are detected per page inside [`items`], so they cannot borrow. + Table(Table), + Img(&'a Image), + PageBreak, +} + +#[cfg(test)] +thread_local! { + /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant. + pub(crate) static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` entries read by a lookup (tests only). +#[inline] +pub(crate) fn count_lookup_steps(_n: usize) { + #[cfg(test)] + LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n))); +} + +/// The body text size: the size most of the document's characters are set in. +pub(crate) fn body_size(pages: &[Page]) -> f64 { + // Sizes in first-seen order (which breaks ties), and where each one is in it. + let mut by: Vec<(i64, usize)> = Vec::new(); + let mut slot: HashMap = HashMap::new(); + for b in pages.iter().flat_map(|p| &p.blocks) { + let k = (b.size * 2.0).round() as i64; + count_lookup_steps(1); + let i = *slot.entry(k).or_insert_with(|| { + by.push((k, 0)); + by.len() - 1 + }); + if let Some(e) = by.get_mut(i) { + e.1 = e.1.saturating_add(b.text.len()); + } + } + by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) +} + +/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text. +fn level(b: &Block, body: f64) -> u8 { + let short = b.text.len() < 200; + if short && b.size >= body * 1.6 { + 1 + } else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) { + 2 + } else { + 0 + } +} + +/// How far two cell left edges may drift (points) and still be the same grid column. +const COL_TOL: f64 = 4.0; + +/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or +/// more columns doesn't open. It also bounds a table's size: every row is materialized to all +/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make +/// (blocks / 2)² cells. +pub(crate) const MAX_COLS: usize = 63; + +/// Two blocks in a row this close (as a fraction of their font size) are one line of running +/// text that changed font or colour, such as a link inside a sentence, not two table cells. +const INLINE_GAP: f64 = 0.5; + +/// How many blocks ending near a block's start are looked at to find the one it continues; a +/// page laid out so that many end at the same place stays quick. +const CONTINUATION_PROBE: usize = 64; + +/// Per block, whether it continues another on the same line: it starts where that one ends (up to +/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running +/// on (a link inside a sentence, a superscript), not a cell of its own. +fn continuations(blocks: &[Block]) -> Vec { + continued_from(blocks).into_iter().map(|from| from.is_some()).collect() +} + +/// Per block, the block it continues on the same line (see [`continuations`]): of those it could +/// continue, the one that ends nearest to where it starts. +fn continued_from(blocks: &[Block]) -> Vec> { + let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); + by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); + blocks + .iter() + .enumerate() + .map(|(bi, b)| { + let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); + by_end + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take(CONTINUATION_PROBE) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) + .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) + .map(|(ai, _)| ai) + }) + .collect() +} + +/// A gap between two runs wider than this (as a fraction of the font size) is a word space the +/// text left out. It is the gap the reading-block grouping uses for the same purpose. +const WORD_GAP: f64 = 0.12; + +/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space +/// before the next run when the source had one there or the page shows a gap, but the text has +/// none). +pub(crate) struct Run<'a> { + pub(crate) block: &'a Block, + pub(crate) text: String, +} + +/// A page's paragraphs, each a list of runs from left to right along its line. A block that +/// continues another on the same line ([`continued_from`]: an italic case name, a link, a +/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its +/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join +/// nothing. Paragraphs come in the order of their first runs in `blocks`. +pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { + let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); + let from = continued_from(blocks); + // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s + // order, NaN included), so that one is visited first and has its paragraph already, and the + // links cannot form a cycle. + let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); + order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + // Each block's paragraph, named by its first block, and each paragraph's blocks in order. + let mut head: Vec = (0..blocks.len()).collect(); + let mut members: Vec> = vec![Vec::new(); blocks.len()]; + for &(x0, bi) in &order { + let Some(b) = blocks.get(bi) else { continue }; + let joined = from + .get(bi) + .copied() + .flatten() + .filter(|ai| !skipped(*ai)) + .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) + .and_then(|ai| head.get(ai).copied()); + let h = joined.unwrap_or(bi); + if let Some(slot) = head.get_mut(bi) { + *slot = h; + } + if let Some(m) = members.get_mut(h) { + m.push(bi); + } + } + members + .into_iter() + .filter(|m| !m.is_empty()) + .map(|m| { + let mut runs: Vec> = Vec::with_capacity(m.len()); + for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { + if let Some(prev) = runs.last_mut() + && !prev.text.ends_with(char::is_whitespace) + && !b.text.starts_with(char::is_whitespace) + && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) + { + prev.text.push(' '); + } + runs.push(Run { block: b, text: b.text.clone() }); + } + runs + }) + .collect() +} + +/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold +/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run +/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. +fn para_level(runs: &[Run], body: f64) -> u8 { + let Some(first) = runs.first() else { return 0 }; + let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; + runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) +} + +/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. +fn is_cell_like(b: &Block) -> bool { + (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() +} + +/// Detect tables in a page's blocks from their text layout: columns are left edges shared by +/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, +/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). +pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { + let mut consumed = vec![false; blocks.len()]; + // Candidate columns: cluster the left edges of cell-like blocks. + let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); + xs.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) + for (x, i) in xs { + match cols.last_mut() { + Some((e, m)) if (x - *e).abs() <= COL_TOL => { + *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; + m.push(i); + } + _ => cols.push((x, vec![i])), + } + } + let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); + if cols.len() < 2 { + return (Vec::new(), consumed); + } + // Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks. + let mut col_of: Vec> = vec![None; blocks.len()]; + for (ci, (_, members)) in cols.iter().enumerate() { + for &i in members { + if let Some(c) = col_of.get_mut(i) { + *c = Some(ci); + } + } + } + // The column whose edge is within tolerance of `x` (edges are in ascending order). + let column_at = |x: f64| -> Option { + let k = cols.partition_point(|(e, _)| *e < x - COL_TOL); + cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k) + }; + // Assign every cell-like block to its nearest column (within tolerance). + let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom + let mut order: Vec = (0..blocks.len()).collect(); + order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); + for &bi in &order { + let b = &blocks[bi]; + if !is_cell_like(b) { + continue; + } + let Some(ci) = column_at(b.rect[0]) else { continue }; + let joins = rows + .last() + .and_then(|row| row.first()) + .map(|(_, fi)| { + let rb = &blocks[*fi]; + let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); + overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) + }) + .unwrap_or(false); + if joins { + if let Some(row) = rows.last_mut() { + row.push((ci, bi)); + } + } else { + rows.push(vec![(ci, bi)]); + } + } + // Runs of consecutive rows that have two or more distinct columns are tables. + let mut out = Vec::new(); + let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { + if run.len() < 2 { + run.clear(); + return; + } + let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); + used.sort_unstable(); + used.dedup(); + let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); + edges.sort_by(f64::total_cmp); + edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); + let ncols = edges.len(); + let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS; + if !ok { + run.clear(); + return; + } + let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL; + let mut rows_out = Vec::new(); + let mut slots: Vec)>> = vec![None; ncols]; + for row in run.drain(..) { + for slot in slots.iter_mut() { + *slot = None; + } + for (_ci, bi) in row { + let b = &blocks[bi]; + let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; + let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; + let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; + match slots.get_mut(slot) { + Some(Some((c, sources))) => { + c.text.push(' '); + c.text.push_str(&cell.text); + sources.push(bi); + } + Some(empty @ None) => *empty = Some((cell, vec![bi])), + None => continue, + } + } + let mut materialized = Vec::with_capacity(ncols); + let mut column = 0; + while column < ncols { + match slots.get_mut(column).and_then(Option::take) { + Some((mut cell, sources)) => { + // A spanning cell can skip later slots. Only consume source blocks + // when their text is emitted, leaving covered text in paragraph flow. + for bi in sources { + if let Some(taken) = consumed.get_mut(bi) { + *taken = true; + } + } + cell.span = cell.span.min(ncols - column).max(1); + column += cell.span; + materialized.push(cell); + } + None => { + column += 1; + materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); + } + } + } + while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) { + materialized.pop(); + } + rows_out.push(materialized); + } + let rect = [ + edges[0], + used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min), + right, + used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max), + ]; + out.push(Table { rect, cols: edges, rows: rows_out }); + }; + // Runs of consecutive multi-column rows are tables. Rows on either side of a run that + // hold a single cell wide enough to span two columns (a header band, a totals row) join + // the table; other single-column rows (captions, following text) stay paragraphs. + let spans_run = |edges: &[f64], bi: usize| -> bool { + let b = &blocks[bi]; + edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL) + }; + let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { + let mut es: Vec = + run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); + es.sort_by(f64::total_cmp); + es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); + es + }; + let cont = continuations(blocks); + let qual: Vec = rows + .iter() + .map(|row| { + // A block that continues another adds no column of its own, and the cells of a table row + // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). + let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); + let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); + spans.sort_by(|a, b| a[0].total_cmp(&b[0])); + let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); + let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); + cs.sort_unstable(); + cs.dedup(); + apart && cs.len() >= 2 + }) + .collect(); + let mut i = 0; + while i < rows.len() { + if !qual[i] { + i += 1; + continue; + } + let start = i; + while i < rows.len() && qual[i] { + i += 1; + } + let end = i; // run = rows[start..end] + let mut pieces: Vec> = Vec::new(); + if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) { + pieces.push(rows[start - 1].clone()); + } + pieces.extend(rows[start..end].iter().cloned()); + if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) { + pieces.push(rows[end].clone()); + } + flush(&mut pieces, &mut out, &mut consumed); + } + (out, consumed) +} + +pub(crate) fn items(pages: &[Page]) -> Vec> { + let body = body_size(pages); + let mut out = Vec::new(); + for (i, p) in pages.iter().enumerate() { + if i > 0 { + out.push(Item::PageBreak); + } + let (tables, consumed) = tables(&p.blocks); + // Blocks and images by their top edge, top to bottom. + // A paragraph sits where its first run does: its other runs follow along the same line. + let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + .into_iter() + .filter_map(|runs| { + let first = runs.first()?.block; + let lvl = para_level(&runs, body); + Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) + }) + .collect(); + parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); + parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); + parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); + out.extend(parts.into_iter().map(|x| x.2)); + } + out +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 30299b2d76..db53ee59c8 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1,8 +1,10 @@ -//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF (L4). +//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF, Markdown (L4). //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing -//! document: headings are the paragraphs set larger than the body text. Word keeps pages with +//! document: headings are the paragraphs set larger than the body text, and a block that +//! continues another on its line (an italic word, a link) is a run of that block's paragraph. +//! Word keeps pages with //! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable //! shapes to avoid reflowing overlays; other pages flow with images inline. @@ -14,1343 +16,28 @@ mod zip; pub use pptx::pptx; pub use zip::Zip; -/// A paragraph of a page. -#[derive(Clone, Debug, PartialEq)] -pub struct Block { - pub text: String, - /// [x0, y0, x1, y1] on the displayed page in points (y up). - pub rect: [f64; 4], - /// Font size in points. - pub size: f64, - pub bold: bool, - pub italic: bool, - /// Text fill colour as sRGB components in 0..=1 (black when unknown). - pub color: [f64; 3], - /// Counterclockwise baseline angle in user space (degrees). - pub rotation: f64, -} +mod as_docx; +mod as_html; +mod as_markdown; +mod as_rtf; +mod layout; -/// An image of a page. -#[derive(Clone, Debug, PartialEq)] -pub struct Image { - /// "png" or "jpg". - pub ext: &'static str, - pub bytes: Vec, - pub rect: [f64; 4], - /// Width and height before rotation, in points. - pub size: [f64; 2], - /// Counterclockwise rotation in user space (degrees). - pub rotation: f64, -} - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Page { - pub width: f64, - pub height: f64, - pub blocks: Vec, - pub images: Vec, -} - -/// One cell of a detected table. `span` is how many grid columns the cell covers (a header -/// row that stretches over sub-columns). -#[derive(Clone, Debug, PartialEq)] -pub struct Cell { - pub text: String, - pub size: f64, - pub bold: bool, - pub italic: bool, - /// Text fill colour as sRGB components in 0..=1. - pub color: [f64; 3], - pub span: usize, -} - -/// A table detected from the page's text layout: rows of cells, and the left edge of each -/// grid column (user space, y up) so the writers can size the columns. -#[derive(Clone, Debug, PartialEq)] -pub struct Table { - pub rect: [f64; 4], - pub cols: Vec, - pub rows: Vec>, -} - -/// What goes into the output, in order. -enum Item<'a> { - Para(&'a Block, u8), - /// Owned: tables are detected per page inside [`items`], so they cannot borrow. - Table(Table), - Img(&'a Image), - PageBreak, -} - -/// The body text size: the size most of the document's characters are set in. -fn body_size(pages: &[Page]) -> f64 { - let mut by: Vec<(i64, usize)> = Vec::new(); - for b in pages.iter().flat_map(|p| &p.blocks) { - let k = (b.size * 2.0).round() as i64; - match by.iter_mut().find(|e| e.0 == k) { - Some(e) => e.1 += b.text.len(), - None => by.push((k, b.text.len())), - } - } - by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0) -} - -/// Heading level for a block: 1 for ≥ 1.6× the body size, 2 for ≥ 1.25×, 0 for body text. -fn level(b: &Block, body: f64) -> u8 { - let short = b.text.len() < 200; - if short && b.size >= body * 1.6 { - 1 - } else if short && (b.size >= body * 1.25 || (b.bold && b.size >= body && b.text.len() < 80 && !b.text.ends_with('.'))) { - 2 - } else { - 0 - } -} - -/// How far two cell left edges may drift (points) and still be the same grid column. -const COL_TOL: f64 = 4.0; -/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or -/// more columns doesn't open. It also bounds a table's size: every row is materialized to all -/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make -/// (blocks / 2)² cells. -const MAX_COLS: usize = 63; -/// Two blocks in a row this close (as a fraction of their font size) are one line of running -/// text that changed font or colour, such as a link inside a sentence, not two table cells. -const INLINE_GAP: f64 = 0.5; -/// How many blocks ending near a block's start are looked at to find the one it continues; a -/// page laid out so that many end at the same place stays quick. -const CONTINUATION_PROBE: usize = 64; -/// Per block, whether it continues another on the same line: it starts where that one ends (up to -/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running -/// on (a link inside a sentence, a superscript), not a cell of its own. -fn continuations(blocks: &[Block]) -> Vec { - let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); - by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); - blocks - .iter() - .enumerate() - .map(|(bi, b)| { - let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); - by_end - .get(k..) - .unwrap_or_default() - .iter() - .take_while(|(end, _)| *end <= b.rect[0] + 1.0) - .take(CONTINUATION_PROBE) - .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi)) - .any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) - }) - .collect() -} -/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. -fn is_cell_like(b: &Block) -> bool { - (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() -} - -/// Detect tables in a page's blocks from their text layout: columns are left edges shared by -/// several short blocks, rows are blocks whose vertical spans overlap. Returns the tables and, -/// per block, whether a table consumed it (so `items` can drop it from the paragraph flow). -pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { - let mut consumed = vec![false; blocks.len()]; - // Candidate columns: cluster the left edges of cell-like blocks. - let mut xs: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(_, b)| is_cell_like(b)).map(|(i, b)| (b.rect[0], i)).collect(); - xs.sort_by(|a, b| a.0.total_cmp(&b.0)); - let mut cols: Vec<(f64, Vec)> = Vec::new(); // (left edge, member block indices) - for (x, i) in xs { - match cols.last_mut() { - Some((e, m)) if (x - *e).abs() <= COL_TOL => { - *e = (*e * m.len() as f64 + x) / (m.len() + 1) as f64; - m.push(i); - } - _ => cols.push((x, vec![i])), - } - } - let cols: Vec<(f64, Vec)> = cols.into_iter().filter(|(_, m)| m.len() >= 2).collect(); - if cols.len() < 2 { - return (Vec::new(), consumed); - } - // Each block's column (an index into `cols`), so lookups stay linear on pages with many blocks. - let mut col_of: Vec> = vec![None; blocks.len()]; - for (ci, (_, members)) in cols.iter().enumerate() { - for &i in members { - if let Some(c) = col_of.get_mut(i) { - *c = Some(ci); - } - } - } - // The column whose edge is within tolerance of `x` (edges are in ascending order). - let column_at = |x: f64| -> Option { - let k = cols.partition_point(|(e, _)| *e < x - COL_TOL); - cols.get(k).filter(|(e, _)| (*e - x).abs() <= COL_TOL).map(|_| k) - }; - // Assign every cell-like block to its nearest column (within tolerance). - let mut rows: Vec> = Vec::new(); // (column index, block index), top to bottom - let mut order: Vec = (0..blocks.len()).collect(); - order.sort_by(|a, b| blocks[*b].rect[3].total_cmp(&blocks[*a].rect[3]).then(blocks[*a].rect[0].total_cmp(&blocks[*b].rect[0]))); - for &bi in &order { - let b = &blocks[bi]; - if !is_cell_like(b) { - continue; - } - let Some(ci) = column_at(b.rect[0]) else { continue }; - let joins = rows - .last() - .and_then(|row| row.first()) - .map(|(_, fi)| { - let rb = &blocks[*fi]; - let overlap = rb.rect[3].min(b.rect[3]) - rb.rect[1].max(b.rect[1]); - overlap > 0.5 * (rb.rect[3] - rb.rect[1]).min(b.rect[3] - b.rect[1]) - }) - .unwrap_or(false); - if joins { - if let Some(row) = rows.last_mut() { - row.push((ci, bi)); - } - } else { - rows.push(vec![(ci, bi)]); - } - } - // Runs of consecutive rows that have two or more distinct columns are tables. - let mut out = Vec::new(); - let flush = |run: &mut Vec>, out: &mut Vec
, consumed: &mut Vec| { - if run.len() < 2 { - run.clear(); - return; - } - let mut used: Vec = run.iter().flatten().map(|(_, i)| *i).collect(); - used.sort_unstable(); - used.dedup(); - let mut edges: Vec = used.iter().filter_map(|&i| col_of.get(i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - edges.sort_by(|a, b| a.total_cmp(b)); - edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); - let ncols = edges.len(); - let ok = (ncols >= 3 || (ncols == 2 && run.len() >= 3)) && ncols <= MAX_COLS; - if !ok { - run.clear(); - return; - } - let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL; - let mut rows_out = Vec::new(); - let mut slots: Vec> = vec![None; ncols]; - for row in run.drain(..) { - for slot in slots.iter_mut() { - *slot = None; - } - for (_ci, bi) in row { - let b = &blocks[bi]; - let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; - let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; - let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; - match &mut slots[slot] { - Some(c) => { - c.text.push(' '); - c.text.push_str(&cell.text); - } - None => slots[slot] = Some(cell), - } - consumed[bi] = true; - } - let mut materialized = Vec::with_capacity(ncols); - let mut column = 0; - while column < ncols { - match slots[column].take() { - Some(mut cell) => { - cell.span = cell.span.min(ncols - column).max(1); - column += cell.span; - materialized.push(cell); - } - None => { - column += 1; - materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); - } - } - } - while materialized.last().is_some_and(|cell| cell.text.is_empty()) && materialized.iter().any(|cell| cell.span > 1) { - materialized.pop(); - } - rows_out.push(materialized); - } - let rect = [ - edges[0], - used.iter().map(|&i| blocks[i].rect[1]).fold(f64::MAX, f64::min), - right, - used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max), - ]; - out.push(Table { rect, cols: edges, rows: rows_out }); - }; - // Runs of consecutive multi-column rows are tables. Rows on either side of a run that - // hold a single cell wide enough to span two columns (a header band, a totals row) join - // the table; other single-column rows (captions, following text) stay paragraphs. - let spans_run = |edges: &[f64], bi: usize| -> bool { - let b = &blocks[bi]; - edges.iter().any(|e| (*e - b.rect[0]).abs() <= COL_TOL) && edges.iter().any(|e| *e > b.rect[0] + COL_TOL && *e < b.rect[2] - COL_TOL) - }; - let run_edges = |run: &[Vec<(usize, usize)>]| -> Vec { - let mut es: Vec = - run.iter().flatten().filter_map(|(_, i)| col_of.get(*i).copied().flatten()).filter_map(|ci| cols.get(ci)).map(|c| c.0).collect(); - es.sort_by(|a, b| a.total_cmp(b)); - es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); - es - }; - let cont = continuations(blocks); - let qual: Vec = rows - .iter() - .map(|row| { - // A block that continues another adds no column of its own, and the cells of a table row - // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). - let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); - let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); - spans.sort_by(|a, b| a[0].total_cmp(&b[0])); - let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); - let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); - cs.sort_unstable(); - cs.dedup(); - apart && cs.len() >= 2 - }) - .collect(); - let mut i = 0; - while i < rows.len() { - if !qual[i] { - i += 1; - continue; - } - let start = i; - while i < rows.len() && qual[i] { - i += 1; - } - let end = i; // run = rows[start..end] - let mut pieces: Vec> = Vec::new(); - if start > 0 && rows[start - 1].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[start - 1][0].1) { - pieces.push(rows[start - 1].clone()); - } - pieces.extend(rows[start..end].iter().cloned()); - if end < rows.len() && rows[end].len() == 1 && spans_run(&run_edges(&rows[start..end]), rows[end][0].1) { - pieces.push(rows[end].clone()); - } - flush(&mut pieces, &mut out, &mut consumed); - } - (out, consumed) -} - -fn items(pages: &[Page]) -> Vec> { - let body = body_size(pages); - let mut out = Vec::new(); - for (i, p) in pages.iter().enumerate() { - if i > 0 { - out.push(Item::PageBreak); - } - let (tables, consumed) = tables(&p.blocks); - // Blocks and images by their top edge, top to bottom. - let mut parts: Vec<(f64, f64, Item)> = p - .blocks - .iter() - .enumerate() - .filter(|(bi, _)| !consumed.get(*bi).copied().unwrap_or(false)) - .map(|(_, b)| (b.rect[3], b.rect[0], Item::Para(b, level(b, body)))) - .collect(); - parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); - parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); - parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); - out.extend(parts.into_iter().map(|x| x.2)); - } - out -} - -/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab, -/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them -/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72). -fn allowed(c: char) -> bool { - matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}') -} - -pub(crate) fn esc(s: &str) -> String { - let mut o = String::with_capacity(s.len()); - for c in s.chars().filter(|c| allowed(*c)) { - match c { - '&' => o.push_str("&"), - '<' => o.push_str("<"), - '>' => o.push_str(">"), - '"' => o.push_str("""), - c => o.push(c), - } - } - o -} - -fn base64(data: &[u8]) -> String { - const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut s = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - s.push(A[(n >> 18) as usize & 63] as char); - s.push(A[(n >> 12) as usize & 63] as char); - s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); - s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); - } - s -} - -/// One HTML file: headings and paragraphs, tables as real `
` elements, images inline, a -/// rule between pages. -pub fn html(pages: &[Page], title: &str) -> String { - let mut s = format!( - "\n\n\n\n{}\n\n\n\n", - esc(title) - ); - for it in items(pages) { - match it { - Item::Para(b, lvl) => { - let mut t = esc(&b.text); - if b.italic { - t = format!("{t}"); - } - if b.bold && lvl == 0 { - t = format!("{t}"); - } - let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); - match lvl { - 0 => s.push_str(&format!("{t}

\n")), - l => s.push_str(&format!("{t}\n")), - } - } - Item::Table(t) => { - s.push_str("
\n"); - for row in &t.rows { - s.push_str(""); - for c in row { - let mut body = esc(&c.text); - if c.italic { - body = format!("{body}"); - } - if c.bold { - body = format!("{body}"); - } - let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); - s.push_str(&format!("{body}")); - } - s.push_str("\n"); - } - s.push_str("
\n"); - } - Item::Img(im) => { - let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" }; - s.push_str(&format!("

\"\"

\n", base64(&im.bytes))); - } - Item::PageBreak => s.push_str("
\n"), - } - } - s.push_str("\n\n"); - s -} - -/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). -pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { - // The value is clamped to 0..=255 before the cast, so it cannot truncate. - c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) -} - -/// The colour as `RRGGBB`, or `None` for black (the default every format already uses). -pub(crate) fn hex(c: [f64; 3]) -> Option { - let [r, g, b] = rgb8(c); - (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) -} - -/// A formatted text run (shared by paragraphs and table cells). -fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { - let mut rpr = String::new(); - if bold { - rpr.push_str(""); - } - if italic { - rpr.push_str(""); - } - if let Some(h) = hex(color) { - rpr.push_str(&format!("")); - } - let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; - rpr.push_str(&format!("")); - format!("{rpr}{}", esc(text)) -} - -/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized -/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes. -fn docx_table(t: &Table) -> String { - let mut edges = t.cols.clone(); - edges.push(t.rect[2]); - let widths: Vec = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect(); - let mut s = String::from(""); - for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] { - s.push_str(&format!("")); - } - s.push_str(""); - for w in &widths { - s.push_str(&format!("")); - } - s.push_str(""); - for row in &t.rows { - s.push_str(""); - for c in row { - let span = if c.span > 1 { format!("", c.span) } else { String::new() }; - let w: i64 = widths.iter().take(c.span.min(widths.len())).sum(); - s.push_str(&format!( - "{span}{}", - run_xml(&c.text, c.size, c.bold, c.italic, c.color) - )); - } - // Pad the grid so every row covers all columns (Word rejects short rows). - let used: usize = row.iter().map(|c| c.span).sum(); - for _ in used..t.cols.len() { - s.push_str(""); - } - s.push_str(""); - } - s.push_str(""); - s -} - -/// Pages with rotated text, or text that actually overlaps an image or an oversized -/// (watermark-like) text block, cannot be put in paragraph flow without changing their layout. -/// Keep those pages as editable, absolutely positioned text boxes. Every other page, including -/// one with a logo or a big title that sits clear of the text, flows as paragraphs and tables. -fn positioned(page: &Page) -> bool { - if page.blocks.iter().any(|b| b.rotation.abs() > 0.1) { - return true; - } - let body = body_size(std::slice::from_ref(page)).max(1.0); - let overlays = page - .images - .iter() - .map(|im| (im.rect, None)) - .chain(page.blocks.iter().enumerate().filter(|(_, b)| b.size >= 2.0 * body).map(|(i, b)| (b.rect, Some(i)))); - // Bound the pairwise test on hostile pages (many overlays times many blocks): past the - // budget, keep the default flowing layout. - let mut budget: usize = 1_000_000; - for (r, own) in overlays { - for (i, b) in page.blocks.iter().enumerate() { - if budget == 0 { - return false; - } - budget -= 1; - if Some(i) != own && overlaps(r, b.rect) { - return true; - } - } - } - false -} - -/// Whether two [x0, y0, x1, y1] boxes share more than a hairline (0.5 pt each way); boxes that -/// only touch, or hostile non-finite coordinates, do not count. -fn overlaps(a: [f64; 4], b: [f64; 4]) -> bool { - let w = a[2].min(b[2]) - a[0].max(b[0]); - let h = a[3].min(b[3]) - a[1].max(b[1]); - w.is_finite() && h.is_finite() && w > 0.5 && h > 0.5 -} - -/// Bound every value written into a shape's style, including hostile PDF coordinates. -fn layout_number(n: f64) -> f64 { - if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 } -} - -/// Most images a Word export embeds, and most image bytes (the zip writer has 32-bit sizes and -/// a 16-bit entry count; a hostile PDF must not be able to overflow them). -const MAX_MEDIA: usize = 1000; -const MAX_MEDIA_BYTES: usize = 512 << 20; - -/// Whether `im` can still be embedded: it has data, and the package stays within its limits. -fn room_for(media: &[(String, &Image)], im: &Image) -> bool { - !im.bytes.is_empty() - && media.len() < MAX_MEDIA - && media.iter().map(|(_, m)| m.bytes.len()).fold(im.bytes.len(), usize::saturating_add) <= MAX_MEDIA_BYTES -} - -/// Points as EMU (12700 per point); non-finite values are 0. -fn emu(pt: f64) -> i64 { - if pt.is_finite() { (pt * 12700.0).round() as i64 } else { 0 } -} - -/// The `a:graphic` of one picture (DrawingML), rotated by `rotation` degrees when given. -fn picture_xml(n: usize, name: &str, (cx, cy): (i64, i64), rotation: Option) -> String { - let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64)); - format!( - "\ -\ -\ -\ -\ -" - ) -} - -fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String { - // One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a - // page-sized watermark cannot push the invoice footer onto another page. - let mut s = String::from(""); - let body = body_size(std::slice::from_ref(page)).max(1.0); - for b in &page.blocks { - *shape_id = shape_id.saturating_add(1); - let rotation = layout_number(-b.rotation).rem_euclid(360.0); - let (sin, cos) = rotation.to_radians().sin_cos(); - let (bw, bh) = (layout_number(b.rect[2] - b.rect[0]).max(1.0), layout_number(b.rect[3] - b.rect[1]).max(1.0)); - let h = if b.rotation.abs() > 0.1 { layout_number(b.size).max(1.0) } else { bh }; - // Recover the unrotated width from the axis-aligned box. Use the larger axis so - // quarter turns and 45-degree watermarks do not divide by (nearly) zero. - let w = if b.rotation.abs() <= 0.1 { - bw - } else if cos.abs() >= sin.abs() { - (bw - h * sin.abs()) / cos.abs() - } else { - (bh - h * cos.abs()) / sin.abs() - }; - let w = layout_number(w).max(1.0); - let (x, y) = (layout_number(b.rect[0] + (bw - w) / 2.0), layout_number(page.height - b.rect[3] + (bh - h) / 2.0)); - let z = if b.size >= 2.0 * body { -2 } else { 1 }; - let style = format!( - "position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page" - ); - s.push_str(&format!( - "{}", - shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color) - )); - } - for im in &page.images { - if !room_for(media, im) { - continue; - } - *shape_id = shape_id.saturating_add(1); - let n = media.len().saturating_add(1); - let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0)); - let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0)); - let rotation = layout_number(-im.rotation).rem_euclid(360.0); - let name = format!("image{n}.{}", im.ext); - let pic = picture_xml(n, &name, (emu(w), emu(h)), Some(rotation)); - // A DrawingML anchor, the way Word itself stores a picture placed behind text: a VML - // rectangle with an image does not show the picture. - s.push_str(&format!( - "\ -{}{}\ -", - n.saturating_add(1), - emu(x), - emu(y), - emu(w), - emu(h), - )); - s.push_str(&pic); - s.push_str(""); - media.push((name, im)); - } - s.push_str(""); - s -} - -fn word_page_size(page: &Page) -> (i64, i64) { - let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 }; - (twips(page.width), twips(page.height)) -} - -fn section(page: &Page) -> String { - let (pw, ph) = word_page_size(page); - let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440)); - format!( - "", - my / 2, - my / 2 - ) -} - -/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; -/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. -pub fn docx(pages: &[Page], title: &str) -> Vec { - let mut body = String::new(); - let mut media: Vec<(String, &Image)> = Vec::new(); - // Word needs a paragraph between adjacent tables and after the last one in the body. - let mut after_table = false; - let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic, b.color); - let mut shape_id = 0usize; - for (page_index, page) in pages.iter().enumerate() { - let (pw, _) = word_page_size(page); - let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0; - if positioned(page) { - body.push_str(&positioned_page(page, &mut media, &mut shape_id)); - after_table = false; - } else { - for it in items(std::slice::from_ref(page)) { - match &it { - Item::Para(b, lvl) => { - let style = match lvl { - 1 => "", - 2 => "", - _ => "", - }; - body.push_str(&format!("{style}{}", run(b))); - } - Item::Table(t) => { - if after_table { - body.push_str(""); - } - body.push_str(&docx_table(t)); - } - Item::Img(im) => { - if room_for(&media, im) { - let n = media.len() + 1; - let name = format!("image{n}.{}", im.ext); - // Size on the page, at most the text width. - let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0)); - let k = (text_w / w).min(1.0); - let (cx, cy) = (emu(w * k), emu(h * k)); - body.push_str(&format!("")); - body.push_str(&picture_xml(n, &name, (cx, cy), None)); - body.push_str(""); - media.push((name, im)); - } - } - Item::PageBreak => body.push_str(""), - } - after_table = matches!(it, Item::Table(_)); - } - } - // Section breaks carry this source page's size and create exactly one next page. - // The last section must be a direct child of the body (OOXML 17.6.17). - if page_index + 1 < pages.len() { - body.push_str(&format!( - "{}", - section(page) - )); - } else { - if after_table || page.blocks.is_empty() && page.images.is_empty() { - body.push_str(""); - } - body.push_str(§ion(page)); - } - after_table = false; - } - let doc = format!( - "\n\ -{body}\ -" - ); - let mut types = String::from( - "\n\ -\ -\ -\ -\ -", - ); - types.push_str(""); - let rels = "\n\ -\ -"; - let mut doc_rels = String::from( - "\n\ -", - ); - for (i, (name, _)) in media.iter().enumerate() { - doc_rels.push_str(&format!( - "", - i + 1 - )); - } - doc_rels.push_str(""); - let styles = "\n\ -\ -\ -\ -"; - let core = format!( - "\n{}", - esc(title) - ); - let mut z = Zip::default(); - z.add("[Content_Types].xml", types.as_bytes(), true); - z.add("_rels/.rels", rels.as_bytes(), true); - z.add("docProps/core.xml", core.as_bytes(), true); - z.add("word/document.xml", doc.as_bytes(), true); - z.add("word/styles.xml", styles.as_bytes(), true); - z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); - for (name, im) in &media { - z.add(&format!("word/media/{name}"), &im.bytes, false); - } - z.finish() -} - -fn rtf_text(s: &str) -> String { - let mut o = String::new(); - for c in s.chars().filter(|c| allowed(*c)) { - match c { - '\\' | '{' | '}' => { - o.push('\\'); - o.push(c); - } - c if (c as u32) < 128 => o.push(c), - c => { - // \uN with a ? fallback; UTF-16 units as signed 16-bit numbers. - let mut buf = [0u16; 2]; - for u in c.encode_utf16(&mut buf) { - o.push_str(&format!("\\u{}?", *u as i16)); - } - } - } - } - o -} - -/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks -/// between pages (images are left out). -pub fn rtf(pages: &[Page]) -> String { - let items = items(pages); - // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. - let mut colors: Vec<[u8; 3]> = Vec::new(); - let mut note = |c: [f64; 3]| { - let c = rgb8(c); - if c != [0, 0, 0] && !colors.contains(&c) { - colors.push(c); - } - }; - for it in &items { - match it { - Item::Para(b, _) => note(b.color), - Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), - Item::Img(_) | Item::PageBreak => {} - } - } - // `\cfN` for a colour (nothing for black, which keeps the default). - let cf = |c: [f64; 3]| { - let c = rgb8(c); - colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) - }; - let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); - if !colors.is_empty() { - s.push_str("{\\colortbl;"); - for [r, g, b] in &colors { - s.push_str(&format!("\\red{r}\\green{g}\\blue{b};")); - } - s.push_str("}\n"); - } - for it in items { - match it { - Item::Para(b, _) => { - let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); - if b.bold { - fmt.push_str("\\b"); - } - if b.italic { - fmt.push_str("\\i"); - } - fmt.push_str(&cf(b.color)); - s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text))); - } - Item::Table(t) => { - let mut edges = t.cols.clone(); - edges.push(t.rect[2]); - let cellx: Vec = edges.iter().skip(1).map(|e| (e * 20.0).round() as i64).collect(); - for row in &t.rows { - s.push_str("\\trowd\\trgaph108"); - // The row's cell boundaries come first: each cell ends at the right edge of - // the last grid column it spans. - let mut column = 0; - for c in row { - column += c.span.max(1); - if let Some(x) = cellx.get(column.min(cellx.len()).saturating_sub(1)) { - s.push_str(&format!("\\cellx{x}")); - } - } - for c in row { - let mut fmt = format!("\\intbl\\fs{}", (c.size * 2.0).round() as i64); - if c.bold { - fmt.push_str("\\b"); - } - if c.italic { - fmt.push_str("\\i"); - } - fmt.push_str(&cf(c.color)); - s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); - } - s.push_str("\\row\n"); - } - } - Item::Img(_) => {} - Item::PageBreak => s.push_str("\\page\n"), - } - } - s.push('}'); - s -} +pub use as_docx::docx; +pub(crate) use as_docx::hex; +#[cfg(test)] +use as_docx::{MAX_MEDIA, docx_table}; +pub(crate) use as_html::esc; +pub use as_html::html; +#[cfg(test)] +use as_html::{allowed, base64}; +pub use as_markdown::markdown; +#[cfg(test)] +use as_markdown::{emphasis, paragraph}; +pub use as_rtf::rtf; +pub use layout::{Block, Cell, Image, Page, Table}; +#[cfg(test)] +use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; +pub(crate) use layout::{Run, paragraphs, tables}; #[cfg(test)] -mod tests { - use super::*; - - fn page() -> Page { - let b = |t: &str, y: f64, size: f64, bold: bool| Block { - text: t.into(), - rect: [72.0, y, 500.0, y + size], - size, - bold, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }; - Page { - width: 612.0, - height: 792.0, - blocks: vec![ - b("Body text after the picture, which is long enough to be the main text size of the page.", 400.0, 11.0, false), - b("Annual Report", 700.0, 24.0, true), - b("Overview", 650.0, 14.0, true), - b("Some text & more of it so that eleven points is the body size here.", 620.0, 11.0, false), - ], - images: vec![Image { - ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(), - rect: [72.0, 450.0, 272.0, 600.0], - size: [200.0, 150.0], - rotation: 0.0, - }], - } - } - - #[test] - fn html_has_headings_paragraphs_and_images_in_order() { - let h = html(&[page(), page()], "Report"); - let order: Vec = ["

Annual Report

", "

Overview

", "

Some <body> text &", "Body text after"] - .iter() - .map(|x| h.find(x).unwrap_or_else(|| panic!("{x} missing in {h}"))) - .collect(); - assert!(order.windows(2).all(|w| w[0] < w[1]), "{order:?}"); - assert_eq!(h.matches("


").count(), 1); - assert_eq!(base64(b"Man"), "TWFu"); - assert_eq!(base64(b"Ma"), "TWE="); - } - - #[test] - fn docx_is_a_word_package() { - let d = docx(&[page()], "Report"); - assert!(d.starts_with(b"PK")); - let names: Vec<&str> = - ["[Content_Types].xml", "word/document.xml", "word/styles.xml", "word/_rels/document.xml.rels", "word/media/image1.png", "_rels/.rels"] - .to_vec(); - for n in names { - assert!(d.windows(n.len()).any(|w| w == n.as_bytes()), "{n}"); - } - } - - #[test] - fn word_does_not_invent_borders_for_aligned_invoice_fields() { - let xml = part(&docx(&[table_page()], "Invoice"), "word/document.xml"); - assert!(xml.contains("")); - assert!(!xml.contains("w:val=\"single\""), "text alignment is not evidence of a border: {xml}"); - assert!(xml.contains("")); - } - - #[test] - fn word_keeps_a_page_with_a_clear_image_and_big_title_in_flow() { - // page() has a picture between paragraphs and a 24 pt title over 11 pt body text, but - // nothing overlaps: it must stay flowing paragraphs with the image inline. - let xml = part(&docx(&[page()], "Report"), "word/document.xml"); - assert!(!xml.contains("position:absolute"), "nothing overlaps, so nothing floats: {xml}"); - assert!(xml.contains(""), "the picture stays inline: {xml}"); - assert!(xml.contains(""), "the title stays a heading: {xml}"); - let (title, body) = (xml.find(">Annual Report"), xml.find(">Body text after the picture")); - assert!(title.is_some() && body.is_some() && title < body, "reading order kept: {xml}"); - // The same page with the picture moved over the body text floats. - let mut over = page(); - over.images[0].rect = [72.0, 380.0, 272.0, 530.0]; - let xml = part(&docx(&[over], "Report"), "word/document.xml"); - assert!(xml.contains("position:absolute"), "an image over text floats: {xml}"); - } - - #[test] - fn word_overlap_does_not_consume_invoice_body_space() { - let mut p = table_page(); - p.blocks.push(Block { - text: "DRAFT".into(), - rect: [100.0, 200.0, 500.0, 700.0], - size: 100.0, - bold: false, - italic: false, - color: [0.8; 3], - rotation: 0.0, - }); - p.images = page().images; - let xml = part(&docx(&[p], "Invoice"), "word/document.xml"); - assert!(xml.contains("position:absolute"), "overlapping text must float: {xml}"); - assert!(!xml.contains(""), "images must not displace invoice text"); - assert!(!xml.contains(""), "the source text boxes must keep their positions"); - assert_eq!(xml.matches(">DRAFT").count(), 1); - assert_eq!(xml.matches("w:type=\"page\"").count(), 0); - assert!(xml.contains("margin-left:72.000pt;margin-top:160.000pt"), "Meter field keeps its source position: {xml}"); - let watermark = xml.split(">DRAFT").next().unwrap().rsplit(""), "{xml}"); - } - - #[test] - fn word_rotated_pages_keep_separate_page_sizes_and_unique_shapes() { - let mut p = table_page(); - p.blocks[0].rotation = 45.0; - let mut second = p.clone(); - second.width = 400.0; - second.height = 500.0; - let xml = part(&docx(&[p, second], "Two invoices"), "word/document.xml"); - assert!(xml.contains("rotation:315.000"), "PDF y-up becomes Word y-down"); - assert_eq!(xml.matches("").count(), 2); - assert!(xml.contains("")); - assert_eq!(xml.matches("id=\"Text1\"").count(), 1); - assert_eq!(xml.matches(">Meter").count(), 2); - } - - /// One part of a package written by [`Zip`], inflated. - pub(crate) fn part(zip: &[u8], name: &str) -> String { - let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); - let mut i = 0; - while zip[i..].starts_with(b"PK\x03\x04") { - let (size, name_len) = (at(i + 18, 4), at(i + 26, 2)); - let data = i + 30 + name_len; - if &zip[i + 30..data] == name.as_bytes() { - let mut s = String::new(); - std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut s).unwrap(); - return s; - } - i = data + size; - } - panic!("{name} missing") - } - - #[test] - fn word_opens_text_with_control_characters_and_long_pages() { - // #72: text extracted without a usable /ToUnicode holds C0 controls, which XML forbids; - // Word then refused the whole file. A till receipt is also taller than Word's 22 in. - let mut p = page(); - p.height = 2400.0; - p.blocks.push(Block { - text: "Total\u{0}\u{3}\u{c} 4.50\u{FFFF}\tGBP".into(), - rect: [72.0, 100.0, 300.0, 110.0], - size: f64::NAN, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }); - let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml"); - assert!(xml.contains(">Total 4.50\tGBP"), "{xml}"); - assert!(!xml.chars().any(|c| !allowed(c))); - assert!(xml.contains(""), "clamped to 22 in: {xml}"); - assert!(xml.contains(""), "a non-finite size falls back to 12 pt"); - assert!(rtf(&[p]).contains("Total 4.50\tGBP")); - // A narrow page keeps room for text: margins shrink. - let narrow = Page { width: 100.0, height: 200.0, ..page() }; - let xml = part(&docx(&[narrow], "Narrow"), "word/document.xml"); - assert!(xml.contains(""), "{xml}"); - assert!(xml.contains(""), "out-of-range values clamp: {xml}"); - // Black text keeps Word's default colour. - assert_eq!(xml.matches("Annual Report"), "{h}"); - assert!(h.contains("

"), "{h}"); - let r = rtf(&[p.clone()]); - assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}"); - assert!(r.contains("\\cf1 Annual Report"), "{r}"); - let ppt = pptx(&[p], "Colour"); - let pxml = part(&ppt, "ppt/slides/slide1.xml"); - assert!(pxml.contains(""), "{pxml}"); - assert!(pxml.contains(""), "out-of-range values clamp: {pxml}"); - // A table cell keeps its colour too. - let mut t = table_page(); - t.blocks[0].color = [1.0, 0.0, 0.0]; - let xml = part(&docx(&[t.clone()], "Grid"), "word/document.xml"); - assert!(xml.contains(""), "{xml}"); - assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\"")); - } - - #[test] - fn rtf_escapes_and_sizes() { - let mut p = page(); - p.blocks.push(Block { - text: "Café {x}".into(), - rect: [72.0, 100.0, 200.0, 110.0], - size: 10.0, - bold: false, - italic: true, - color: [0.0; 3], - rotation: 0.0, - }); - let r = rtf(&[p]); - assert!(r.starts_with("{\\rtf1") && r.ends_with('}')); - assert!(r.contains("\\fs48\\b Annual Report")); - assert!(r.contains("\\fs20\\i Caf\\u233? \\{x\\}"), "{r}"); - } - - /// A 3-column table with a spanning header row and a hole in the last row. - fn table_page() -> Page { - let cell = |t: &str, x: f64, y: f64, w: f64| Block { - text: t.into(), - rect: [x, y, x + w, y + 12.0], - size: 11.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }; - Page { - width: 612.0, - height: 792.0, - blocks: vec![ - cell("Meter", 72.0, 620.0, 120.0), - cell("Unit", 232.0, 620.0, 80.0), - cell("Reading", 392.0, 620.0, 100.0), - cell("A1", 72.0, 590.0, 120.0), - cell("kWh", 232.0, 590.0, 80.0), - cell("37414.00", 392.0, 590.0, 100.0), - cell("A2", 72.0, 560.0, 120.0), - cell("kW", 232.0, 560.0, 80.0), - // A2's row is missing its third cell (an empty cell in the grid). - cell("Totals", 72.0, 530.0, 240.0), - Block { - text: "Notes follow the table and are long enough to be the body text size of the page here.".into(), - rect: [72.0, 460.0, 500.0, 471.0], - size: 11.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }, - ], - images: Vec::new(), - } - } - - #[test] - fn grid_text_becomes_a_real_table_in_every_format() { - let p = table_page(); - let h = html(std::slice::from_ref(&p), "Bill"); - assert!(h.contains(""), "{h}"); - assert!(h.contains(""), "spanning cell: {h}"); - assert_eq!(h.matches("").count(), 4, "{h}"); - assert!(h.contains(""), "the hole is an empty cell: {h}"); - assert!(h.contains("

Notes follow"), "the paragraph after the table stays: {h}"); - - let d = docx(std::slice::from_ref(&p), "Bill"); - assert!(d.starts_with(b"PK")); - assert!(d.windows(b"word/document.xml".len()).any(|w| w == b"word/document.xml")); - let (tables, _) = tables(&p.blocks); - assert_eq!(tables.len(), 1); - let table_xml = docx_table(&tables[0]); - assert!(table_xml.contains("")); - assert!(table_xml.contains(""), "{table_xml}"); - assert_eq!(table_xml.matches("").count(), 4); - - let r = rtf(std::slice::from_ref(&p)); - assert!(r.contains("\\trowd"), "{r}"); - assert_eq!(r.matches("\\row").count(), 4); - // Boundaries precede the cells; "Totals" spans two columns, so it ends at the third edge. - assert!(r.contains("\\trowd\\trgaph108\\cellx4640\\cellx7840\\cellx9920{"), "{r}"); - assert!(r.contains("\\trowd\\trgaph108\\cellx7840{\\intbl\\fs22 Totals}\\cell\\row"), "{r}"); - - let pt = pptx(std::slice::from_ref(&p), "Bill"); - assert!(pt.starts_with(b"PK")); - assert!(pt.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml")); - let slide_xml = part(&pt, "ppt/slides/slide1.xml"); - assert!(slide_xml.contains("")); - assert!(slide_xml.contains("gridSpan=\"2\"")); - assert_eq!(slide_xml.matches(" = (0..n) - .map(|i| { - let (row, col) = (i / 2, i / 2 + i % 2); - let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0); - Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 } - }) - .collect(); - let started = std::time::Instant::now(); - let (tables, _) = tables(&blocks); - assert!(tables.iter().all(|t| t.cols.len() <= MAX_COLS), "{} columns", tables.iter().map(|t| t.cols.len()).max().unwrap_or(0)); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); - } - - #[test] - fn word_opens_tables_up_to_its_column_limit() { - // Rows of short numbers on a regular grid, n columns wide. - let grid = |n: usize| Page { - width: 1500.0, - height: 792.0, - blocks: (0..3) - .flat_map(|row| { - (0..n).map(move |col| { - let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0); - Block { - text: (col + 1).to_string(), - rect: [x, y, x + 8.0, y + 8.0], - size: 8.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - } - }) - }) - .collect(), - images: Vec::new(), - }; - let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(" Block { - Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 } - } - - /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks - /// on one line. They are not table columns, even when several such lines share left edges. - #[test] - fn a_link_inside_a_sentence_is_not_a_table_column() { - let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; - let p = Page { - width: 612.0, - height: 792.0, - blocks: vec![ - block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9), - blue("http", 273.5, 710.3, 294.1, 724.9), - blue("s", 294.1, 709.8, 298.8, 725.8), - blue("://women", 298.8, 710.3, 348.0, 724.9), - blue("g", 348.1, 709.8, 353.7, 725.8), - blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9), - block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9), - block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5), - blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4), - block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5), - block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9), - block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6), - ], - images: Vec::new(), - }; - let h = html(std::slice::from_ref(&p), "Regulations"); - assert!(!h.contains("

Totals
"), "running text became a table: {h}"); - for b in &p.blocks { - assert!(h.contains(&b.text), "{} was lost: {h}", b.text); - } - } - - /// #2806: lines that each run on into a link starting at the same place are sentences, not a - /// two-column table, even three in a row. - #[test] - fn sentences_that_run_on_into_links_are_not_a_table() { - let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; - let mut blocks = Vec::new(); - for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() { - let y = 700.0 - 16.0 * n as f64; - blocks.push(block(lead, 72.0, y, 211.0, y + 12.0)); - blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0)); - } - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; - let h = html(std::slice::from_ref(&p), "Event"); - assert!(!h.contains("
"), "sentences became a table: {h}"); - assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}"); - } - - /// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table - /// around it stays a table. - #[test] - fn a_superscript_beside_a_number_keeps_its_table() { - let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; - for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() { - let y = 680.0 - 20.0 * n as f64; - blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); - blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); - blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); - } - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; - let h = html(std::slice::from_ref(&p), "Prizes"); - assert!(h.contains("
"), "the prize table was lost: {h}"); - assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); - } - - #[test] - fn two_column_body_text_is_not_mistaken_for_a_table() { - let para = |t: &str, x: f64, y: f64| Block { - text: t.into(), - rect: [x, y, x + 200.0, y + 90.0], // nine lines tall: body text, not a cell - size: 11.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }; - let p = Page { - width: 612.0, - height: 792.0, - blocks: vec![ - para("Left column text of the page, long enough to be a flowing paragraph.", 72.0, 500.0), - para("Right column text of the page, long enough to be a flowing paragraph.", 320.0, 500.0), - para("Second left block, still a tall flowing paragraph rather than a cell.", 72.0, 400.0), - para("Second right block, still a tall flowing paragraph rather than a cell.", 320.0, 400.0), - ], - images: Vec::new(), - }; - let h = html(&[p], "Paper"); - assert!(!h.contains("
"), "tall two-column text must stay paragraphs: {h}"); - } - - /// #1936: every picture Word is told to draw (`r:embed`) names a relationship whose target is a - /// file in the package, with a content type; JPEG pictures too, inline or placed. - #[test] - fn word_pictures_resolve_to_media_in_the_package() { - let jpeg = Image { ext: "jpg", bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], ..page().images[0].clone() }; - for placed in [false, true] { - let mut p = page(); - p.images = vec![jpeg.clone()]; - if placed { - p.images[0].rect = [72.0, 640.0, 272.0, 790.0]; - p.blocks[0].rect = [72.0, 700.0, 300.0, 760.0]; - } - let zip = docx(&[p], "T"); - let xml = part(&zip, "word/document.xml"); - assert_eq!(xml.contains(" { - Para(&'a Block), + /// A paragraph's runs (at least one). + Para(Vec>), Table(Table), Img(&'a Image), } @@ -136,11 +137,11 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { .map(|(i, _)| i); let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); - for (bi, b) in p.blocks.iter().enumerate() { - if Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false) { - continue; - } - items.push((b.rect[3], b.rect[0], SlideBodyItem::Para(b))); + // The title is a shape of its own, so it joins no paragraph. + let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); + for runs in crate::paragraphs(&p.blocks, &skip) { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); } for t in tables { items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t))); @@ -173,11 +174,16 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { SlideBodyItem::Para(_) => { let mut paras_xml = String::new(); let mut est_h_pt = 0.0; - while let Some((_, _, SlideBodyItem::Para(b))) = items.get(i) { - let run = run_drawingml(&b.text, b.size, b.bold, b.italic, b.color); - paras_xml.push_str(&format!("{run}")); - let lines = (b.text.len() as f64 / 60.0).ceil().max(1.0); - est_h_pt += (b.size.max(10.0) * 1.3) * lines + 6.0; + while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) { + paras_xml.push_str(""); + for r in runs { + paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); + } + paras_xml.push_str(""); + let len = runs.iter().map(|r| r.text.len()).fold(0usize, usize::saturating_add); + let size = runs.iter().map(|r| r.block.size).fold(10.0, f64::max); + let lines = (len as f64 / 60.0).ceil().max(1.0); + est_h_pt += (size * 1.3) * lines + 6.0; i += 1; } let box_h = height_emu(est_h_pt); @@ -352,381 +358,4 @@ xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" type=\"blank\" } #[cfg(test)] -mod tests { - use super::*; - use crate::tests::part; - - fn sample_page() -> Page { - Page { - width: 720.0, - height: 540.0, - blocks: vec![ - Block { - text: "Q3 Business Update".into(), - rect: [72.0, 460.0, 500.0, 500.0], - size: 28.0, - bold: true, - italic: false, - color: [0.1, 0.2, 0.3], - rotation: 0.0, - }, - Block { - text: "Revenue grew 25% year over year across all regions.".into(), - rect: [72.0, 380.0, 500.0, 410.0], - size: 14.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }, - Block { - text: "Operational expenses remained flat.".into(), - rect: [72.0, 340.0, 500.0, 370.0], - size: 14.0, - bold: false, - italic: true, - color: [0.0; 3], - rotation: 0.0, - }, - ], - images: vec![Image { - ext: "png", - bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(), - rect: [72.0, 150.0, 300.0, 300.0], - size: [228.0, 150.0], - rotation: 0.0, - }], - } - } - - #[test] - pub fn pptx_is_a_presentationml_package() { - let p = sample_page(); - let bytes = pptx(&[p], "Executive Deck"); - assert!(bytes.starts_with(b"PK")); - for part_name in [ - "[Content_Types].xml", - "_rels/.rels", - "docProps/core.xml", - "ppt/presentation.xml", - "ppt/_rels/presentation.xml.rels", - "ppt/slideMasters/slideMaster1.xml", - "ppt/slideMasters/_rels/slideMaster1.xml.rels", - "ppt/slideLayouts/slideLayout1.xml", - "ppt/slideLayouts/_rels/slideLayout1.xml.rels", - "ppt/theme/theme1.xml", - "ppt/slides/slide1.xml", - "ppt/slides/_rels/slide1.xml.rels", - "ppt/media/image1.png", - ] { - assert!(bytes.windows(part_name.len()).any(|w| w == part_name.as_bytes()), "missing {part_name}"); - } - } - - #[test] - pub fn pptx_slide_has_title_body_and_images_in_order() { - let p = sample_page(); - let bytes = pptx(&[p], "Deck"); - let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); - assert!(slide_xml.contains("name=\"Title 2\"")); - assert!(slide_xml.contains("txBox=\"1\"")); - assert!(slide_xml.contains("Q3 Business Update")); - assert!(slide_xml.contains("name=\"TextBox 3\"")); - assert!(slide_xml.contains("Revenue grew 25%")); - assert!(slide_xml.contains("")); - assert!(slide_xml.contains("")); - assert!(!slide_xml.contains("")); - assert!(slide_xml.contains("i=\"1\"")); - } - - /// Every entry of the package in order: (name, uncompressed bytes), read from the local - /// headers the way an unzipper walks them, so a broken structure fails here. - fn entries(zip: &[u8]) -> Vec<(String, Vec)> { - let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); - let mut out = Vec::new(); - let mut i = 0; - while zip[i..].starts_with(b"PK\x03\x04") { - let (method, size, raw, name_len, extra) = (at(i + 8, 2), at(i + 18, 4), at(i + 22, 4), at(i + 26, 2), at(i + 28, 2)); - let data = i + 30 + name_len + extra; - let name = String::from_utf8(zip[i + 30..i + 30 + name_len].to_vec()).expect("UTF-8 name"); - let body = &zip[data..data + size]; - let bytes = match method { - 0 => body.to_vec(), - 8 => { - let mut v = Vec::new(); - std::io::Read::read_to_end(&mut flate2::read::DeflateDecoder::new(body), &mut v).expect("inflates"); - v - } - m => panic!("{name}: unexpected method {m}"), - }; - assert_eq!(bytes.len(), raw, "{name}: uncompressed size"); - out.push((name, bytes)); - i = data + size; - } - assert!(zip[i..].starts_with(b"PK\x01\x02"), "the central directory follows the entries"); - out - } - - /// Checks that every start tag is closed by the matching end tag (text is escaped, so a - /// `<` always starts markup). Catches a typo such as `…`. - fn assert_tags_balance(name: &str, xml: &str) { - let mut stack: Vec<&str> = Vec::new(); - for chunk in xml.split('<').skip(1) { - let tag = chunk.split('>').next().unwrap_or_default(); - if tag.starts_with('?') { - continue; - } - if let Some(close) = tag.strip_prefix('/') { - assert_eq!(stack.pop(), Some(close.trim()), "{name}: closes the wrong element"); - } else if !tag.ends_with('/') { - stack.push(tag.split_whitespace().next().unwrap_or_default()); - } - } - assert!(stack.is_empty(), "{name}: unclosed {stack:?}"); - } - - #[test] - fn pptx_package_parts_are_complete_and_consistent() { - let cell = |t: &str, x: f64, y: f64| Block { - text: t.into(), - rect: [x, y, x + 100.0, y + 14.0], - size: 12.0, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }; - let table_page = Page { - width: 612.0, - height: 792.0, - blocks: vec![ - cell("Metric", 72.0, 400.0), - cell("Value", 200.0, 400.0), - cell("Growth", 72.0, 360.0), - cell("+9%", 200.0, 360.0), - cell("Target", 72.0, 320.0), - cell("100%", 200.0, 320.0), - ], - images: Vec::new(), - }; - let bytes = pptx(&[sample_page(), table_page], "Two"); - let parts = entries(&bytes); - assert_eq!(parts.first().map(|p| p.0.as_str()), Some("[Content_Types].xml"), "content types come first"); - let names: std::collections::HashSet<&str> = parts.iter().map(|p| p.0.as_str()).collect(); - assert_eq!(names.len(), parts.len(), "no duplicate parts"); - for required in [ - "[Content_Types].xml", - "_rels/.rels", - "ppt/presentation.xml", - "ppt/_rels/presentation.xml.rels", - "ppt/slideMasters/slideMaster1.xml", - "ppt/slideMasters/_rels/slideMaster1.xml.rels", - "ppt/slideLayouts/slideLayout1.xml", - "ppt/slideLayouts/_rels/slideLayout1.xml.rels", - "ppt/theme/theme1.xml", - "ppt/slides/slide1.xml", - "ppt/slides/_rels/slide1.xml.rels", - "ppt/slides/slide2.xml", - "ppt/slides/_rels/slide2.xml.rels", - "ppt/media/image1.png", - ] { - assert!(names.contains(required), "missing {required}"); - } - let text = |n: &str| String::from_utf8(parts.iter().find(|p| p.0 == n).map(|p| p.1.clone()).unwrap_or_default()).expect("UTF-8 XML"); - for (name, body) in &parts { - if name.ends_with(".xml") || name.ends_with(".rels") { - assert!(body.starts_with(b""), "the second slide carries the table"); - // Text boxes, not placeholders: the blank layout defines none to inherit from. - for slide in ["ppt/slides/slide1.xml", "ppt/slides/slide2.xml"] { - assert!(!text(slide).contains(" = base.split('/').filter(|s| !s.is_empty()).collect(); - for seg in target.split('/') { - if seg == ".." { - path.pop(); - } else { - path.push(seg); - } - } - let resolved = path.join("/"); - assert!(names.contains(resolved.as_str()), "{rels}: target {target} is missing"); - } - } - // The theme's font scheme carries the schema-required latin, ea and cs faces. - let theme = text("ppt/theme/theme1.xml"); - for font in ["majorFont", "minorFont"] { - let inner = theme.split(&format!("")).nth(1).and_then(|r| r.split(&format!("")).next()).unwrap_or_default(); - assert!(inner.contains(""), "the slide is still written"); - assert!(!slide.contains("y=\"-"), "positions never wrap negative"); - assert!(!slide.contains("")); - let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); - assert!(slide_xml.contains("")); - } - - #[test] - fn pptx_slide_dimensions_and_clamping() { - // Known dimensions: 720x540 pt (10x7.5 in) = 9144000x6858000 EMU - let p = Page { width: 720.0, height: 540.0, ..Page::default() }; - let bytes = pptx(&[p], "Known Dimensions"); - let pres_xml = part(&bytes, "ppt/presentation.xml"); - assert!(pres_xml.contains(""), "{pres_xml}"); - - // Tiny page (10x10 pt) clamps to PowerPoint minimum of 1 inch (914400 EMU) - let tiny = Page { width: 10.0, height: 10.0, ..Page::default() }; - let tiny_bytes = pptx(&[tiny], "Tiny"); - let tiny_pres = part(&tiny_bytes, "ppt/presentation.xml"); - assert!(tiny_pres.contains(""), "{tiny_pres}"); - - // Poster page (10000x10000 pt) clamps to PowerPoint maximum of 56 inches (51206400 EMU) - let poster = Page { width: 10_000.0, height: 10_000.0, ..Page::default() }; - let poster_bytes = pptx(&[poster], "Poster"); - let poster_pres = part(&poster_bytes, "ppt/presentation.xml"); - assert!(poster_pres.contains(""), "{poster_pres}"); - - // Non-finite dimensions fall back to default - let nan_page = Page { width: f64::NAN, height: -1.0, ..Page::default() }; - let nan_bytes = pptx(&[nan_page], "NaN"); - let nan_pres = part(&nan_bytes, "ppt/presentation.xml"); - assert!(nan_pres.contains(""), "{nan_pres}"); - } - - #[test] - fn pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title() { - let cell = |t: &str, x: f64, y: f64, w: f64, sz: f64| Block { - text: t.into(), - rect: [x, y, x + w, y + 14.0], - size: sz, - bold: false, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }; - let p = Page { - width: 720.0, - height: 540.0, - blocks: vec![ - // Heading outside the table (short width, does not span table columns) - Block { - text: "Monthly Summary".into(), - rect: [50.0, 480.0, 150.0, 500.0], - size: 20.0, - bold: true, - italic: false, - color: [0.0; 3], - rotation: 0.0, - }, - // Table where one cell has a very large number (e.g. 36 pt callout in the table) - cell("Metric", 72.0, 400.0, 100.0, 12.0), - cell("Value", 200.0, 400.0, 100.0, 12.0), - cell("Growth", 72.0, 360.0, 100.0, 12.0), - cell("+99%", 200.0, 360.0, 100.0, 36.0), - cell("Target", 72.0, 320.0, 100.0, 12.0), - cell("100%", 200.0, 320.0, 100.0, 12.0), - ], - images: Vec::new(), - }; - let bytes = pptx(&[p], "Table Check"); - let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); - // Title should be the block outside the table ("Monthly Summary"), not the "+99%" cell - assert!(slide_xml.contains("Monthly Summary")); - assert!(slide_xml.contains("name=\"Title 2\"")); - // The table must contain both the cell and explicit borders - assert!(slide_xml.contains("")); - assert!(slide_xml.contains("+99%")); - assert!(slide_xml.contains("")); - } - - #[test] - fn pptx_opens_in_libreoffice() { - let p = sample_page(); - let bytes = pptx(&[p], "LibreOffice Check"); - let dir = std::env::temp_dir().join(format!("pdfcraft-pptx-lo-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let _ = std::fs::create_dir_all(&dir); - let pptx_path = dir.join("test.pptx"); - std::fs::write(&pptx_path, &bytes).unwrap(); - - for cmd in ["libreoffice", "soffice"] { - let status = std::process::Command::new(cmd).args(["--headless", "--convert-to", "pdf", "--outdir"]).arg(&dir).arg(&pptx_path).status(); - if status.is_ok_and(|st| st.success()) { - assert!(dir.join("test.pdf").is_file(), "LibreOffice produced test.pdf"); - break; - } - } - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/export/src/pptx/tests.rs b/crates/export/src/pptx/tests.rs new file mode 100644 index 0000000000..2e1c1bd046 --- /dev/null +++ b/crates/export/src/pptx/tests.rs @@ -0,0 +1,393 @@ +use super::*; +use crate::Block; +use crate::tests::part; + +fn sample_page() -> Page { + Page { + width: 720.0, + height: 540.0, + blocks: vec![ + Block { + text: "Q3 Business Update".into(), + rect: [72.0, 460.0, 500.0, 500.0], + size: 28.0, + bold: true, + italic: false, + color: [0.1, 0.2, 0.3], + rotation: 0.0, + space_around: [false; 2], + }, + Block { + text: "Revenue grew 25% year over year across all regions.".into(), + rect: [72.0, 380.0, 500.0, 410.0], + size: 14.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + Block { + text: "Operational expenses remained flat.".into(), + rect: [72.0, 340.0, 500.0, 370.0], + size: 14.0, + bold: false, + italic: true, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + ], + images: vec![Image { + ext: "png", + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), + rect: [72.0, 150.0, 300.0, 300.0], + size: [228.0, 150.0], + rotation: 0.0, + }], + background: None, + } +} + +#[test] +pub fn pptx_is_a_presentationml_package() { + let p = sample_page(); + let bytes = pptx(&[p], "Executive Deck"); + assert!(bytes.starts_with(b"PK")); + for part_name in [ + "[Content_Types].xml", + "_rels/.rels", + "docProps/core.xml", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/_rels/slide1.xml.rels", + "ppt/media/image1.png", + ] { + assert!(bytes.windows(part_name.len()).any(|w| w == part_name.as_bytes()), "missing {part_name}"); + } +} + +#[test] +pub fn pptx_slide_has_title_body_and_images_in_order() { + let p = sample_page(); + let bytes = pptx(&[p], "Deck"); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("name=\"Title 2\"")); + assert!(slide_xml.contains("txBox=\"1\"")); + assert!(slide_xml.contains("Q3 Business Update")); + assert!(slide_xml.contains("name=\"TextBox 3\"")); + assert!(slide_xml.contains("Revenue grew 25%")); + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("")); + assert!(!slide_xml.contains("")); + assert!(slide_xml.contains("i=\"1\"")); +} + +/// Every entry of the package in order: (name, uncompressed bytes), read from the local +/// headers the way an unzipper walks them, so a broken structure fails here. +fn entries(zip: &[u8]) -> Vec<(String, Vec)> { + let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let mut out = Vec::new(); + let mut i = 0; + while zip[i..].starts_with(b"PK\x03\x04") { + let (method, size, raw, name_len, extra) = (at(i + 8, 2), at(i + 18, 4), at(i + 22, 4), at(i + 26, 2), at(i + 28, 2)); + let data = i + 30 + name_len + extra; + let name = String::from_utf8(zip[i + 30..i + 30 + name_len].to_vec()).expect("UTF-8 name"); + let body = &zip[data..data + size]; + let bytes = match method { + 0 => body.to_vec(), + 8 => { + let mut v = Vec::new(); + std::io::Read::read_to_end(&mut flate2::read::DeflateDecoder::new(body), &mut v).expect("inflates"); + v + } + m => panic!("{name}: unexpected method {m}"), + }; + assert_eq!(bytes.len(), raw, "{name}: uncompressed size"); + out.push((name, bytes)); + i = data + size; + } + assert!(zip[i..].starts_with(b"PK\x01\x02"), "the central directory follows the entries"); + out +} + +/// Checks that every start tag is closed by the matching end tag (text is escaped, so a +/// `<` always starts markup). Catches a typo such as `…`. +fn assert_tags_balance(name: &str, xml: &str) { + let mut stack: Vec<&str> = Vec::new(); + for chunk in xml.split('<').skip(1) { + let tag = chunk.split('>').next().unwrap_or_default(); + if tag.starts_with('?') { + continue; + } + if let Some(close) = tag.strip_prefix('/') { + assert_eq!(stack.pop(), Some(close.trim()), "{name}: closes the wrong element"); + } else if !tag.ends_with('/') { + stack.push(tag.split_whitespace().next().unwrap_or_default()); + } + } + assert!(stack.is_empty(), "{name}: unclosed {stack:?}"); +} + +#[test] +fn pptx_package_parts_are_complete_and_consistent() { + let cell = |t: &str, x: f64, y: f64| Block { + text: t.into(), + rect: [x, y, x + 100.0, y + 14.0], + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let table_page = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + cell("Metric", 72.0, 400.0), + cell("Value", 200.0, 400.0), + cell("Growth", 72.0, 360.0), + cell("+9%", 200.0, 360.0), + cell("Target", 72.0, 320.0), + cell("100%", 200.0, 320.0), + ], + images: Vec::new(), + background: None, + }; + let bytes = pptx(&[sample_page(), table_page], "Two"); + let parts = entries(&bytes); + assert_eq!(parts.first().map(|p| p.0.as_str()), Some("[Content_Types].xml"), "content types come first"); + let names: std::collections::HashSet<&str> = parts.iter().map(|p| p.0.as_str()).collect(); + assert_eq!(names.len(), parts.len(), "no duplicate parts"); + for required in [ + "[Content_Types].xml", + "_rels/.rels", + "ppt/presentation.xml", + "ppt/_rels/presentation.xml.rels", + "ppt/slideMasters/slideMaster1.xml", + "ppt/slideMasters/_rels/slideMaster1.xml.rels", + "ppt/slideLayouts/slideLayout1.xml", + "ppt/slideLayouts/_rels/slideLayout1.xml.rels", + "ppt/theme/theme1.xml", + "ppt/slides/slide1.xml", + "ppt/slides/_rels/slide1.xml.rels", + "ppt/slides/slide2.xml", + "ppt/slides/_rels/slide2.xml.rels", + "ppt/media/image1.png", + ] { + assert!(names.contains(required), "missing {required}"); + } + let text = |n: &str| String::from_utf8(parts.iter().find(|p| p.0 == n).map(|p| p.1.clone()).unwrap_or_default()).expect("UTF-8 XML"); + for (name, body) in &parts { + if name.ends_with(".xml") || name.ends_with(".rels") { + assert!(body.starts_with(b""), "the second slide carries the table"); + // Text boxes, not placeholders: the blank layout defines none to inherit from. + for slide in ["ppt/slides/slide1.xml", "ppt/slides/slide2.xml"] { + assert!(!text(slide).contains(" = base.split('/').filter(|s| !s.is_empty()).collect(); + for seg in target.split('/') { + if seg == ".." { + path.pop(); + } else { + path.push(seg); + } + } + let resolved = path.join("/"); + assert!(names.contains(resolved.as_str()), "{rels}: target {target} is missing"); + } + } + // The theme's font scheme carries the schema-required latin, ea and cs faces. + let theme = text("ppt/theme/theme1.xml"); + for font in ["majorFont", "minorFont"] { + let inner = theme.split(&format!("")).nth(1).and_then(|r| r.split(&format!("")).next()).unwrap_or_default(); + assert!(inner.contains(""), "the slide is still written"); + assert!(!slide.contains("y=\"-"), "positions never wrap negative"); + assert!(!slide.contains("")); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("")); +} + +#[test] +fn pptx_slide_dimensions_and_clamping() { + // Known dimensions: 720x540 pt (10x7.5 in) = 9144000x6858000 EMU + let p = Page { width: 720.0, height: 540.0, ..Page::default() }; + let bytes = pptx(&[p], "Known Dimensions"); + let pres_xml = part(&bytes, "ppt/presentation.xml"); + assert!(pres_xml.contains(""), "{pres_xml}"); + + // Tiny page (10x10 pt) clamps to PowerPoint minimum of 1 inch (914400 EMU) + let tiny = Page { width: 10.0, height: 10.0, ..Page::default() }; + let tiny_bytes = pptx(&[tiny], "Tiny"); + let tiny_pres = part(&tiny_bytes, "ppt/presentation.xml"); + assert!(tiny_pres.contains(""), "{tiny_pres}"); + + // Poster page (10000x10000 pt) clamps to PowerPoint maximum of 56 inches (51206400 EMU) + let poster = Page { width: 10_000.0, height: 10_000.0, ..Page::default() }; + let poster_bytes = pptx(&[poster], "Poster"); + let poster_pres = part(&poster_bytes, "ppt/presentation.xml"); + assert!(poster_pres.contains(""), "{poster_pres}"); + + // Non-finite dimensions fall back to default + let nan_page = Page { width: f64::NAN, height: -1.0, ..Page::default() }; + let nan_bytes = pptx(&[nan_page], "NaN"); + let nan_pres = part(&nan_bytes, "ppt/presentation.xml"); + assert!(nan_pres.contains(""), "{nan_pres}"); +} + +#[test] +fn pptx_table_cell_with_largest_font_stays_in_table_and_not_in_title() { + let cell = |t: &str, x: f64, y: f64, w: f64, sz: f64| Block { + text: t.into(), + rect: [x, y, x + w, y + 14.0], + size: sz, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let p = Page { + width: 720.0, + height: 540.0, + blocks: vec![ + // Heading outside the table (short width, does not span table columns) + Block { + text: "Monthly Summary".into(), + rect: [50.0, 480.0, 150.0, 500.0], + size: 20.0, + bold: true, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + // Table where one cell has a very large number (e.g. 36 pt callout in the table) + cell("Metric", 72.0, 400.0, 100.0, 12.0), + cell("Value", 200.0, 400.0, 100.0, 12.0), + cell("Growth", 72.0, 360.0, 100.0, 12.0), + cell("+99%", 200.0, 360.0, 100.0, 36.0), + cell("Target", 72.0, 320.0, 100.0, 12.0), + cell("100%", 200.0, 320.0, 100.0, 12.0), + ], + images: Vec::new(), + background: None, + }; + let bytes = pptx(&[p], "Table Check"); + let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); + // Title should be the block outside the table ("Monthly Summary"), not the "+99%" cell + assert!(slide_xml.contains("Monthly Summary")); + assert!(slide_xml.contains("name=\"Title 2\"")); + // The table must contain both the cell and explicit borders + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("+99%")); + assert!(slide_xml.contains("")); +} + +#[test] +fn pptx_opens_in_libreoffice() { + let p = sample_page(); + let bytes = pptx(&[p], "LibreOffice Check"); + let dir = std::env::temp_dir().join(format!("pdfcraft-pptx-lo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + let pptx_path = dir.join("test.pptx"); + std::fs::write(&pptx_path, &bytes).unwrap(); + + for cmd in ["libreoffice", "soffice"] { + let status = std::process::Command::new(cmd).args(["--headless", "--convert-to", "pdf", "--outdir"]).arg(&dir).arg(&pptx_path).status(); + if status.is_ok_and(|st| st.success()) { + assert!(dir.join("test.pdf").is_file(), "LibreOffice produced test.pdf"); + break; + } + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs new file mode 100644 index 0000000000..27cd8264e0 --- /dev/null +++ b/crates/export/src/tests.rs @@ -0,0 +1,843 @@ +use super::*; + +/// A page of `n` one-word blocks, each in its own size and colour. +fn varied_page(n: usize) -> Page { + let blocks = (0..n) + .map(|i| Block { + text: "word".into(), + rect: [72.0, i as f64, 100.0, i as f64 + 1.0], + size: 4.0 + i as f64 * 0.5, + bold: false, + italic: false, + color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5], + rotation: 0.0, + space_around: [false; 2], + }) + .collect(); + Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None } +} + +fn lookup_steps(f: impl FnOnce()) -> usize { + LOOKUP_STEPS.with(|c| c.set(0)); + f(); + LOOKUP_STEPS.with(|c| c.get()) +} + +#[test] +fn many_distinct_sizes_are_counted_in_constant_steps_each() { + let pages = [varied_page(3000)]; + let steps = lookup_steps(|| { + body_size(&pages); + }); + assert!(steps <= 2 * 3000, "{steps} steps"); +} + +#[test] +fn many_distinct_colours_take_constant_steps_each() { + let pages = [varied_page(3000)]; + let mut out = String::new(); + let steps = lookup_steps(|| out = rtf(&pages)); + assert!(steps <= 4 * 3000, "{steps} steps"); + assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot"); +} + +fn page() -> Page { + let b = |t: &str, y: f64, size: f64, bold: bool| Block { + text: t.into(), + rect: [72.0, y, 500.0, y + size], + size, + bold, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + Page { + width: 612.0, + height: 792.0, + blocks: vec![ + b("Body text after the picture, which is long enough to be the main text size of the page.", 400.0, 11.0, false), + b("Annual Report", 700.0, 24.0, true), + b("Overview", 650.0, 14.0, true), + b("Some text & more of it so that eleven points is the body size here.", 620.0, 11.0, false), + ], + images: vec![Image { + ext: "png", + bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(), + rect: [72.0, 450.0, 272.0, 600.0], + size: [200.0, 150.0], + rotation: 0.0, + }], + background: None, + } +} + +#[test] +fn html_has_headings_paragraphs_and_images_in_order() { + let h = html(&[page(), page()], "Report"); + let order: Vec = ["

Annual Report

", "

Overview

", "

Some <body> text &", "Body text after"] + .iter() + .map(|x| h.find(x).unwrap_or_else(|| panic!("{x} missing in {h}"))) + .collect(); + assert!(order.windows(2).all(|w| w[0] < w[1]), "{order:?}"); + assert_eq!(h.matches("


").count(), 1); + assert_eq!(base64(b"Man"), "TWFu"); + assert_eq!(base64(b"Ma"), "TWE="); +} + +#[test] +fn docx_is_a_word_package() { + let d = docx(&[page()], "Report"); + assert!(d.starts_with(b"PK")); + let names: Vec<&str> = + ["[Content_Types].xml", "word/document.xml", "word/styles.xml", "word/_rels/document.xml.rels", "word/media/image1.png", "_rels/.rels"] + .to_vec(); + for n in names { + assert!(d.windows(n.len()).any(|w| w == n.as_bytes()), "{n}"); + } +} + +#[test] +fn word_does_not_invent_borders_for_aligned_invoice_fields() { + let xml = part(&docx(&[table_page()], "Invoice"), "word/document.xml"); + assert!(xml.contains("")); + assert!(!xml.contains("w:val=\"single\""), "text alignment is not evidence of a border: {xml}"); + assert!(xml.contains("")); +} + +#[test] +fn word_table_cell_widths_follow_the_columns_they_span() { + let cell = |text: &str, span| Cell { text: text.into(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span }; + let table = Table { + rect: [0.0, 0.0, 300.0, 100.0], + cols: vec![0.0, 50.0, 150.0], + rows: vec![ + vec![cell("First", 1), cell("Second", 1), cell("Third", 1)], + vec![cell("First", 1), cell("Last two", 2)], + vec![cell("First two", 2), cell("Third", 1)], + vec![cell("All", 3)], + vec![cell("First", 1)], + ], + }; + let xml = docx_table(&table); + assert!(xml.contains(""), "{xml}"); + let widths: Vec> = + xml.split("").skip(1).map(|row| row.split(""), "the picture stays inline: {xml}"); + assert!(xml.contains(""), "the title stays a heading: {xml}"); + let (title, body) = (xml.find(">Annual Report"), xml.find(">Body text after the picture")); + assert!(title.is_some() && body.is_some() && title < body, "reading order kept: {xml}"); + // The same page with the picture moved over the body text floats. + let mut over = page(); + over.images[0].rect = [72.0, 380.0, 272.0, 530.0]; + let xml = part(&docx(&[over], "Report"), "word/document.xml"); + assert!(xml.contains("position:absolute"), "an image over text floats: {xml}"); +} + +#[test] +fn word_overlap_does_not_consume_invoice_body_space() { + let mut p = table_page(); + p.blocks.push(Block { + text: "DRAFT".into(), + rect: [100.0, 200.0, 500.0, 700.0], + size: 100.0, + bold: false, + italic: false, + color: [0.8; 3], + rotation: 0.0, + space_around: [false; 2], + }); + p.images = page().images; + let xml = part(&docx(&[p], "Invoice"), "word/document.xml"); + assert!(xml.contains("position:absolute"), "overlapping text must float: {xml}"); + assert!(!xml.contains(""), "images must not displace invoice text"); + assert!(!xml.contains(""), "the source text boxes must keep their positions"); + assert_eq!(xml.matches(">DRAFT").count(), 1); + assert_eq!(xml.matches("w:type=\"page\"").count(), 0); + assert!(xml.contains("margin-left:72.000pt;margin-top:160.000pt"), "Meter field keeps its source position: {xml}"); + let watermark = xml.split(">DRAFT").next().unwrap().rsplit(""), "{xml}"); +} + +#[test] +fn word_rotated_pages_keep_separate_page_sizes_and_unique_shapes() { + let mut p = table_page(); + p.blocks[0].rotation = 45.0; + let mut second = p.clone(); + second.width = 400.0; + second.height = 500.0; + let xml = part(&docx(&[p, second], "Two invoices"), "word/document.xml"); + assert!(xml.contains("rotation:315.000"), "PDF y-up becomes Word y-down"); + assert_eq!(xml.matches("").count(), 2); + assert!(xml.contains("")); + assert_eq!(xml.matches("id=\"Text1\"").count(), 1); + assert_eq!(xml.matches(">Meter").count(), 2); +} + +/// #2992: pages that share a background colour get it as the Word document's page colour +/// (with the setting that makes Word show it); differing or white backgrounds don't. +#[test] +fn docx_keeps_a_shared_page_background() { + let teal = Page { background: Some([0.0, 0.5, 0.5]), ..page() }; + let zip = docx(&[teal.clone(), teal.clone()], "Teal"); + let xml = part(&zip, "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(part(&zip, "word/settings.xml").contains("")); + assert!(part(&zip, "[Content_Types].xml").contains("/word/settings.xml")); + assert!(part(&zip, "word/_rels/document.xml.rels").contains("Target=\"settings.xml\"")); + let other = Page { background: Some([1.0, 0.0, 0.0]), ..page() }; + for (pages, why) in [ + (vec![teal.clone(), other], "different colours"), + (vec![teal.clone(), page()], "a page without one"), + (vec![Page { background: Some([1.0; 3]), ..page() }], "white"), + (vec![page()], "none"), + ] { + let zip = docx(&pages, "Plain"); + assert!(!part(&zip, "word/document.xml").contains(" String { + let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let mut i = 0; + while zip[i..].starts_with(b"PK\x03\x04") { + let (size, name_len) = (at(i + 18, 4), at(i + 26, 2)); + let data = i + 30 + name_len; + if &zip[i + 30..data] == name.as_bytes() { + let mut s = String::new(); + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut s).unwrap(); + return s; + } + i = data + size; + } + panic!("{name} missing") +} + +#[test] +fn word_opens_text_with_control_characters_and_long_pages() { + // #72: text extracted without a usable /ToUnicode holds C0 controls, which XML forbids; + // Word then refused the whole file. A till receipt is also taller than Word's 22 in. + let mut p = page(); + p.height = 2400.0; + p.blocks.push(Block { + text: "Total\u{0}\u{3}\u{c} 4.50\u{FFFF}\tGBP".into(), + rect: [72.0, 100.0, 300.0, 110.0], + size: f64::NAN, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }); + let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml"); + assert!(xml.contains(">Total 4.50\tGBP"), "{xml}"); + assert!(!xml.chars().any(|c| !allowed(c))); + assert!(xml.contains(""), "clamped to 22 in: {xml}"); + assert!(xml.contains(""), "a non-finite size falls back to 12 pt"); + assert!(rtf(&[p]).contains("Total 4.50\tGBP")); + // A narrow page keeps room for text: margins shrink. + let narrow = Page { width: 100.0, height: 200.0, ..page() }; + let xml = part(&docx(&[narrow], "Narrow"), "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(xml.contains(""), "out-of-range values clamp: {xml}"); + // Black text keeps Word's default colour. + assert_eq!(xml.matches("Annual Report"), "{h}"); + assert!(h.contains("

"), "{h}"); + let r = rtf(&[p.clone()]); + assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}"); + assert!(r.contains("\\cf1 Annual Report"), "{r}"); + let ppt = pptx(&[p], "Colour"); + let pxml = part(&ppt, "ppt/slides/slide1.xml"); + assert!(pxml.contains(""), "{pxml}"); + assert!(pxml.contains(""), "out-of-range values clamp: {pxml}"); + // A table cell keeps its colour too. + let mut t = table_page(); + t.blocks[0].color = [1.0, 0.0, 0.0]; + let xml = part(&docx(&[t.clone()], "Grid"), "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\"")); +} + +#[test] +fn rtf_escapes_and_sizes() { + let mut p = page(); + p.blocks.push(Block { + text: "Café {x}".into(), + rect: [72.0, 100.0, 200.0, 110.0], + size: 10.0, + bold: false, + italic: true, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }); + let r = rtf(&[p]); + assert!(r.starts_with("{\\rtf1") && r.ends_with('}')); + assert!(r.contains("\\fs48\\b Annual Report")); + assert!(r.contains("\\fs20\\i Caf\\u233? \\{x\\}"), "{r}"); +} + +/// A 3-column table with a spanning header row and a hole in the last row. +fn table_page() -> Page { + let cell = |t: &str, x: f64, y: f64, w: f64| Block { + text: t.into(), + rect: [x, y, x + w, y + 12.0], + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + Page { + width: 612.0, + height: 792.0, + blocks: vec![ + cell("Meter", 72.0, 620.0, 120.0), + cell("Unit", 232.0, 620.0, 80.0), + cell("Reading", 392.0, 620.0, 100.0), + cell("A1", 72.0, 590.0, 120.0), + cell("kWh", 232.0, 590.0, 80.0), + cell("37414.00", 392.0, 590.0, 100.0), + cell("A2", 72.0, 560.0, 120.0), + cell("kW", 232.0, 560.0, 80.0), + // A2's row is missing its third cell (an empty cell in the grid). + cell("Totals", 72.0, 530.0, 240.0), + Block { + text: "Notes follow the table and are long enough to be the body text size of the page here.".into(), + rect: [72.0, 460.0, 500.0, 471.0], + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }, + ], + images: Vec::new(), + background: None, + } +} + +#[test] +fn grid_text_becomes_a_real_table_in_every_format() { + let p = table_page(); + let h = html(std::slice::from_ref(&p), "Bill"); + assert!(h.contains("

"), "{h}"); + assert!(h.contains(""), "spanning cell: {h}"); + assert_eq!(h.matches("").count(), 4, "{h}"); + assert!(h.contains(""), "the hole is an empty cell: {h}"); + assert!(h.contains("

Notes follow"), "the paragraph after the table stays: {h}"); + + let d = docx(std::slice::from_ref(&p), "Bill"); + assert!(d.starts_with(b"PK")); + assert!(d.windows(b"word/document.xml".len()).any(|w| w == b"word/document.xml")); + let (tables, _) = tables(&p.blocks); + assert_eq!(tables.len(), 1); + let table_xml = docx_table(&tables[0]); + assert!(table_xml.contains("")); + assert!(table_xml.contains(""), "{table_xml}"); + assert_eq!(table_xml.matches("").count(), 4); + + let r = rtf(std::slice::from_ref(&p)); + assert!(r.contains("\\trowd"), "{r}"); + assert_eq!(r.matches("\\row").count(), 4); + // Boundaries precede the cells; "Totals" spans two columns, so it ends at the third edge. + assert!(r.contains("\\trowd\\trgaph108\\cellx4640\\cellx7840\\cellx9920{"), "{r}"); + assert!(r.contains("\\trowd\\trgaph108\\cellx7840{\\intbl\\fs22 Totals}\\cell\\row"), "{r}"); + + let pt = pptx(std::slice::from_ref(&p), "Bill"); + assert!(pt.starts_with(b"PK")); + assert!(pt.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml")); + let slide_xml = part(&pt, "ppt/slides/slide1.xml"); + assert!(slide_xml.contains("")); + assert!(slide_xml.contains("gridSpan=\"2\"")); + assert_eq!(slide_xml.matches(" = (0..n) + .map(|i| { + let (row, col) = (i / 2, i / 2 + i % 2); + let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0); + Block { + text: "x".into(), + rect: [x, y, x + 8.0, y + 12.0], + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } + }) + .collect(); + let started = std::time::Instant::now(); + let (tables, _) = tables(&blocks); + assert!(tables.iter().all(|t| t.cols.len() <= MAX_COLS), "{} columns", tables.iter().map(|t| t.cols.len()).max().unwrap_or(0)); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); +} + +#[test] +fn word_opens_tables_up_to_its_column_limit() { + // Rows of short numbers on a regular grid, n columns wide. + let grid = |n: usize| Page { + width: 1500.0, + height: 792.0, + blocks: (0..3) + .flat_map(|row| { + (0..n).map(move |col| { + let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0); + Block { + text: (col + 1).to_string(), + rect: [x, y, x + 8.0, y + 8.0], + size: 8.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } + }) + }) + .collect(), + images: Vec::new(), + background: None, + }; + let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(" Block { + Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0, space_around: [false; 2] } +} + +/// #2806: a sentence whose link is set in another colour or font runs on as separate blocks +/// on one line. They are not table columns, even when several such lines share left edges. +#[test] +fn a_link_inside_a_sentence_is_not_a_table_column() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9), + blue("http", 273.5, 710.3, 294.1, 724.9), + blue("s", 294.1, 709.8, 298.8, 725.8), + blue("://women", 298.8, 710.3, 348.0, 724.9), + blue("g", 348.1, 709.8, 353.7, 725.8), + blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9), + block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9), + block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5), + blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4), + block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5), + block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9), + block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6), + ], + images: Vec::new(), + background: None, + }; + let h = html(std::slice::from_ref(&p), "Regulations"); + assert!(!h.contains("

Totals
"), "running text became a table: {h}"); + // The link is a run of its sentence, in its own colour (#4158). + assert!(h.contains("

6.3. 2. All content shown at http"), "{h}"); + for b in &p.blocks { + assert!(h.contains(&b.text), "{} was lost: {h}", b.text); + } +} + +/// #2806: lines that each run on into a link starting at the same place are sentences, not a +/// two-column table, even three in a row. +#[test] +fn sentences_that_run_on_into_links_are_not_a_table() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let mut blocks = Vec::new(); + for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() { + let y = 700.0 - 16.0 * n as f64; + blocks.push(block(lead, 72.0, y, 211.0, y + 12.0)); + blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0)); + } + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let h = html(std::slice::from_ref(&p), "Event"); + assert!(!h.contains("

"), "sentences became a table: {h}"); + assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}"); +} + +/// A continuation can drift into a grid column covered by the preceding cell's span. +/// It must remain available as paragraph text if that slot never becomes an output cell. +#[test] +fn table_spans_do_not_consume_text_they_do_not_emit() { + let mut blocks = vec![ + Block { size: 12.0, ..block("ABCDEFGHIJK", 72.1, 697.0, 151.3, 710.8) }, + Block { size: 12.0, color: [0.0, 0.0, 1.0], ..block("LOST", 150.4, 697.0, 179.2, 710.8) }, + Block { size: 12.0, ..block("R0", 250.0, 697.0, 264.4, 710.8) }, + ]; + for row in 1..=4 { + let y = 697.0 - f64::from(row) * 50.0; + for (text, left, right) in [(format!("L{row}"), 72.1, 86.5), (format!("M{row}"), 146.4, 160.8), (format!("R{row}"), 250.0, 264.4)] { + blocks.push(Block { size: 12.0, ..block(&text, left, y, right, y + 13.8) }); + } + } + let p = Page { width: 612.0, height: 792.0, blocks, ..Page::default() }; + let (tables, consumed) = tables(&p.blocks); + assert_eq!(tables.len(), 1); + assert_eq!(tables[0].rows[0][0].span, 2); + assert!(!consumed[1], "a covered slot did not emit its text"); + assert!(consumed.iter().enumerate().all(|(index, used)| index == 1 || *used)); + let outputs = [ + html(std::slice::from_ref(&p), "Continuations"), + part(&docx(std::slice::from_ref(&p), "Continuations"), "word/document.xml"), + rtf(std::slice::from_ref(&p)), + part(&pptx(std::slice::from_ref(&p), "Continuations"), "ppt/slides/slide1.xml"), + ]; + for output in outputs { + for b in &p.blocks { + assert_eq!(output.matches(&b.text).count(), 1, "{} must survive exactly once", b.text); + } + } +} + +/// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table +/// around it stays a table. +#[test] +fn a_superscript_beside_a_number_keeps_its_table() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let h = html(std::slice::from_ref(&p), "Prizes"); + assert!(h.contains("
"), "the prize table was lost: {h}"); + assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); + let (tables, consumed) = tables(&p.blocks); + assert!(consumed.iter().all(|used| *used), "ordinary table continuations stay in their cells"); + let cells: Vec<&str> = tables.iter().flat_map(|t| &t.rows).flatten().map(|c| c.text.as_str()).collect(); + assert!(["st", "nd", "rd", "th"].iter().all(|suffix| cells.contains(suffix))); +} + +#[test] +fn two_column_body_text_is_not_mistaken_for_a_table() { + let para = |t: &str, x: f64, y: f64| Block { + text: t.into(), + rect: [x, y, x + 200.0, y + 90.0], // nine lines tall: body text, not a cell + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + para("Left column text of the page, long enough to be a flowing paragraph.", 72.0, 500.0), + para("Right column text of the page, long enough to be a flowing paragraph.", 320.0, 500.0), + para("Second left block, still a tall flowing paragraph rather than a cell.", 72.0, 400.0), + para("Second right block, still a tall flowing paragraph rather than a cell.", 320.0, 400.0), + ], + images: Vec::new(), + background: None, + }; + let h = html(&[p], "Paper"); + assert!(!h.contains("
"), "tall two-column text must stay paragraphs: {h}"); +} + +/// #1936: every picture Word is told to draw (`r:embed`) names a relationship whose target is a +/// file in the package, with a content type; JPEG pictures too, inline or placed. +#[test] +fn word_pictures_resolve_to_media_in_the_package() { + let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() }; + for placed in [false, true] { + let mut p = page(); + p.images = vec![jpeg.clone()]; + if placed { + p.images[0].rect = [72.0, 640.0, 272.0, 790.0]; + p.blocks[0].rect = [72.0, 700.0, 300.0, 760.0]; + } + let zip = docx(&[p], "T"); + let xml = part(&zip, "word/document.xml"); + assert_eq!(xml.contains(" Vec> { + let body = xml.split("").map(|s| s.split_once("").map_or(s, |(_, after)| after)).collect::(); + body.split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("") + .skip(1) + .filter_map(|r| { + let text = r.split_once("")?.1.split_once("")?.0; + Some((text.to_string(), r.contains(""))) + }) + .collect::>() + }) + .filter(|runs| !runs.is_empty()) + .collect() +} + +fn runs(r: &[(&str, bool)]) -> Vec<(String, bool)> { + r.iter().map(|(t, i)| (t.to_string(), *i)).collect() +} + +/// "Before *Chevron* after." on one line: the roman text, the italic case name and the roman +/// text after it, the way reading blocks split a sentence at its style changes (#4158). The +/// italic face stands a little taller than the roman one around it. +fn italic_sentence() -> Vec { + vec![ + block("Before", 72.0, 700.0, 110.0, 712.0), + Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.6) }, + block("after.", 161.0, 700.0, 190.0, 712.0), + ] +} + +/// #4158: a run in another style inside a sentence stays in that sentence's paragraph, as a +/// run in its own style, in every Office format. +#[test] +fn an_italic_word_stays_in_its_sentence() { + let mut blocks = italic_sentence(); + blocks.push(block("The next line is a paragraph of its own.", 72.0, 684.0, 300.0, 696.0)); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("The next line is a paragraph of its own.", false)])], + "{xml}" + ); + let h = html(std::slice::from_ref(&p), "Opinion"); + assert!(h.contains("

Before Chevron after.

"), "{h}"); + let r = rtf(std::slice::from_ref(&p)); + assert!(r.contains("{\\pard\\fs24 Before {\\fs24\\b0\\i\\cf0 Chevron }{\\fs24\\b0\\i0\\cf0 after.}\\par}"), "{r}"); + // The longest line is the slide's title; the sentence is one paragraph of the body. + let pxml = part(&pptx(std::slice::from_ref(&p), "Opinion"), "ppt/slides/slide1.xml"); + assert!( + pxml.contains("Before Chevron after."), + "{pxml}" + ); +} + +/// #4158: the runs keep their order along the line whatever order the blocks come in, and the +/// paragraph keeps its place on the page, though the italic run stands taller than the first. +#[test] +fn a_sentence_keeps_its_reading_order() { + let mut blocks = vec![block("Then the next line.", 72.0, 684.0, 200.0, 696.0)]; + blocks.extend(italic_sentence().into_iter().rev()); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("Then the next line.", false)])], + "{xml}" + ); +} + +/// The text of a block leaves out the spaces at its ends, and the box of a run often takes in +/// its trailing space, so runs can touch. The source's own space still separates them; runs +/// that touch with no space between them (a superscript) stay joined. +#[test] +fn runs_keep_the_spaces_the_source_had_between_them() { + let blocks = vec![ + Block { space_around: [false, true], ..block("Before", 72.0, 700.0, 113.0, 712.0) }, + Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.0) }, + Block { space_around: [true, false], ..block("after.", 158.0, 700.0, 190.0, 712.0) }, + block("E = mc", 72.0, 660.0, 110.0, 672.0), + Block { size: 8.0, ..block("2", 110.0, 665.0, 115.0, 673.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("E = mc", false), ("2", false)])], + "{xml}" + ); +} +/// Text turned another way (a label up the margin) is not a run of the line it touches. +#[test] +fn turned_text_is_not_a_run_of_the_line_beside_it() { + let blocks = vec![block("Before", 72.0, 700.0, 110.0, 712.0), Block { rotation: 90.0, ..block("SIDE", 111.0, 690.0, 123.0, 720.0) }]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let h = html(std::slice::from_ref(&p), "Turned"); + assert!(h.contains("

Before

") && h.contains("

SIDE

"), "{h}"); +} +/// A block that starts where the line above ends is the next line, not a run of it. +#[test] +fn a_block_on_the_next_line_starts_a_paragraph() { + let blocks = vec![ + block("The first line ends here", 72.0, 700.0, 190.0, 712.0), + Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml"); + assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}"); +} + +/// A superscript beside a table cell stays in the table: table detection consumes it before +/// paragraphs are formed, and a sentence after the table still keeps its italic run. +#[test] +fn table_cells_are_not_runs_of_a_paragraph() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); + blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); + } + blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b })); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + for cell in ["st", "nd", "rd", "30,000"] { + assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}"); + } + // The cells are all in the table: the only flowing paragraph is the sentence. + assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}"); +} + +/// A bold lead-in followed by body text on its line is body text, not a heading; a heading +/// with a smaller footnote marker after it stays a heading. +#[test] +fn a_paragraph_is_a_heading_only_when_its_text_is() { + let blocks = vec![ + Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) }, + Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) }, + Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) }, + block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0), + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); + assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); +} + +/// #1595: Markdown gets the headings, paragraphs and a rule between pages, in reading order; +/// images are left out. +#[test] +fn markdown_has_headings_paragraphs_and_page_rules_in_order() { + let md = markdown(&[page(), page()]); + let order: Vec = ["# Annual Report\n\n", "## Overview\n\n", "Some \\ text \\& more", "Body text after the picture", "\n\n---\n\n"] + .iter() + .map(|x| md.find(x).unwrap_or_else(|| panic!("{x:?} missing in {md}"))) + .collect(); + assert!(order.windows(2).all(|w| w[0] < w[1]), "{md}"); + assert_eq!(md.matches("\n---\n").count(), 1, "one rule between two pages: {md}"); + assert_eq!(md.matches("# Annual Report").count(), 2, "{md}"); + assert!(!md.contains("PNG") && !md.contains("!["), "images are left out: {md}"); + assert!(md.ends_with(".\n") && !md.ends_with("\n\n"), "{md:?}"); +} + +/// #1595: text Markdown would read as markup stays text, emphasis wraps a run without its outer +/// spaces, and a detected table becomes a pipe table with its first row as the header. +#[test] +fn markdown_escapes_markup_and_keeps_emphasis_and_tables() { + let b = |t: &str, y: f64, bold: bool, italic: bool| Block { + text: t.into(), + rect: [72.0, y, 500.0, y + 11.0], + size: 11.0, + bold, + italic, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + b("- not a list item, and long enough to make eleven points the body size here", 700.0, false, false), + b("1. not a numbered item either", 650.0, false, false), + b("*stars* _under_ `tick` [link](x) a|b # #tag\u{1}\nnext", 600.0, false, false), + b("Strong words in a sentence of their own.", 550.0, true, false), + b("slanted", 500.0, false, true), + ], + images: Vec::new(), + background: None, + }; + let md = markdown(&[p]); + assert!(md.contains("\\- not a list item"), "{md}"); + assert!(md.contains("1\\. not a numbered item"), "{md}"); + assert!(md.contains("\\*stars\\* \\_under\\_ \\`tick\\` \\[link\\](x) a\\|b \\# \\#tag next"), "{md}"); + assert!(md.contains("**Strong words in a sentence of their own.**"), "{md}"); + // Markdown reads no emphasis where a marker sits next to a space: the outer spaces go outside. + assert_eq!(emphasis(" two words ", true, false, false, false), " **two words** "); + assert_eq!(emphasis("both", true, true, true, true), "***both***"); + assert_eq!(emphasis(" ", true, true, true, true), " ", "nothing to mark"); + // Runs in one style are one: two bold runs side by side don't meet as `****`. + assert_eq!(paragraph(&[("hello", true, false), ("world", true, false)]), "**helloworld**"); + // Markers inside a word would stay literal in CommonMark, so a run there gets HTML tags. + assert_eq!(paragraph(&[("foo", false, false), (".", true, false), ("bar", false, false)]), "foo.bar"); + assert_eq!(paragraph(&[("x", true, false), ("y", false, true)]), "x*y*", "two styles side by side"); + // `*` may sit inside a word, and before punctuation that follows a letter. + assert_eq!(paragraph(&[("foo", false, false), ("bar", true, false), ("baz", false, false)]), "foo**bar**baz"); + assert_eq!(paragraph(&[("a ", false, false), ("b", true, false), (" c", false, false)]), "a **b** c"); + assert_eq!(paragraph(&[("say ", false, false), ("this", false, true), (", then", false, false)]), "say *this*, then"); + assert!(md.contains("*slanted*"), "{md}"); + assert!(!md.chars().any(|c| !allowed(c)), "{md:?}"); + + let md = markdown(&[table_page()]); + let rows: Vec<&str> = md.lines().filter(|l| l.starts_with('|')).collect(); + assert_eq!(rows.first(), Some(&"| Meter | Unit | Reading |"), "{md}"); + assert_eq!(rows.get(1), Some(&"| --- | --- | --- |"), "{md}"); + assert!(rows.contains(&"| A1 | kWh | 37414.00 |"), "{md}"); + assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}"); +} diff --git a/crates/filters/Cargo.toml b/crates/filters/Cargo.toml index 9d2ae34b94..aa8aaa0c4d 100644 --- a/crates/filters/Cargo.toml +++ b/crates/filters/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pdfcraft-filters" -description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength" +description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding and Group 3 and 4 decoding" version.workspace = true edition.workspace = true license.workspace = true @@ -11,9 +11,14 @@ repository.workspace = true workspace = true [dependencies] +fax.workspace = true +# CCITT fax decoding (`decode_ccitt`); already in the tree through hayro. +hayro-ccitt.workspace = true flate2.workspace = true thiserror.workspace = true [dev-dependencies] +hayro-ccitt.workspace = true +hayro-jbig2.workspace = true proptest.workspace = true weezl.workspace = true diff --git a/crates/filters/README.md b/crates/filters/README.md index df64e4b7c0..0c34857c43 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -3,7 +3,8 @@ PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`. - **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3). -- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). +- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's (MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte. + - `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. ## API @@ -17,14 +18,22 @@ pub struct Params { predictor, colors, bits_per_component, columns, early_change pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String) } decode(chain, data, max_output) -> Result, FilterError> +decode_counted(chain, data, max_output) -> Result<(Vec, usize /*decoded*/), (FilterError, usize /*decoded*/)> decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), FilterError> +decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)> encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 +encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS +pub struct CcittParams { k, columns, rows, end_of_line, encoded_byte_align, end_of_block, black_is_1 } // Table 11; Default = its defaults +decode_ccitt(data, ¶ms, max_rows) -> Result, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS +encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS +encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; columns <= MAX_JBIG2_COLUMNS ``` - `chain` is applied first to last, as in a `/Filter` array. -- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`. +- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`. - `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. +- `decode_counted` and `decode_tolerant_counted` are for a caller charging a budget (`pdfcraft_cos::DecodeBudget`). `max_output` bounds all stages together (each stage gets what the earlier ones left), and they give the bytes every stage that ran decoded, on success and on failure. A stage's output counts as its decoder produced it, before a predictor or a later stage shrinks it, and a failed decode pays for what it decoded first. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. ## Behaviour @@ -37,14 +46,23 @@ encode_flate(data) -> Vec // zlib, level 6 | ASCIIHexDecode | white space ignored, odd digit padded with 0, `>` EOD (optional), other characters → Corrupt | upper case, newline every 64 digits, `>` | | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | +| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | +| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | ## Tests - Unit tests per filter with hand-computed vectors (the ISO 32000-2 LZW example, RFC 1951 stored blocks, PNG/TIFF rows). - LZW is cross-checked against `weezl` (dev-dependency) in both directions for both early-change settings. - `tests/proptests.rs`: encode→decode round trips for every encodable filter × predictor × colors × bpc × columns, two-filter chains, output-limit checks, and fuzz-style no-panic tests (random bytes, random/nonsense params, bit-flipped valid streams). +- `ccitt.rs`: `decode_ccitt` gives back the rows `encode_g4` was given (with `/BlackIs1`, `/Rows` and a row limit), refuses bad widths, and bounds its output for any input; byte-aligned Group 3 in each layout, over 32 seeded pictures, decodes to its picture, and a byte short to fewer rows. Group 4 output decoded by `hayro-ccitt` (an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. +- `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. +- `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. +- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status -M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2, CCITT G3/G4 decode and G4 encode, DCT encode) and Flate encode levels other than 6. +M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2), DCT encoding, CCITT Group 3 encoding and Flate encode levels other than 6. + + +Lossless symbol coding is also implemented: exact-copy dictionaries/text regions, bounded by the encoder; no approximate or lossy symbol substitution. diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs new file mode 100644 index 0000000000..e87a82fe16 --- /dev/null +++ b/crates/filters/src/ccitt.rs @@ -0,0 +1,486 @@ +//! CCITT fax for `CCITTFaxDecode` (ISO 32000-2 §7.4.6): Group 4 (ITU-T T.6) encoding, with `/K -1`, +//! and decoding of Group 3 (T.4) and Group 4. The coding is the `fax` crate's and the decoding the +//! `hayro-ccitt` crate's; this module checks what they are given and bounds what they give. + +use fax::encoder::Encoder; +use fax::{Color, VecWriter}; + +use crate::FilterError; + +/// The widest row [`encode_g4`] encodes. The encoder counts the positions in a row in 16 bits +/// and adds two of them, so a row stays within half that range. +pub const MAX_G4_COLUMNS: u32 = 32_767; + +/// `rows` rows of `columns` 1-bit samples, most significant bit first, each row starting on a new +/// byte (as a PDF image's samples are packed), encoded as CCITT Group 4 with an end-of-block mark. +/// +/// A 0 bit is coded black and a 1 bit white, so the stream decodes to the same bits with +/// `/BlackIs1 false`, the default: an image keeps its colour space and decode array. Bytes after +/// the last row are ignored, as are the bits that pad a row to a whole byte. +/// +/// Errors: no rows or columns, or rows wider than [`MAX_G4_COLUMNS`] ([`FilterError::Unsupported`]); +/// fewer bytes than the rows need ([`FilterError::Corrupt`]). +pub fn encode_g4(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + let width = match u16::try_from(columns) { + Ok(w) if w > 0 && columns <= MAX_G4_COLUMNS => w, + _ => return Err(FilterError::Unsupported(format!("CCITT Group 4 encoding of rows {columns} samples wide"))), + }; + if rows == 0 { + return Err(FilterError::Unsupported("CCITT Group 4 encoding of an image without rows".into())); + } + let stride = usize::from(width).div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt { + filter: "CCITTFaxDecode", + detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()), + })?; + let mut encoder = Encoder::new(VecWriter::new()); + for row in samples.chunks_exact(stride) { + let pels = (0..usize::from(width)).map(|x| { + let white = row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + if white { Color::White } else { Color::Black } + }); + // `VecWriter` writes to memory: its error type has no values. + if let Err(never) = encoder.encode_line(pels, width) { + match never {} + } + } + match encoder.finish() { + Ok(writer) => Ok(writer.finish()), + Err(never) => match never {}, + } +} + +/// The widest row [`decode_ccitt`] decodes: its row buffer stays within 128 KiB. +pub const MAX_CCITT_COLUMNS: u32 = 1 << 20; + +/// A `CCITTFaxDecode` stream's `/DecodeParms` (ISO 32000-2 Table 11). [`Default`] gives the +/// table's defaults. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CcittParams { + /// `/K`: negative for Group 4, 0 for Group 3 one-dimensional, positive for Group 3 mixed. + pub k: i64, + pub columns: i64, + /// `/Rows`: 0 when not given. + pub rows: i64, + pub end_of_line: bool, + pub encoded_byte_align: bool, + pub end_of_block: bool, + pub black_is_1: bool, +} + +impl Default for CcittParams { + fn default() -> Self { + CcittParams { k: 0, columns: 1728, rows: 0, end_of_line: false, encoded_byte_align: false, end_of_block: true, black_is_1: false } + } +} + +/// CCITT fax `data` decoded as `CCITTFaxDecode` gives it: rows of `columns` 1-bit samples, most +/// significant bit first, each starting on a new byte, with black as 0 (as 1 with `black_is_1`). +/// +/// At most `max_rows` rows are decoded (fewer when `rows` says so), so the output is bounded by +/// the caller. Damaged data gives the rows decoded before the damage, as viewers show them: a +/// caller that needs a number of rows checks it got them. +/// +/// Group 3 rows on bytes (`k` from 0, `encoded_byte_align`) are read by their end-of-line codes, +/// whether the fill starts each code on a byte or ends it on one, when `data` starts with one (T.4 +/// sends one before the first row when it sends them at all), and on bytes otherwise, whatever +/// `end_of_line` says. Group 4 is read as its parameters say. Data with codes but none before the +/// first row is read on bytes, as PdfCraft's renderer draws it; with `k` 0 and the fill ending each +/// code on a byte, that may be other rows than were coded. +/// +/// Errors: `columns` that aren't from 1 to [`MAX_CCITT_COLUMNS`] ([`FilterError::Unsupported`]). +pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result, FilterError> { + let columns = u32::try_from(p.columns) + .ok() + .filter(|c| (1..=MAX_CCITT_COLUMNS).contains(c)) + .ok_or_else(|| FilterError::Unsupported(format!("CCITT fax rows {} samples wide", p.columns)))?; + let rows = u32::try_from(p.rows).ok().filter(|r| *r > 0).map_or(max_rows, |r| r.min(max_rows)); + if rows == 0 { + return Ok(Vec::new()); + } + let settings = hayro_ccitt::DecodeSettings { + columns, + rows, + end_of_block: p.end_of_block, + end_of_line: p.end_of_line, + // Group 3 rows on bytes may have end-of-line codes whatever `end_of_line` says (the filter + // always accepts them), with the fill starting each code on a byte or ending it on one (T.4's + // fill, as TIFF writes it). Read unaligned, the codes mark the rows either way; without + // codes, the rows are read on bytes. A reading that comes up short isn't tried the other + // way: data cut short would then read as other rows. + rows_are_byte_aligned: p.encoded_byte_align && (p.k < 0 || !starts_with_end_of_line(data)), + encoding: match p.k { + ..0 => hayro_ccitt::EncodingMode::Group4, + 0 => hayro_ccitt::EncodingMode::Group3_1D, + k => hayro_ccitt::EncodingMode::Group3_2D { k: u32::try_from(k).unwrap_or(u32::MAX) }, + }, + invert_black: false, + }; + // u32 to usize: wasm32 and up have at least 32-bit usize. + let mut out = Rows::new(columns as usize, rows as usize, p.black_is_1); + // The rows decoded before damaged data are kept. + let _ = hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)); + Ok(out.out) +} + +/// Whether CCITT fax `data` starts with an end-of-line code: a 1 after 11 zeros or more, fill +/// included. T.4 sends one before the first row when it sends them at all, and no other code starts +/// with more than eight zeros. +fn starts_with_end_of_line(data: &[u8]) -> bool { + let Some((i, b)) = data.iter().enumerate().find(|(_, b)| **b != 0) else { return false }; + i.saturating_mul(8).saturating_add(b.leading_zeros() as usize) >= 11 +} + +/// Rows of 1-bit samples filled from a decoder's pixels: `columns` a row, each row on a new byte, at +/// most `rows` rows. Pixels past a row's end and rows past the last are dropped. +struct Rows { + out: Vec, + row: Vec, + x: usize, + columns: usize, + rows: usize, + done: usize, + black_is_1: bool, +} + +impl Rows { + fn new(columns: usize, rows: usize, black_is_1: bool) -> Self { + Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0, black_is_1 } + } + + /// `count` pixels, white or black. + fn push(&mut self, white: bool, count: usize) { + let end = self.x.saturating_add(count); + if white != self.black_is_1 { + for x in self.x..end.min(self.columns) { + if let Some(b) = self.row.get_mut(x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + self.x = end; + } +} + +impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.push(white, 1); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.push(white, (chunks as usize).saturating_mul(8)); + } + fn next_line(&mut self) { + if self.done < self.rows { + self.out.extend_from_slice(&self.row); + self.done += 1; + } + self.row.fill(0); + self.x = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rows decoded by an independent decoder (`hayro-ccitt`), as 1 for white and 0 for black. + pub(crate) fn decode(data: &[u8], columns: u32, rows: u32) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.1.push(white); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(white, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let settings = hayro_ccitt::DecodeSettings { + columns, + rows, + end_of_block: true, + end_of_line: false, + rows_are_byte_aligned: false, + encoding: hayro_ccitt::EncodingMode::Group4, + invert_black: false, + }; + let mut out = Rows(Vec::new(), Vec::new(), columns as usize); + hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)).unwrap(); + out.0 + } + + /// The rows of packed samples `data`, as 1 for a 1 bit. + pub(crate) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { + let stride = (columns as usize).div_ceil(8); + (0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect() + } + + #[test] + fn a_page_of_text_like_marks_round_trips_and_shrinks() { + let (w, h) = (1700u32, 220u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + // Short black strokes in lines, like glyphs on a white page. + for y in (20..h as usize).step_by(40) { + for dy in 0..12 { + for x in (100..1600).step_by(9) { + if (x / 9 + dy) % 5 != 0 { + data[(y + dy) * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + } + let g4 = encode_g4(&data, w, h).unwrap(); + assert_eq!(decode(&g4, w, h), unpack(&data, w, h)); + assert!(g4.len() * 4 < data.len(), "{} bytes from {}", g4.len(), data.len()); + } + + #[test] + fn padding_bits_and_trailing_bytes_are_ignored() { + // 3 columns: the last 5 bits of each byte pad the row; set them to anything. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010, 0xAB, 0xCD]; + let g4 = encode_g4(&data, 3, 3).unwrap(); + assert_eq!(decode(&g4, 3, 3), [[true, false, true], [false, true, false], [true, true, true]]); + } + + #[test] + fn what_it_cannot_encode_is_an_error() { + assert!(matches!(encode_g4(&[0; 8], 0, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&[0; 8], 8, 0), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&vec![0; 4097], MAX_G4_COLUMNS + 1, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes"); + assert!(matches!(encode_g4(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. }))); + let widest = vec![0x55; (MAX_G4_COLUMNS as usize).div_ceil(8) * 2]; + let g4 = encode_g4(&widest, MAX_G4_COLUMNS, 2).unwrap(); + assert_eq!(decode(&g4, MAX_G4_COLUMNS, 2), unpack(&widest, MAX_G4_COLUMNS, 2)); + } + + /// Rows of `columns` samples built from `runs`: alternating white and black runs one longer + /// than those values (so never empty, even when shrinking makes them all 0), repeated until the + /// rows are full; or, with `noise`, the bytes of `runs` as they are. + fn bitmap(columns: u32, rows: u32, runs: &[u8], noise: bool) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut data = vec![0u8; stride * rows as usize]; + if runs.is_empty() { + return data; + } + if noise { + data.iter_mut().zip(runs.iter().cycle()).for_each(|(d, r)| *d = *r); + return data; + } + let (mut white, mut left, mut next) = (true, 1 + usize::from(runs[0]), 1); + for y in 0..rows as usize { + for x in 0..columns as usize { + if left == 0 { + white = !white; + left = 1 + usize::from(runs[next % runs.len()]); + next += 1; + } + if white { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + left -= 1; + } + } + data + } + + fn g4(columns: u32) -> CcittParams { + CcittParams { k: -1, columns: i64::from(columns), ..CcittParams::default() } + } + + #[test] + fn decoding_gives_the_rows_back() { + // 3 columns, padded with anything: the decoded padding is 0. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010]; + let coded = encode_g4(&data, 3, 3).unwrap(); + assert_eq!(decode_ccitt(&coded, &g4(3), 3).unwrap(), [0b1010_0000, 0b0100_0000, 0b1110_0000]); + // /BlackIs1 gives black as 1. + let p = CcittParams { black_is_1: true, ..g4(3) }; + assert_eq!(decode_ccitt(&coded, &p, 3).unwrap(), [0b0100_0000, 0b1010_0000, 0b0000_0000]); + // At most /Rows rows, and at most max_rows. + assert_eq!(decode_ccitt(&coded, &CcittParams { rows: 2, ..g4(3) }, 3).unwrap().len(), 2); + assert_eq!(decode_ccitt(&coded, &g4(3), 1).unwrap().len(), 1); + assert_eq!(decode_ccitt(&coded, &CcittParams { rows: 3, ..g4(3) }, 1).unwrap().len(), 1, "max_rows bounds /Rows"); + assert_eq!(decode_ccitt(&coded, &g4(3), 0).unwrap(), [0u8; 0]); + // Damaged data gives the rows before the damage; none here. + assert!(decode_ccitt(&coded[..1], &g4(3), 3).unwrap().len() < 3); + } + + #[test] + fn what_it_cannot_decode_is_an_error() { + for columns in [0, -1, i64::from(MAX_CCITT_COLUMNS) + 1, i64::MAX] { + let p = CcittParams { columns, ..CcittParams::default() }; + assert!(matches!(decode_ccitt(&[0; 8], &p, 4), Err(FilterError::Unsupported(_))), "{columns}"); + } + assert_eq!(CcittParams::default().columns, 1728, "Table 11"); + } + + /// Group 3 data: rows of run lengths below 64, white first, coded one-dimensionally with T.4's + /// terminating codes (ITU-T T.4 Tables 2 and 3). Each row starts with an end-of-line code with + /// `eol`, then with `tag` a 1 bit (the next row one-dimensional, as `k` above 0 has it). Fill + /// bits (0s) go where `fill` says. + fn group_3(rows: &[Vec], eol: bool, tag: bool, fill: Fill) -> Vec { + // The codes for runs 0 to 63, in order. + const WHITE: &str = concat!( + "00110101 000111 0111 1000 1011 1100 1110 1111 10011 10100 00111 01000 001000 000011 110100 110101 101010 101011 ", + "0100111 0001100 0001000 0010111 0000011 0000100 0101000 0101011 0010011 0100100 0011000 00000010 00000011 00011010 ", + "00011011 00010010 00010011 00010100 00010101 00010110 00010111 00101000 00101001 00101010 00101011 00101100 00101101 ", + "00000100 00000101 00001010 00001011 01010010 01010011 01010100 01010101 00100100 00100101 01011000 01011001 01011010 ", + "01011011 01001010 01001011 00110010 00110011 00110100", + ); + const BLACK: &str = concat!( + "0000110111 010 11 10 011 0011 0010 00011 000101 000100 0000100 0000101 0000111 00000100 00000111 000011000 0000010111 ", + "0000011000 0000001000 00001100111 00001101000 00001101100 00000110111 00000101000 00000010111 00000011000 ", + "000011001010 000011001011 000011001100 000011001101 000001101000 000001101001 000001101010 000001101011 000011010010 ", + "000011010011 000011010100 000011010101 000011010110 000011010111 000001101100 000001101101 000011011010 000011011011 ", + "000001010100 000001010101 000001010110 000001010111 000001100100 000001100101 000001010010 000001010011 000000100100 ", + "000000110111 000000111000 000000100111 000000101000 000001011000 000001011001 000000101011 000000101100 000001011010 ", + "000001100110 000001100111", + ); + let code = |white: bool, run: u32| (if white { WHITE } else { BLACK }).split(' ').nth(run as usize).expect("a run below 64"); + let mut bits = String::new(); + for row in rows { + // The end-of-line code is 12 bits. + let ends_code = if fill == Fill::CodeEnd && eol { 12 } else { 0 }; + while fill != Fill::None && !(bits.len() + ends_code).is_multiple_of(8) { + bits.push('0'); + } + if eol { + bits.push_str("000000000001"); + } + if tag { + bits.push('1'); + } + row.iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() + } + + /// Where [`group_3`] puts fill bits: none; so each row, its end-of-line code first, starts on a + /// byte; or so each end-of-line code ends on one (T.4's fill, as TIFF writes it). + #[derive(Clone, Copy, Debug, PartialEq)] + enum Fill { + None, + RowStart, + CodeEnd, + } + + /// `rows` text-like rows of `columns` samples as runs, white first, from `seed`: long white runs + /// and short black ones; a row may start black. + fn text_like(seed: u64, columns: u32, rows: u32) -> Vec> { + let mut seed = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + let mut random = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n) as u32 + }; + (0..rows) + .map(|_| { + let (mut runs, mut left) = (Vec::new(), columns); + while left > 0 { + let run = match runs.len() % 2 { + 0 if runs.is_empty() => random(31), + 0 => 1 + random(30), + _ => 1 + random(5), + }; + runs.push(run.min(left)); + left -= run.min(left); + } + runs + }) + .collect() + } + + /// Rows of runs, white first, packed as `decode_ccitt` gives them (white 1). + fn pack(runs: &[Vec], columns: u32) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut out = vec![0u8; stride * runs.len()]; + for (y, row) in runs.iter().enumerate() { + let mut x = 0; + for (i, run) in row.iter().enumerate() { + if i % 2 == 0 { + (x..x + *run as usize).for_each(|x| out[y * stride + x / 8] |= 0x80 >> (x % 8)); + } + x += *run as usize; + } + } + out + } + + /// Group 3 rows on bytes in each layout, over many pictures, as how a misreading comes out + /// depends on the picture: no codes (as TIFF's Modified Huffman has it), and `end_of_line` + /// without them; codes, each starting on a byte or ending on one, `end_of_line` or not. Each + /// decodes to its picture, and a byte short to fewer rows, not to other rows read another way. + /// Group 4 rows on bytes keep their aligned read, `end_of_line` or not. + #[test] + fn group_3_rows_on_bytes_read_by_the_codes_in_the_data() { + for seed in 0..32u64 { + let (columns, rows) = (8 + (seed as u32 * 37) % 200, 2 + seed as u32 % 20); + let runs = text_like(seed, columns, rows); + let picture = pack(&runs, columns); + for k in [0, 1] { + for (codes, fill, end_of_line) in [ + (false, Fill::RowStart, false), + (false, Fill::RowStart, true), + (true, Fill::RowStart, false), + (true, Fill::RowStart, true), + (true, Fill::CodeEnd, false), + (true, Fill::CodeEnd, true), + ] { + let p = CcittParams { k, columns: i64::from(columns), end_of_line, encoded_byte_align: true, ..CcittParams::default() }; + let mut data = group_3(&runs, codes, k > 0, fill); + let case = format!("seed {seed}, {columns} × {rows}, k {k}, codes {codes}, fill {fill:?}, end_of_line {end_of_line}"); + assert_eq!(decode_ccitt(&data, &p, rows).unwrap(), picture, "{case}"); + data.pop(); + assert!(decode_ccitt(&data, &p, rows).unwrap().len() < picture.len(), "{case}, a byte short"); + } + } + } + // White rows, each the one code 1 (vertical, no change) and its fill. + for end_of_line in [false, true] { + let p = CcittParams { k: -1, columns: 64, end_of_line, encoded_byte_align: true, ..CcittParams::default() }; + assert_eq!(decode_ccitt(&[0x80; 16], &p, 16).unwrap(), [0xFF; 8 * 16], "Group 4, end_of_line {end_of_line}"); + } + } + + proptest::proptest! { + /// Any bitmap, of noise or of runs, decodes to its own bits, through an independent decoder + /// and through `decode_ccitt`. + #[test] + fn bitmaps_round_trip( + columns in 1u32..400, + rows in 1u32..24, + runs in proptest::collection::vec(0u8..=255, 1..48), + noise in proptest::bool::ANY, + ) { + let data = bitmap(columns, rows, &runs, noise); + let coded = encode_g4(&data, columns, rows).unwrap(); + proptest::prop_assert_eq!(decode(&coded, columns, rows), unpack(&data, columns, rows)); + let decoded = decode_ccitt(&coded, &g4(columns), rows).unwrap(); + proptest::prop_assert_eq!(unpack(&decoded, columns, rows), unpack(&data, columns, rows)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns( + data in proptest::collection::vec(0u8..=255, 0..600), + columns in 0u32..40_000, + rows in 0u32..80, + k in -2i64..3, + end_of_line in proptest::bool::ANY, + encoded_byte_align in proptest::bool::ANY, + ) { + let _ = encode_g4(&data, columns, rows); + let p = CcittParams { k, columns: i64::from(columns), end_of_line, encoded_byte_align, ..CcittParams::default() }; + if let Ok(out) = decode_ccitt(&data, &p, rows) { + proptest::prop_assert!(out.len() <= (columns as usize).div_ceil(8) * rows as usize); + } + } + } +} diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs new file mode 100644 index 0000000000..343fbbd27d --- /dev/null +++ b/crates/filters/src/jbig2.rs @@ -0,0 +1,368 @@ +//! JBIG2 encoding (ITU-T T.88 | ISO/IEC 14492), for `JBIG2Decode` (ISO 32000-2 §7.4.7), lossless: +//! a generic region, template 0 with its nominal adaptive pixels, typical prediction on, MQ +//! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]). Written from +//! the standard's 6.2.5 (coding a generic region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 +//! (the segments) and D.3 (the embedded organisation PDF uses). + +mod symbols; + +use crate::FilterError; +use crate::mq::{Context, MqEncoder}; + +/// The widest row [`encode_jbig2_generic`] encodes. JBIG2 allows 32-bit widths; this keeps a +/// row's buffers small and its positions in range. +pub const MAX_JBIG2_COLUMNS: u32 = 1 << 20; + +/// Template 0's pixels (Figure 3), as (x, y) offsets from the pixel being coded, each a bit of the +/// context in this order: the four on the current row, the five fixed ones on the row above, the +/// three on the row above that, then the adaptive pixels A1–A4 at their nominal places (Table 5). +const TEMPLATE: [(i32, i32); 16] = [ + (-1, 0), + (-2, 0), + (-3, 0), + (-4, 0), + (-2, -1), + (-1, -1), + (0, -1), + (1, -1), + (2, -1), + (-1, -2), + (0, -2), + (1, -2), + (3, -1), + (-3, -1), + (2, -2), + (-2, -2), +]; + +/// The context that codes SLTP when template 0 is used (Figure 8): those pixel values, in +/// [`TEMPLATE`]'s order. Figure 8 reads, by row from x = -4: `0 1 0 1` on the current row, +/// `0 1 1 0 0 1 0` (x = -3 to 3) above it, `1 0 0 1 1` (x = -2 to 2) above that. +const SLTP: [u8; 16] = [1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1]; + +/// Columns of padding either side of a row, so every template pixel of a row's pixels is in it. +const PAD: usize = 4; + +/// `rows` rows of `columns` 1-bit PDF samples as lossless JBIG2, the smaller of two codings: +/// [`encode_jbig2_generic`]'s, or one that stores each distinct mark of the image once, as a +/// symbol, and places its copies (see [`symbols`]). Either way the stream is in the embedded +/// organisation and decodes to the same bits without `/JBIG2Globals`. +/// +/// Errors: as [`encode_jbig2_generic`]. +pub fn encode_jbig2(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + let generic = encode_jbig2_generic(data, columns, rows)?; + // `encode_jbig2_generic` checked that the rows are there. + let stride = (columns as usize).div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)); + Ok(match samples.and_then(|s| symbols::encode(s, columns, rows)) { + Some(coded) if coded.len() < generic.len() => coded, + _ => generic, + }) +} + +/// `rows` rows of `columns` 1-bit PDF samples (most significant bit first, each row starting on +/// a new byte), as a JBIG2 stream in the embedded organisation: a page information segment and an +/// immediate lossless generic region segment, which `JBIG2Decode` decodes to the same bits +/// without `/JBIG2Globals`. +/// +/// A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, as a grey image's +/// samples have it. Bytes after the last row are ignored, as are the bits that pad a row to a whole +/// byte. +/// +/// Errors: no rows or columns, or rows wider than [`MAX_JBIG2_COLUMNS`] +/// ([`FilterError::Unsupported`]); fewer bytes than the rows need ([`FilterError::Corrupt`]). +pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + if columns == 0 || rows == 0 || columns > MAX_JBIG2_COLUMNS { + return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples"))); + } + let width = columns as usize; + let stride = width.div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt { + filter: "JBIG2Decode", + detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()), + })?; + let mut contexts: Vec = vec![0; 1 << 16]; + let mut mq = MqEncoder::new(); + let height = samples.len() / stride; + code_generic(&mut mq, &mut contexts, width, height, true, |y, row| { + let packed = samples.get(y * stride..).unwrap_or_default(); + for (x, pixel) in row.iter_mut().enumerate() { + let sample = packed.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + *pixel = u8::from(!sample); + } + }); + let region = mq.finish(); + let mut out = Vec::with_capacity(region.len() + 64); + segment(&mut out, 0, 48, &[], false, &page_information(columns, rows)); + segment(&mut out, 1, 39, &[], false, &generic_region(columns, rows, 0, 0, ®ion)); + Ok(out) +} + +/// A page information segment's data (7.4.8): the page's size, resolution unknown, eventually +/// lossless, default pixel 0 (white), OR combination, not striped. +fn page_information(columns: u32, rows: u32) -> Vec { + let mut page = Vec::with_capacity(19); + page.extend_from_slice(&columns.to_be_bytes()); + page.extend_from_slice(&rows.to_be_bytes()); + page.extend_from_slice(&[0; 8]); + page.push(0x01); + page.extend_from_slice(&[0, 0]); + page +} + +/// A region segment information field (7.4.1): a `width` × `height` region at (`x`, `y`) on the +/// page, combined with it by OR. +fn region_information(out: &mut Vec, width: u32, height: u32, x: u32, y: u32) { + for v in [width, height, x, y] { + out.extend_from_slice(&v.to_be_bytes()); + } + out.push(0); +} + +/// An immediate lossless generic region segment's data (7.4.6): its region information, flags +/// (MMR 0, template 0, TPGDON 1), the AT pixels at their nominal places, then `coded`. +fn generic_region(width: u32, height: u32, x: u32, y: u32, coded: &[u8]) -> Vec { + let mut data = Vec::with_capacity(26 + coded.len()); + region_information(&mut data, width, height, x, y); + data.push(0x08); + at_pixels(&mut data); + data.extend_from_slice(coded); + data +} + +/// Template 0's four AT pixels at their nominal places, as AT flags (7.4.6.3, 7.4.2.1.2). +fn at_pixels(out: &mut Vec) { + for (x, y) in &TEMPLATE[12..] { + out.push(*x as i8 as u8); + out.push(*y as i8 as u8); + } +} + +/// A segment (7.2): its header (number, type, the segments it refers to, page 1, data length), +/// then its data. `retained`: a later segment refers to this one. `refers_to` holds each +/// referred-to segment's number and whether a segment after this one refers to it too (its +/// retain bit, 7.2.4). Segment numbers here stay below 256, so each referred-to one takes a byte +/// (7.2.5); a segment refers to at most four, so their count and retain bits take one byte too. +fn segment(out: &mut Vec, number: u32, kind: u8, refers_to: &[(u8, bool)], retained: bool, data: &[u8]) { + out.extend_from_slice(&number.to_be_bytes()); + out.push(kind); + // At most four, so the count fits its three bits. + let refers_to = refers_to.get(..refers_to.len().min(4)).unwrap_or_default(); + let kept = refers_to.iter().enumerate().fold(0u8, |bits, (k, (_, kept))| bits | u8::from(*kept) << (k + 1)); + out.push((refers_to.len() as u8) << 5 | kept | u8::from(retained)); + out.extend(refers_to.iter().map(|(n, _)| *n)); + out.push(1); + // A segment's data never comes near 4 GiB here: a row is at most 1 MiB wide. + out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); + out.extend_from_slice(data); +} + +/// Codes a `width` × `height` bitmap as a generic region (6.2.5.7) with template 0 and the nominal +/// AT pixels, its decisions in `contexts` (2^16 of them), with typical prediction when `typical` +/// (TPGDON). `fill(y, row)` sets row `y`'s `width` pixels, 1 for black. Pixels outside the bitmap +/// count as 0 (white), as the decoder takes them. +fn code_generic(mq: &mut MqEncoder, contexts: &mut [Context], width: usize, height: usize, typical: bool, mut fill: impl FnMut(usize, &mut [u8])) { + let sltp = SLTP.iter().enumerate().fold(0usize, |cx, (k, v)| cx | usize::from(*v) << k); + // The two rows above the current one and the current one, as JBIG2 pixels (1 black), padded. + let blank = vec![0u8; width + 2 * PAD]; + let (mut above2, mut above1) = (blank.clone(), blank.clone()); + let mut row = blank; + let mut ltp = false; + for y in 0..height { + if let Some(pixels) = row.get_mut(PAD..PAD + width) { + fill(y, pixels); + } + if typical { + // Typical prediction (6.2.5.5): a row the same as the one above is coded as one decision. + let same = row == above1; + if let Some(cx) = contexts.get_mut(sltp) { + mq.encode(cx, same != ltp); + } + ltp = same; + } + if !ltp { + for x in 0..width { + let at = |dx: i32, dy: i32| { + let line = match dy { + 0 => &row, + -1 => &above1, + _ => &above2, + }; + let i = (PAD + x).checked_add_signed(dx as isize); + i.and_then(|i| line.get(i)).copied().unwrap_or(0) + }; + let cx = TEMPLATE.iter().enumerate().fold(0usize, |cx, (k, (dx, dy))| cx | usize::from(at(*dx, *dy)) << k); + if let Some(state) = contexts.get_mut(cx) { + mq.encode(state, row.get(PAD + x) == Some(&1)); + } + } + } + std::mem::swap(&mut above2, &mut above1); + std::mem::swap(&mut above1, &mut row); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rows an independent decoder (`hayro-jbig2`) reads from `data`, as PDF samples: true + /// for white (a JBIG2 0). + pub(super) fn decode(data: &[u8]) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.1.push(!black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(!black, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let image = hayro_jbig2::Image::new_embedded(data, None).unwrap(); + let mut rows = Rows(Vec::new(), Vec::new(), image.width() as usize); + image.decode(&mut rows).unwrap(); + rows.0 + } + + /// The rows of packed samples `data`, true for a 1 bit. + pub(super) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { + let stride = (columns as usize).div_ceil(8); + (0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect() + } + + /// Rows of `columns` samples from `runs`: alternating white and black runs one longer than + /// those values (never empty, even when shrinking makes them all 0), until the rows are full; + /// or, with `noise`, the bytes of `runs` as they are. + fn bitmap(columns: u32, rows: u32, runs: &[u8], noise: bool) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut data = vec![0u8; stride * rows as usize]; + if runs.is_empty() { + return data; + } + if noise { + data.iter_mut().zip(runs.iter().cycle()).for_each(|(d, r)| *d = *r); + return data; + } + let (mut white, mut left, mut next) = (true, 1 + usize::from(runs[0]), 1); + for y in 0..rows as usize { + for x in 0..columns as usize { + if left == 0 { + white = !white; + left = 1 + usize::from(runs[next % runs.len()]); + next += 1; + } + if white { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + left -= 1; + } + } + data + } + + #[test] + fn a_page_of_text_like_marks_round_trips_and_shrinks() { + let (w, h) = (1700u32, 220u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in (20..h as usize).step_by(40) { + for dy in 0..12 { + for x in (100..1600).step_by(9) { + if (x / 9 + dy) % 5 != 0 { + data[(y + dy) * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + } + let jbig2 = encode_jbig2_generic(&data, w, h).unwrap(); + assert_eq!(decode(&jbig2), unpack(&data, w, h)); + let g4 = crate::encode_g4(&data, w, h).unwrap(); + assert!(jbig2.len() < g4.len(), "JBIG2 {} bytes, Group 4 {}", jbig2.len(), g4.len()); + } + + #[test] + fn edges_padding_and_typical_rows_round_trip() { + // 3 columns: the last 5 bits of each byte pad the row; set them to anything. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010, 0xAB, 0xCD]; + assert_eq!(decode(&encode_jbig2_generic(&data, 3, 3).unwrap()), [[true, false, true], [false, true, false], [true, true, true]]); + for (w, h, fill) in [(1, 1, 0x00), (1, 1, 0xFF), (13, 9, 0x00), (13, 9, 0xFF), (64, 40, 0x5A)] { + let data = vec![fill; (w as usize).div_ceil(8) * h as usize]; + assert_eq!(decode(&encode_jbig2_generic(&data, w, h).unwrap()), unpack(&data, w, h), "{w} × {h} of {fill:#04x}"); + } + } + + /// Packed samples from JBIG2 pixels (1 black): a 0 sample for each 1. + pub(super) fn samples(pixels: &[Vec]) -> Vec { + let width = pixels.first().map_or(0, Vec::len); + let stride = width.div_ceil(8); + let mut data = vec![0xFF; stride * pixels.len()]; + for (y, row) in pixels.iter().enumerate() { + for (x, p) in row.iter().enumerate() { + if *p == 1 { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + data + } + + /// A row the same as the one above is typical (6.2.5.5): one decision, not a row of pixels. + #[test] + fn a_repeated_row_is_one_decision() { + let row: Vec = (0..800u32).map(|x| u8::from((x * 7919 + x / 3) % 5 < 2)).collect(); + let pixels = vec![row; 1000]; + let data = samples(&pixels); + crate::mq::DECISIONS.with(|n| n.set(0)); + let jbig2 = encode_jbig2_generic(&data, 800, 1000).unwrap(); + // The first row's 800 pixels, and one typical-row decision for each of the 1000 rows. + assert_eq!(crate::mq::DECISIONS.with(|n| n.get()), 800 + 1000); + assert_eq!(decode(&jbig2), unpack(&data, 800, 1000)); + } + + /// SLTP is coded in the context Figure 8 shows, which ordinary pixels can share: an image + /// tiled with that very neighbourhood decodes only if both coders use the same context. + #[test] + fn the_typical_row_flag_shares_figure_8s_context() { + // Figure 8, around a pixel at x = 4 of an 8-column tile: two rows up x = 2..6, one row up + // x = 1..7, the pixel's own row x = 0..3. + let tile = [[0, 0, 1, 0, 0, 1, 1, 0], [0, 0, 1, 1, 0, 0, 1, 0], [0, 1, 0, 1, 1, 0, 0, 0]]; + let pixels: Vec> = (0..90).map(|y| (0..400).map(|x| tile[y % 3][x % 8]).collect()).collect(); + let data = samples(&pixels); + assert_eq!(decode(&encode_jbig2_generic(&data, 400, 90).unwrap()), unpack(&data, 400, 90)); + } + + #[test] + fn what_it_cannot_encode_is_an_error() { + assert!(matches!(encode_jbig2_generic(&[0; 8], 0, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 8], 8, 0), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 8], MAX_JBIG2_COLUMNS + 1, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes"); + assert!(matches!(encode_jbig2_generic(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. }))); + } + + proptest::proptest! { + /// Any bitmap, of noise or of runs, decodes to its own bits through an independent decoder. + #[test] + fn bitmaps_round_trip( + columns in 1u32..300, + rows in 1u32..24, + runs in proptest::collection::vec(0u8..=255, 1..48), + noise in proptest::bool::ANY, + ) { + let data = bitmap(columns, rows, &runs, noise); + let jbig2 = encode_jbig2_generic(&data, columns, rows).unwrap(); + proptest::prop_assert_eq!(decode(&jbig2), unpack(&data, columns, rows)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) { + let _ = encode_jbig2_generic(&data, columns, rows); + } + } +} diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs new file mode 100644 index 0000000000..e9fed24441 --- /dev/null +++ b/crates/filters/src/jbig2/symbols.rs @@ -0,0 +1,1029 @@ +//! JBIG2 symbol coding, lossless: each distinct mark of an image (a group of black pixels that +//! touch, side by side or at a corner) is stored once, as a symbol in a symbol dictionary, and +//! text regions place a copy of it wherever it occurs. Marks too large to be symbols (rules, +//! frames, pictures) are left to a generic region beside them, and so, in [`encode`]'s second +//! plan, are marks that occur only once. Only exact copies share a symbol, so the image decodes to the +//! same bits. Written from the committee draft's 6.4 (the text region decoding procedure), 6.5 +//! (the symbol dictionary decoding procedure), 7.4.2 and 7.4.3 (their segments) and Annex A (the +//! arithmetic integer coders). + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::ops::Range; + +use super::{at_pixels, code_generic, generic_region, page_information, region_information, segment}; +use crate::mq::{Context, MqEncoder}; + +/// The longest side of a mark stored as a symbol. A larger one is left to the generic region. +const MAX_SYMBOL_SIDE: u32 = 256; + +/// The most black runs looked at. Past it (halftones, noise) the image is left to generic coding, +/// which suits it better anyway; this bounds the memory the marks take. +const MAX_RUNS: usize = 1 << 20; + +/// The most distinct marks sorted into symbols, and the most pixels their bitmaps may hold +/// together: past either the image is left to generic coding. These bound the memory they take. +const MAX_SYMBOLS: usize = 1 << 16; +const MAX_SYMBOL_PIXELS: usize = 1 << 24; + +/// The most symbols one plan codes. The decoder PdfCraft renders JBIG2 with (`hayro-jbig2`) +/// refuses 32,768 symbols or more, and others may too, so a plan with more isn't made. +const MAX_CODED_SYMBOLS: usize = (1 << 15) - 1; + +/// The most symbol instances one text region places. The decoder PdfCraft renders JBIG2 with +/// refuses a text region of more than 10,000 (a page it then draws blank), so a page with more is +/// placed by several regions, each over a band of rows, all referring to the one dictionary. +const MAX_REGION_INSTANCES: usize = 10_000; + +// Segment numbers stay at most 256, so a referred-to one takes a byte (7.2.5): the page, the +// dictionary, a text region for each MAX_REGION_INSTANCES of at most MAX_RUNS marks, and the +// generic region. +const _: () = assert!(MAX_RUNS.div_ceil(MAX_REGION_INSTANCES) + 3 <= 256); + +/// The most rows symbol coded. With rows at most 2^20 samples wide, every coordinate and +/// difference coded then fits the 32-bit integers Annex A codes. +const MAX_ROWS: u32 = 1 << 24; + +/// The integer coders' ranges (Table A.1): how many 1 bits pick the range, how many bits give the +/// offset into it, and its first value. The last range has no 0 after its prefix. +const RANGES: [(u32, u32, u32); 6] = [(0, 2, 0), (1, 4, 4), (2, 6, 20), (3, 8, 84), (4, 12, 340), (5, 32, 4436)]; + +/// A run of black pixels on row `y`, from `x0` up to but not including `x1`. +#[derive(Clone, Copy, Debug)] +struct Run { + y: u32, + x0: u32, + x1: u32, +} + +/// A mark: its box (`x1` and `y1` just past it) and its runs, as a range of the run order +/// [`marks`] gives. +#[derive(Debug)] +struct Mark { + x0: u32, + x1: u32, + y0: u32, + y1: u32, + runs: Range, +} + +/// A distinct mark's bitmap, a byte a pixel (1 black), and how many marks are exact copies of it. +#[derive(Debug)] +struct Symbol { + width: u32, + height: u32, + pixels: Vec, + uses: u32, +} + +/// An image's marks, sorted out: its black runs, its marks with the run order they index, the +/// distinct marks of at most [`MAX_SYMBOL_SIDE`] a side as symbols, each such mark as (its +/// symbol, the mark), and the marks too large to be symbols. +struct Page { + runs: Vec, + marks: Vec, + order: Vec, + symbols: Vec, + copies: Vec<(u32, u32)>, + large: Vec, +} + +/// A symbol instance (6.4.5): the symbol, and where its bottom-left pixel goes on the page, as S +/// (the column) and T (the row). `width` and `height` are the symbol's. +#[derive(Clone, Copy, Debug)] +struct Instance { + id: u32, + s: u32, + t: u32, + width: u32, + height: u32, +} + +/// `rows` rows of `columns` 1-bit PDF samples (black 0), symbol coded: the smaller of two plans, +/// every mark of at most [`MAX_SYMBOL_SIDE`] a side as a symbol, or only those with a copy. +/// `None` when neither plan has from two to [`MAX_CODED_SYMBOLS`] symbols, or the image is too +/// large or too busy to look at. +pub(super) fn encode(samples: &[u8], columns: u32, rows: u32) -> Option> { + let page = page(samples, columns, rows)?; + [1, 2].into_iter().filter_map(|copies| plan(&page, columns, rows, copies, None)).min_by_key(Vec::len) +} + +/// The image's marks and symbols; `None` past the limits above. +fn page(samples: &[u8], columns: u32, rows: u32) -> Option { + if rows > MAX_ROWS { + return None; + } + let width = usize::try_from(columns).ok()?; + let stride = width.div_ceil(8); + if stride == 0 { + return None; + } + let runs = black_runs(samples, width, stride)?; + let (marks, order) = marks(&runs); + let mut page = Page { runs, marks, order, symbols: Vec::new(), copies: Vec::new(), large: Vec::new() }; + symbols(&mut page)?; + Some(page) +} + +/// The black runs of each row (0 samples), row by row and left to right; `None` past [`MAX_RUNS`]. +fn black_runs(samples: &[u8], width: usize, stride: usize) -> Option> { + let black = |row: &[u8], x: usize| row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) == 0); + let mut runs = Vec::new(); + for (y, row) in samples.chunks_exact(stride).enumerate() { + let y = u32::try_from(y).ok()?; + let mut x = 0; + while x < width { + // Whole white bytes at once. + if x % 8 == 0 && row.get(x / 8) == Some(&0xFF) { + x += 8; + continue; + } + if !black(row, x) { + x += 1; + continue; + } + let start = x; + while x < width && black(row, x) { + x += 1; + } + if runs.len() == MAX_RUNS { + return None; + } + runs.push(Run { y, x0: u32::try_from(start).ok()?, x1: u32::try_from(x).ok()? }); + } + } + Some(runs) +} + +/// The marks the runs make, in the order their first runs come (so from the top), and the run +/// order: the runs' indexes, mark by mark, each mark's in row order. +fn marks(runs: &[Run]) -> (Vec, Vec) { + // Union–find over the runs; a set's root is its first run, as the larger root always joins + // the smaller. Runs on adjacent rows join when they overlap, counting a corner. + let mut parent: Vec = (0..runs.len()).map(|i| i as u32).collect(); + let (mut above, mut above_at, mut above_y): (&[Run], usize, Option) = (&[], 0, None); + let mut at = 0; + for row in runs.chunk_by(|a, b| a.y == b.y) { + let y = row.first().map(|r| r.y); + if above_y.and_then(|a| a.checked_add(1)) == y { + let mut i = 0; + for (j, b) in row.iter().enumerate() { + while above.get(i).is_some_and(|a| a.x1 < b.x0) { + i += 1; + } + let mut k = i; + while above.get(k).is_some_and(|a| a.x0 <= b.x1) { + union(&mut parent, above_at + k, at + j); + k += 1; + } + } + } + (above, above_at, above_y) = (row, at, y); + at += row.len(); + } + let mut mark_of = vec![0u32; runs.len()]; + let mut marks: Vec = Vec::new(); + let mut counts: Vec = Vec::new(); + for (i, r) in runs.iter().enumerate() { + let root = find_root(&mut parent, i); + let m = if root == i { + marks.push(Mark { x0: r.x0, x1: r.x1, y0: r.y, y1: r.y.saturating_add(1), runs: 0..0 }); + counts.push(0); + marks.len() - 1 + } else { + mark_of.get(root).map_or(0, |m| *m as usize) + }; + if let Some(slot) = mark_of.get_mut(i) { + *slot = m as u32; + } + if let (Some(mark), Some(count)) = (marks.get_mut(m), counts.get_mut(m)) { + mark.x0 = mark.x0.min(r.x0); + mark.x1 = mark.x1.max(r.x1); + mark.y1 = mark.y1.max(r.y.saturating_add(1)); + *count += 1; + } + } + let mut next = 0; + for (mark, count) in marks.iter_mut().zip(&counts) { + mark.runs = next..next + count; + next += count; + } + let mut order = vec![0u32; runs.len()]; + let mut fill: Vec = marks.iter().map(|m| m.runs.start).collect(); + for (i, m) in mark_of.iter().enumerate() { + if let Some(n) = fill.get_mut(*m as usize) { + if let Some(slot) = order.get_mut(*n) { + *slot = i as u32; + } + *n += 1; + } + } + (marks, order) +} + +/// The root of `i`'s set, halving the path to it as it goes. Parents are never above their +/// children, so the walk ends. +fn find_root(parent: &mut [u32], mut i: usize) -> usize { + loop { + let Some(&p) = parent.get(i) else { return i }; + let p = p as usize; + if p == i { + return i; + } + let grandparent = parent.get(p).copied().unwrap_or(p as u32); + if let Some(slot) = parent.get_mut(i) { + *slot = grandparent; + } + i = grandparent as usize; + } +} + +fn union(parent: &mut [u32], a: usize, b: usize) { + let (a, b) = (find_root(parent, a), find_root(parent, b)); + if let Some(slot) = parent.get_mut(a.max(b)) { + *slot = a.min(b) as u32; + } +} + +/// Each mark's own pixels in its box: those of its runs, not of other marks the box overlaps. +fn pixels(mark: &Mark, runs: &[Run], order: &[u32]) -> Vec { + // A mark's box holds its runs, so these don't go below 0; a symbol is at most 256 a side. + let width = mark.x1.saturating_sub(mark.x0) as usize; + let height = mark.y1.saturating_sub(mark.y0) as usize; + let mut pixels = vec![0u8; width * height]; + for r in order.get(mark.runs.clone()).unwrap_or_default().iter().filter_map(|i| runs.get(*i as usize)) { + let row = r.y.saturating_sub(mark.y0) as usize * width; + let from = row + r.x0.saturating_sub(mark.x0) as usize; + let to = row + r.x1.saturating_sub(mark.x0) as usize; + pixels.get_mut(from..to).unwrap_or_default().fill(1); + } + pixels +} + +/// Sorts the marks into symbols: exact copies share one. `None` past [`MAX_SYMBOLS`] or +/// [`MAX_SYMBOL_PIXELS`]. +fn symbols(page: &mut Page) -> Option<()> { + let mut by_hash: HashMap> = HashMap::new(); + let mut kept = 0usize; + for (m, mark) in page.marks.iter().enumerate() { + let m = u32::try_from(m).ok()?; + let (width, height) = (mark.x1.saturating_sub(mark.x0), mark.y1.saturating_sub(mark.y0)); + if width > MAX_SYMBOL_SIDE || height > MAX_SYMBOL_SIDE { + page.large.push(m); + continue; + } + let pixels = pixels(mark, &page.runs, &page.order); + let mut hasher = DefaultHasher::new(); + (width, height, &pixels).hash(&mut hasher); + let same = by_hash.entry(hasher.finish()).or_default(); + let symbols = &mut page.symbols; + let known = + same.iter().copied().find(|s| symbols.get(*s as usize).is_some_and(|s| (s.width, s.height) == (width, height) && s.pixels == pixels)); + let id = match known { + Some(id) => id, + None => { + kept = kept.checked_add(pixels.len())?; + if kept > MAX_SYMBOL_PIXELS || symbols.len() == MAX_SYMBOLS { + return None; + } + let id = u32::try_from(symbols.len()).ok()?; + symbols.push(Symbol { width, height, pixels, uses: 0 }); + same.push(id); + id + } + }; + if let Some(s) = symbols.get_mut(id as usize) { + s.uses = s.uses.saturating_add(1); + } + page.copies.push((id, m)); + } + Some(()) +} + +/// One plan's stream: the symbols with at least `copies` uses in a symbol dictionary, text +/// regions placing them (in strips of 2^`log_strips` rows, or whichever size codes smallest), and +/// the other marks in a generic region. `None` with fewer than two such symbols (their IDs would +/// take no bits at all, SBSYMCODELEN 0, a corner of the standard left alone here) or more than +/// [`MAX_CODED_SYMBOLS`]. +fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option) -> Option> { + let symbol = |s: u32| page.symbols.get(s as usize); + // The dictionary's order (6.5.5): height classes from the shortest, each narrowest first. + let mut kept: Vec = (0..page.symbols.len() as u32).filter(|s| symbol(*s).is_some_and(|s| s.uses >= copies)).collect(); + if kept.len() < 2 || kept.len() > MAX_CODED_SYMBOLS { + return None; + } + kept.sort_by_key(|s| symbol(*s).map(|s| (s.height, s.width))); + let mut id = vec![None; page.symbols.len()]; + for (k, s) in kept.iter().enumerate() { + if let Some(slot) = id.get_mut(*s as usize) { + *slot = u32::try_from(k).ok(); + } + } + let mut instances = Vec::new(); + let mut left = page.large.clone(); + for (s, m) in &page.copies { + match (id.get(*s as usize).copied().flatten(), page.marks.get(*m as usize), symbol(*s)) { + (Some(id), Some(mark), Some(symbol)) => { + instances.push(Instance { id, s: mark.x0, t: mark.y1.saturating_sub(1), width: symbol.width, height: symbol.height }) + } + _ => left.push(*m), + } + } + let kept: Vec<&Symbol> = kept.iter().filter_map(|s| symbol(*s)).collect(); + let dictionary = symbol_dictionary(&kept)?; + let count = u32::try_from(kept.len()).ok()?; + // Bands of at most MAX_REGION_INSTANCES, from the top. + instances.sort_by_key(|i| (i.t, i.s)); + let mut regions = Vec::new(); + for band in instances.chunks_mut(MAX_REGION_INSTANCES) { + let top = band.iter().map(|i| i.t.saturating_add(1).saturating_sub(i.height)).min()?; + let height = band.iter().map(|i| i.t).max()?.checked_sub(top)?.checked_add(1)?; + regions.push(match log_strips { + Some(l) => text_region(band, count, columns, top, height, l)?, + None => (0..4).filter_map(|l| text_region(band, count, columns, top, height, l)).min_by_key(Vec::len)?, + }); + } + let mut out = Vec::new(); + segment(&mut out, 0, 48, &[], false, &page_information(columns, rows)); + segment(&mut out, 1, 0, &[], true, &dictionary); + let mut number = 2; + for (k, text) in regions.iter().enumerate() { + // The dictionary stays retained for the next region, until the last (7.2.4). + segment(&mut out, number, 7, &[(1, k + 1 < regions.len())], false, text); + number += 1; + } + if !left.is_empty() { + segment(&mut out, number, 39, &[], false, &leftover_region(&left, page)?); + } + Some(out) +} + +/// A symbol dictionary segment's data (7.4.2) defining `symbols` (in dictionary order) and +/// exporting them all: arithmetic coded, no refinement or aggregation, each bitmap a generic +/// region with template 0, its nominal AT pixels and no typical prediction (Table 16), the +/// bitmaps sharing one set of contexts. +fn symbol_dictionary(symbols: &[&Symbol]) -> Option> { + let count = u32::try_from(symbols.len()).ok()?; + let mut data = vec![0, 0]; + at_pixels(&mut data); + data.extend_from_slice(&count.to_be_bytes()); + data.extend_from_slice(&count.to_be_bytes()); + let mut mq = MqEncoder::new(); + let mut contexts: Vec = vec![0; 1 << 16]; + let (mut iadh, mut iadw, mut iaex) = (IntCoder::new(), IntCoder::new(), IntCoder::new()); + let mut height = 0; + for class in symbols.chunk_by(|a, b| a.height == b.height) { + let h = class.first()?.height; + iadh.encode(&mut mq, Some(int(i64::from(h) - i64::from(height))?)); + height = h; + let mut width = 0; + for s in class { + iadw.encode(&mut mq, Some(int(i64::from(s.width) - i64::from(width))?)); + width = s.width; + let w = s.width as usize; + code_generic(&mut mq, &mut contexts, w, s.height as usize, false, |y, row| { + let line = s.pixels.get(y * w..).unwrap_or_default(); + row.iter_mut().zip(line).for_each(|(p, v)| *p = *v); + }); + } + iadw.encode(&mut mq, None); + } + // The export flags (6.5.10): a run of no symbols not exported, then a run of all of them. + iaex.encode(&mut mq, Some(0)); + iaex.encode(&mut mq, Some(int(i64::from(count))?)); + data.extend_from_slice(&mq.finish()); + Some(data) +} + +/// An immediate lossless text region segment's data (7.4.3) placing `instances` in the band of +/// `height` rows from row `top` (which holds them): arithmetic coded, no refinement, strips of +/// 2^`log_strips` rows, each symbol's bottom-left pixel at (S, T) (REFCORNER BOTTOMLEFT, so a +/// line's letters share their T), not transposed, OR, default pixel 0, SBDSOFFSET 0. Sorts +/// `instances` into strips, each left to right. +fn text_region(instances: &mut [Instance], symbols: u32, columns: u32, top: u32, height: u32, log_strips: u32) -> Option> { + // LOGSBSTRIPS has two bits: strips of 1, 2, 4 or 8 rows. + if log_strips > 3 { + return None; + } + let strips = 1i64 << log_strips; + // T counts from the band's top row. + let t = |i: &Instance| i.t.saturating_sub(top); + instances.sort_by_key(|i| (t(i) >> log_strips, i.s)); + let mut data = Vec::new(); + region_information(&mut data, columns, height, 0, top); + data.extend_from_slice(&((log_strips as u16) << 2).to_be_bytes()); + data.extend_from_slice(&u32::try_from(instances.len()).ok()?.to_be_bytes()); + // SBSYMCODELEN (Table 31): ⌈log2 SBNUMSYMS⌉. + let length = symbols.checked_next_power_of_two()?.trailing_zeros(); + let mut ids: Vec = vec![0; 1 << length]; + let (mut iadt, mut iafs, mut iads, mut iait) = (IntCoder::new(), IntCoder::new(), IntCoder::new(), IntCoder::new()); + let mut mq = MqEncoder::new(); + // The initial STRIPT (6.4.5 step 2) is 0, so each strip's delta T is its first row over the + // strip size, from the strip before. + iadt.encode(&mut mq, Some(0)); + let (mut strip_t, mut first_s) = (0i64, 0i64); + for strip in instances.chunk_by(|a, b| t(a) >> log_strips == t(b) >> log_strips) { + let first = i64::from(t(strip.first()?) >> log_strips << log_strips); + iadt.encode(&mut mq, Some(int((first - strip_t) / strips)?)); + strip_t = first; + let mut cur_s = 0; + for (k, instance) in strip.iter().enumerate() { + let s = i64::from(instance.s); + if k == 0 { + iafs.encode(&mut mq, Some(int(s - first_s)?)); + first_s = s; + } else { + iads.encode(&mut mq, Some(int(s - cur_s)?)); + } + if strips > 1 { + iait.encode(&mut mq, Some(int(i64::from(t(instance)) - strip_t)?)); + } + encode_id(&mut mq, &mut ids, length, instance.id); + // The next S counts from this symbol's last column (6.4.5 step 3c x). + cur_s = s + i64::from(instance.width) - 1; + } + iads.encode(&mut mq, None); + } + data.extend_from_slice(&mq.finish()); + Some(data) +} + +/// A generic region segment's data holding the marks `left`, over their box. +fn leftover_region(left: &[u32], page: &Page) -> Option> { + let left: Vec<&Mark> = left.iter().filter_map(|m| page.marks.get(*m as usize)).collect(); + let x0 = left.iter().map(|m| m.x0).min()?; + let y0 = left.iter().map(|m| m.y0).min()?; + let x1 = left.iter().map(|m| m.x1).max()?; + let y1 = left.iter().map(|m| m.y1).max()?; + let (width, height) = (x1.saturating_sub(x0) as usize, y1.saturating_sub(y0) as usize); + let stride = width.div_ceil(8); + // Packed, 1 for black: no larger than the image's own samples. + let mut packed = vec![0u8; stride.checked_mul(height)?]; + for mark in &left { + for r in page.order.get(mark.runs.clone()).unwrap_or_default().iter().filter_map(|i| page.runs.get(*i as usize)) { + let row = r.y.saturating_sub(y0) as usize * stride; + for x in r.x0.saturating_sub(x0) as usize..r.x1.saturating_sub(x0) as usize { + if let Some(b) = packed.get_mut(row + x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + } + let mut mq = MqEncoder::new(); + let mut contexts: Vec = vec![0; 1 << 16]; + code_generic(&mut mq, &mut contexts, width, height, true, |y, row| { + let line = packed.get(y * stride..).unwrap_or_default(); + for (x, p) in row.iter_mut().enumerate() { + *p = u8::from(line.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0)); + } + }); + Some(generic_region(u32::try_from(width).ok()?, u32::try_from(height).ok()?, x0, y0, &mq.finish())) +} + +fn int(v: i64) -> Option { + i32::try_from(v).ok() +} + +/// An arithmetic integer coder other than IAID (A.2): its 512 contexts, indexed by PREV. +struct IntCoder([Context; 512]); + +impl IntCoder { + fn new() -> Self { + IntCoder([0; 512]) + } + + /// Codes `value`, or OOB for `None`. + fn encode(&mut self, mq: &mut MqEncoder, value: Option) { + for (cx, d) in decisions(value) { + if let Some(state) = self.0.get_mut(cx) { + mq.encode(state, d); + } + } + } +} + +/// The decisions that code `value`, or OOB for `None`, with their contexts (A.2, Table A.1): the +/// sign, the prefix that picks the range, then the offset into it, most significant bit first; +/// OOB is a negative 0. Each decision's context is PREV: a leading 1 and the decisions before it, +/// at most the last eight once there are more. +fn decisions(value: Option) -> Vec<(usize, bool)> { + let (negative, magnitude) = value.map_or((true, 0), |v| (v < 0, v.unsigned_abs())); + let (ones, bits, first) = + RANGES.into_iter().find(|(_, bits, first)| *bits == 32 || u64::from(magnitude) < u64::from(*first) + (1 << bits)).unwrap_or(RANGES[5]); + let offset = magnitude - first; + let mut d = vec![negative]; + d.extend(std::iter::repeat_n(true, ones as usize)); + if ones < 5 { + d.push(false); + } + d.extend((0..bits).rev().map(|i| offset >> i & 1 == 1)); + let mut prev = 1usize; + d.into_iter() + .map(|bit| { + let cx = prev; + prev = if prev < 256 { prev << 1 | usize::from(bit) } else { ((prev << 1 | usize::from(bit)) & 511) | 256 }; + (cx, bit) + }) + .collect() +} + +/// Codes symbol ID `id` in `length` bits, most significant first, through IAID (A.3): each +/// decision's context is a leading 1 and all the bits before it. +fn encode_id(mq: &mut MqEncoder, contexts: &mut [Context], length: u32, id: u32) { + let mut prev = 1usize; + for i in (0..length).rev() { + let d = id >> i & 1 == 1; + if let Some(state) = contexts.get_mut(prev) { + mq.encode(state, d); + } + prev = prev << 1 | usize::from(d); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jbig2::tests::{decode, samples, unpack}; + use crate::jbig2::{encode_jbig2, encode_jbig2_generic}; + + type Bitmap = Vec>; + + /// A small linear congruential generator, so the pictures are the same every run. + struct Lcg(u64); + + impl Lcg { + fn below(&mut self, n: usize) -> usize { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as usize % n.max(1) + } + } + + /// `count` different letter-like shapes: three strokes two pixels thick in a box of 6 to 18 + /// pixels a side, each after the first starting on one before it, so a shape is one mark; + /// trimmed to its pixels. + fn glyphs(count: usize, seed: u64) -> Vec { + let mut r = Lcg(seed); + let mut out: Vec = Vec::new(); + while out.len() < count { + let (w, h) = (6 + r.below(13), 6 + r.below(13)); + let mut g = vec![vec![0u8; w]; h]; + let (mut x, mut y) = (r.below(w), r.below(h)); + for _ in 0..3 { + let (dx, dy) = [(1, 0), (0, 1), (1, 1)][r.below(3)]; + let mut stroke = Vec::new(); + while x < w && y < h { + g[y][x] = 1; + g[y][(x + 1).min(w - 1)] = 1; + stroke.push((x, y)); + x += dx; + y += dy; + } + (x, y) = stroke[r.below(stroke.len())]; + } + let rows: Vec = (0..h).filter(|y| g[*y].contains(&1)).collect(); + let columns: Vec = (0..w).filter(|x| g.iter().any(|row| row[*x] == 1)).collect(); + let g: Bitmap = g[rows[0]..=rows[rows.len() - 1]].iter().map(|row| row[columns[0]..=columns[columns.len() - 1]].to_vec()).collect(); + if !out.contains(&g) { + out.push(g); + } + } + out + } + + /// Draws `glyph` with its top-left pixel at (`x`, `y`), clipped to the page, by OR. + fn draw(page: &mut Bitmap, glyph: &Bitmap, x: i64, y: i64) { + for (gy, row) in glyph.iter().enumerate() { + for (gx, p) in row.iter().enumerate() { + let (px, py) = (x + gx as i64, y + gy as i64); + if *p == 1 + && px >= 0 + && py >= 0 + && let Some(cell) = page.get_mut(py as usize).and_then(|r| r.get_mut(px as usize)) + { + *cell = 1; + } + } + } + } + + /// Lines of text: glyphs side by side, one to four pixels apart, each line's glyphs on its + /// baseline, the lines 30 rows apart; the first glyphs are the most common, as letters are. + fn text(columns: usize, rows: usize, glyphs: &[Bitmap], seed: u64) -> Bitmap { + let mut page = vec![vec![0u8; columns]; rows]; + let mut r = Lcg(seed); + for baseline in (24..rows).step_by(30) { + let mut x = 4 + r.below(8); + loop { + let common = r.below(glyphs.len()) + 1; + let g = &glyphs[r.below(common)]; + if x + g[0].len() > columns { + break; + } + draw(&mut page, g, x as i64, (baseline + 1 - g.len()) as i64); + x += g[0].len() + 1 + r.below(4); + } + } + page + } + + /// `n` glyphs anywhere, some over the edges, some over each other. + fn scatter(columns: usize, rows: usize, glyphs: &[Bitmap], seed: u64, n: usize) -> Bitmap { + let mut page = vec![vec![0u8; columns]; rows]; + let mut r = Lcg(seed); + for _ in 0..n { + let g = &glyphs[r.below(glyphs.len())]; + draw(&mut page, g, r.below(columns + 12) as i64 - 6, r.below(rows + 12) as i64 - 6); + } + page + } + + /// A segment's number and type, whether it is retained, and the segments it refers to, each + /// with its retain bit. + type Segment = (u32, u8, bool, Vec<(u8, bool)>); + + /// The segments of a stream in the embedded organisation, from their headers (7.2). + fn segments(data: &[u8]) -> Vec { + let (mut segments, mut at) = (Vec::new(), 0); + while at < data.len() { + let flags = data[at + 5]; + let refers = usize::from(flags >> 5); + let length_at = at + 6 + refers + 1; + let length = u32::from_be_bytes(data[length_at..length_at + 4].try_into().unwrap()) as usize; + let referred = data[at + 6..at + 6 + refers].iter().enumerate().map(|(k, n)| (*n, flags >> (k + 1) & 1 == 1)).collect(); + let number = u32::from_be_bytes(data[at..at + 4].try_into().unwrap()); + segments.push((number, data[at + 4] & 0x3F, flags & 1 == 1, referred)); + at = length_at + 4 + length; + } + segments + } + + fn segment_types(data: &[u8]) -> Vec { + segments(data).into_iter().map(|(_, kind, ..)| kind).collect() + } + + /// Every plan, at every strip size, decodes to the picture; returns how many plans there were. + fn every_plan_round_trips(picture: &Bitmap) -> usize { + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let data = samples(picture); + let page = page(&data, columns, rows).unwrap(); + let mut plans = 0; + for copies in [1, 2] { + for log_strips in 0..4 { + if let Some(coded) = plan(&page, columns, rows, copies, Some(log_strips)) { + assert_eq!(decode(&coded), unpack(&data, columns, rows), "{copies} copies, strips of {}", 1 << log_strips); + plans += 1; + } + } + } + plans + } + + /// Annex A.2's example (12 through IADW: the decisions 0101000, in contexts 1, 10, 101, …) + /// and Table A.1's codes. + #[test] + fn integers_are_coded_as_annex_a_says() { + assert_eq!(decisions(Some(12)), [(1, false), (2, true), (5, false), (10, true), (21, false), (42, false), (84, false)]); + let bits = |v| decisions(v).iter().map(|(_, d)| u8::from(*d)).collect::>(); + assert_eq!(bits(None), [1, 0, 0, 0], "OOB"); + assert_eq!(bits(Some(0)), [0, 0, 0, 0]); + assert_eq!(bits(Some(-1)), [1, 0, 0, 1]); + assert_eq!(bits(Some(-3)), [1, 0, 1, 1]); + assert_eq!(bits(Some(4)), [0, 1, 0, 0, 0, 0, 0]); + assert_eq!(bits(Some(-19)), [1, 1, 0, 1, 1, 1, 1]); + assert_eq!(bits(Some(83)), [0, 1, 1, 0, 1, 1, 1, 1, 1, 1]); + assert_eq!(bits(Some(4435))[..6], [0, 1, 1, 1, 1, 0]); + let big = bits(Some(4436)); + assert_eq!((big.len(), &big[..6]), (6 + 32, &[0, 1, 1, 1, 1, 1][..])); + assert!(big[6..].iter().all(|b| *b == 0)); + // PREV keeps a leading 1 and the last eight decisions, so it stays below 512, also when + // those eight are all 0 (PREV exactly 256), as in 4436's offset of 0. + for value in [Some(i32::MIN), Some(4436), Some(-4436), Some(4436 + 0x100), Some(i32::MAX), None] { + let contexts: Vec = decisions(value).iter().map(|(cx, _)| *cx).collect(); + assert!(contexts.iter().take(8).all(|cx| *cx < 256) && contexts.iter().skip(8).all(|cx| (256..512).contains(cx)), "{value:?}"); + } + assert!(decisions(Some(4436)).iter().any(|(cx, _)| *cx == 256)); + } + + /// Strips and first columns thousands of pixels apart need Annex A's 32-bit range: a mark at + /// the far right of a 12,000-pixel page, then one at the left below it (S back by 11,000), and + /// lines 5,000 rows apart. Each shape occurs twice, so both plans are made. + #[test] + fn coordinates_past_4435_round_trip() { + let shapes = glyphs(3, 4); + let mut picture = vec![vec![0u8; 12_000]; 40]; + for (k, x) in [11_000, 0, 5_000, 11_980].into_iter().enumerate() { + draw(&mut picture, &shapes[k % 2], x, 2 + 9 * k as i64); + } + assert_eq!(every_plan_round_trips(&picture), 8); + let mut tall = vec![vec![0u8; 30]; 10_050]; + for (k, y) in [0, 5_000, 10_030].into_iter().enumerate() { + draw(&mut tall, &shapes[k % 3], 3, y); + draw(&mut tall, &shapes[(k + 1) % 3], 15, y); + } + assert_eq!(every_plan_round_trips(&tall), 8); + } + + /// The padding bits of a row whose width isn't a multiple of 8 are ignored whatever they are: + /// black (0) padding, as many scans have, neither joins the marks nor stops symbol coding. + #[test] + fn black_padding_bits_are_ignored() { + for columns in [13, 241, 901] { + let picture = text(columns, 120, &glyphs(12, 6), 8); + let mut data = samples(&picture); + let stride = columns.div_ceil(8); + for row in data.chunks_mut(stride) { + row[stride - 1] &= 0xFF << (8 * stride - columns); + } + let (c, r) = (columns as u32, 120); + let page = page(&data, c, r).unwrap(); + assert!(page.marks.iter().all(|m| m.x1 as usize <= columns), "{columns} columns"); + for copies in [1, 2] { + if let Some(coded) = plan(&page, c, r, copies, None) { + assert_eq!(decode(&coded), unpack(&data, c, r), "{columns} columns"); + } + } + if columns > 13 { + assert_eq!(segment_types(&encode_jbig2(&data, c, r).unwrap())[1], 0, "{columns} columns: symbol coded"); + } + } + } + + /// Pixels touching only at a corner, down to the right or down to the left, are one mark + /// (8-connected); side by side with a white column between, two. A mark of 256 pixels a side + /// is a symbol; one 257 wide, or 257 tall, is left to the generic region. The diagonal and the + /// dot occur twice, so both plans are made. + #[test] + fn marks_join_at_corners_and_large_ones_are_left_out() { + let mut picture = vec![vec![0u8; 300]; 300]; + for i in 0..6 { + picture[10 + i][10 + i] = 1; + picture[10 + i][100 + i] = 1; + picture[10 + i][60 - i] = 1; + } + for row in &mut picture[0..257] { + row[280] = 1; + } + picture[30][10] = 1; + picture[30][12] = 1; + picture[40][0..256].fill(1); + picture[60][0..257].fill(1); + for row in &mut picture[44..300] { + row[290] = 1; + } + let data = samples(&picture); + let page = page(&data, 300, 300).unwrap(); + let boxes: Vec<(u32, u32, u32, u32)> = page.marks.iter().map(|m| (m.x0, m.y0, m.x1 - m.x0, m.y1 - m.y0)).collect(); + assert_eq!( + boxes, + [ + (280, 0, 1, 257), + (10, 10, 6, 6), + (55, 10, 6, 6), + (100, 10, 6, 6), + (10, 30, 1, 1), + (12, 30, 1, 1), + (0, 40, 256, 1), + (290, 44, 1, 256), + (0, 60, 257, 1) + ] + ); + let large: Vec<(u32, u32)> = page.large.iter().map(|m| (page.marks[*m as usize].x0, page.marks[*m as usize].y0)).collect(); + assert_eq!(large, [(280, 0), (0, 60)]); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Past 2^20 black runs or 2^24 rows the image is left to generic coding; at either cap it is + /// still looked at. + #[test] + fn busy_or_tall_images_are_left_to_generic_coding() { + // 0x55: four black pixels a byte, each its own run, under the same ones on the next row. + let columns = 1 << 20; + let row = vec![0x55u8; columns / 8]; + assert!(page(&row.repeat(2), columns as u32, 2).is_some(), "2^20 runs"); + assert!(page(&row.repeat(3), columns as u32, 3).is_none(), "1.5 × 2^20 runs"); + let tall = vec![0xFFu8; MAX_ROWS as usize + 1]; + assert!(page(&tall, 1, MAX_ROWS + 1).is_none()); + assert!(page(&tall, 1, MAX_ROWS).is_some()); + } + + /// A plan codes at most 32,767 symbols, which an independent decoder reads back; with more, + /// the second plan can still code the repeated ones. Past 65,536 distinct marks, or 2^24 + /// pixels of them, the image is left to generic coding; at either cap it is still looked at. + #[test] + fn too_many_symbols_are_left_to_generic_coding() { + // `n` distinct marks of 17 × 3 (full top and bottom rows joined by a code of 1 to n, in + // binary, on the row between), then `again` more with the codes 1 and 2 in turn. + let codes = |n: usize, again: usize| { + let (columns, rows) = (256 * 18, (n + again).div_ceil(256) * 4); + let mut picture = vec![vec![0u8; columns]; rows]; + for k in 0..n + again { + let (x, y) = (k % 256 * 18, k / 256 * 4); + let code = if k < n { k + 1 } else { (k - n) % 2 + 1 }; + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + for b in (0..17).filter(|b| code >> b & 1 == 1) { + picture[y + 1][x + b] = 1; + } + } + (samples(&picture), columns as u32, rows as u32) + }; + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS, 0); + let at_cap = page(&data, columns, rows).unwrap(); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); + // One more distinct mark, each once: neither plan is made. + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 0); + let past = page(&data, columns, rows).unwrap(); + assert!(plan(&past, columns, rows, 1, None).is_none() && plan(&past, columns, rows, 2, None).is_none()); + assert_eq!(encode_jbig2(&data, columns, rows).unwrap(), encode_jbig2_generic(&data, columns, rows).unwrap()); + // With two of them repeated, the second plan codes those two. + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 200); + let past = page(&data, columns, rows).unwrap(); + assert!(plan(&past, columns, rows, 1, None).is_none()); + assert_eq!(decode(&plan(&past, columns, rows, 2, None).unwrap()), unpack(&data, columns, rows)); + // Above a page of text whose letters repeat, that second plan is the one `encode_jbig2` + // keeps: the first, of every mark, would code too many symbols. + let mut picture: Bitmap = vec![vec![0u8; 256 * 18]; 512]; + for k in 0..=MAX_CODED_SYMBOLS { + let (x, y) = (k % 256 * 18, k / 256 * 4); + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + for b in (0..17).filter(|b| (k + 1) >> b & 1 == 1) { + picture[y + 1][x + b] = 1; + } + } + picture.extend(text(256 * 18, 600, &glyphs(40, 7), 11)); + let data = samples(&picture); + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let coded = encode_jbig2(&data, columns, rows).unwrap(); + assert_eq!(segment_types(&coded)[1], 0, "symbol coded"); + assert_eq!(decode(&coded), unpack(&data, columns, rows)); + let (data, columns, rows) = codes(MAX_SYMBOLS, 0); + assert_eq!(page(&data, columns, rows).map(|p| p.symbols.len()), Some(MAX_SYMBOLS)); + let (data, columns, rows) = codes(MAX_SYMBOLS + 1, 0); + assert!(page(&data, columns, rows).is_none()); + // `n` black squares of 256 × 256, each with a white pixel in a different place: 2^16 + // pixels a symbol, so 256 of them are exactly the cap. + let squares = |n: usize| { + let (columns, rows) = (16 * 258, n.div_ceil(16) * 258); + let mut picture = vec![vec![0u8; columns]; rows]; + for k in 0..n { + let (x, y) = (k % 16 * 258, k / 16 * 258); + for row in &mut picture[y..y + 256] { + row[x..x + 256].fill(1); + } + picture[y + 1 + k / 254][x + 1 + k % 254] = 0; + } + (samples(&picture), columns as u32, rows as u32) + }; + let (data, columns, rows) = squares(256); + let at_cap = page(&data, columns, rows).unwrap(); + assert_eq!(at_cap.symbols.len(), 256); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); + let (data, columns, rows) = squares(257); + assert!(page(&data, columns, rows).is_none()); + } + + /// More than 10,000 marks are placed by several text regions, each of at most 10,000 (more + /// than the decoder PdfCraft renders with accepts in one), over bands of rows, all referring to + /// the dictionary, which each region but the last keeps retained; the generic region comes + /// after them, every segment numbered one more than the one before. + #[test] + fn more_than_10000_marks_are_placed_by_several_regions() { + // 25,000 copies of two 17 × 3 shapes, 256 a line, the lines 5 rows apart, and a bar too + // tall to be a symbol beside them. + let mut picture = vec![vec![0u8; 256 * 18 + 100]; 25_000usize.div_ceil(256) * 5]; + for k in 0..25_000 { + let (x, y) = (k % 256 * 18, k / 256 * 5); + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + picture[y + 1][x + k % 2] = 1; + } + for row in &mut picture[0..300] { + row[256 * 18 + 50] = 1; + } + let data = samples(&picture); + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let coded = encode_jbig2(&data, columns, rows).unwrap(); + let region = |number, kept| (number, 7, false, vec![(1, kept)]); + assert_eq!( + segments(&coded), + [(0, 48, false, vec![]), (1, 0, true, vec![]), region(2, true), region(3, true), region(4, false), (5, 39, false, vec![])] + ); + assert_eq!(decode(&coded), unpack(&data, columns, rows)); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// A page of text whose letters repeat exactly, as rendered text does, keeps each letter once: + /// a symbol dictionary and a text region, much smaller than the generic coding. + #[test] + fn repeated_marks_are_stored_once() { + let picture = text(1700, 400, &glyphs(40, 7), 11); + let data = samples(&picture); + let coded = encode_jbig2(&data, 1700, 400).unwrap(); + let generic = encode_jbig2_generic(&data, 1700, 400).unwrap(); + // The dictionary is retained for the text region, which refers to it (7.2.4, 7.2.5). + assert_eq!(segments(&coded), [(0, 48, false, vec![]), (1, 0, true, vec![]), (2, 7, false, vec![(1, false)])]); + assert_eq!(decode(&coded), unpack(&data, 1700, 400)); + assert!(coded.len() * 2 < generic.len(), "symbols {} bytes, generic {}", coded.len(), generic.len()); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Marks too large to be symbols (a frame, a rule) go to a generic region over their box, and + /// with the second plan so do marks that occur once; the page still decodes to its bits. + #[test] + fn large_and_lone_marks_are_left_to_a_generic_region() { + let mut picture = text(900, 300, &glyphs(25, 3), 5); + picture[10][100..800].fill(1); + picture[290][100..800].fill(1); + for row in &mut picture[10..=290] { + row[100] = 1; + row[799] = 1; + } + // A lone blob, unlike any letter. + for (y, row) in picture[150..170].iter_mut().enumerate() { + row[850 + y % 7] = 1; + } + let data = samples(&picture); + let coded = encode_jbig2(&data, 900, 300).unwrap(); + assert_eq!(segment_types(&coded), [48, 0, 7, 39]); + assert_eq!(decode(&coded), unpack(&data, 900, 300)); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Marks on every edge, marks touching only at a corner (one mark), a mark inside another's + /// box, and marks over each other's boxes. + #[test] + fn marks_at_the_edges_and_corners_round_trip() { + let dot = vec![vec![1u8, 1], vec![1, 1]]; + let ell = vec![vec![1u8, 0, 0], vec![1, 0, 0], vec![1, 1, 1]]; + let mut picture = vec![vec![0u8; 41]; 23]; + for (x, y) in [(0, 0), (39, 0), (0, 21), (39, 21), (20, 0), (0, 10), (39, 10), (20, 21)] { + draw(&mut picture, &dot, x, y); + } + for (x, y) in [(10, 5), (28, 14), (12, 14)] { + draw(&mut picture, &ell, x, y); + } + // Corners touching: one mark (`marks_join_at_corners_and_large_ones_are_left_out` checks it). + picture[3][6] = 1; + picture[4][7] = 1; + picture[5][8] = 1; + // A ring with a dot inside, and an ell over the ring's box. + for i in 0..7 { + picture[8][20 + i] = 1; + picture[14][20 + i] = 1; + picture[8 + i][20] = 1; + picture[8 + i][26] = 1; + } + draw(&mut picture, &dot, 22, 10); + draw(&mut picture, &ell, 25, 15); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// One shape, however often, would need symbol IDs of no bits: that image is coded as a generic + /// region, as are a blank page and pure noise. + #[test] + fn one_shape_blank_pages_and_noise_are_coded_generically() { + let letter = &glyphs(1, 9)[..]; + let mut r = Lcg(1); + let noise: Bitmap = (0..60).map(|_| (0..200).map(|_| u8::from(r.below(2) == 1)).collect()).collect(); + for picture in [text(600, 200, letter, 2), vec![vec![0u8; 300]; 40], noise] { + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let data = samples(&picture); + assert_eq!(encode_jbig2(&data, columns, rows).unwrap(), encode_jbig2_generic(&data, columns, rows).unwrap()); + } + } + + proptest::proptest! { + /// Glyphs anywhere, over the edges and each other, with stray pixels: every plan decodes to + /// the picture, and `encode_jbig2` is never larger than the generic coding. + #[test] + fn scattered_marks_round_trip( + columns in 1usize..240, + rows in 1usize..120, + shapes in 2usize..10, + n in 0usize..80, + seed in proptest::num::u64::ANY, + stray in proptest::collection::vec((0usize..240, 0usize..120), 0..20), + ) { + let mut picture = scatter(columns, rows, &glyphs(shapes, seed), seed, n); + for (x, y) in stray { + if let Some(cell) = picture.get_mut(y).and_then(|r| r.get_mut(x)) { + *cell ^= 1; + } + } + every_plan_round_trips(&picture); + let data = samples(&picture); + let (c, r) = (columns as u32, rows as u32); + let coded = encode_jbig2(&data, c, r).unwrap(); + proptest::prop_assert!(coded.len() <= encode_jbig2_generic(&data, c, r).unwrap().len()); + proptest::prop_assert_eq!(decode(&coded), unpack(&data, c, r)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) { + let _ = encode_jbig2(&data, columns, rows); + let _ = encode(&data, columns, rows); + } + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 9a0124d859..7eac1a83e1 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -6,7 +6,8 @@ //! TIFF/PNG predictors, `ASCIIHexDecode`, `ASCII85Decode`, `RunLengthDecode`). //! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`), //! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so -//! callers keep such data encoded. +//! callers keep such data encoded. 1-bit samples can be encoded as CCITT Group 4 +//! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb //! defence) and never panics on malformed input. @@ -15,13 +16,19 @@ mod ascii85; mod asciihex; +mod ccitt; mod flate; +mod jbig2; mod lzw; +mod mq; mod predictor; mod runlength; use std::borrow::Cow; +pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4}; +pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2, encode_jbig2_generic}; + /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Filter { @@ -137,52 +144,95 @@ pub(crate) type Step = Result, Failure>; /// Decodes `data` through `chain` (first element applied first). /// /// Image codecs, `Crypt` and unknown filters anywhere in the chain give -/// [`FilterError::Unsupported`] (callers keep such data encoded). `max_output` bounds -/// every intermediate buffer. +/// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than +/// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer. pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result, FilterError> { - run(chain, data, max_output, true).map(|(v, _)| v) + run(chain, data, max_output, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e) +} + +/// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages +/// decode together, each stage getting what the earlier ones left, and the result gives those +/// bytes, on success and on failure. A stage's output counts even when a later stage or its +/// predictor shrinks it. +pub fn decode_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, usize), (FilterError, usize)> { + run(chain, data, max_output, true, true).map(|(v, _, spent)| (v, spent)) } /// Like [`decode`], but on corrupt data returns what was decoded before the error and /// reports `partial = true` (the behaviour viewers expect). The remaining filters of the /// chain still run on the partial data. Limit and unsupported errors are still errors. pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool), FilterError> { - run(chain, data, max_output, false) + run(chain, data, max_output, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e) } -fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool) -> Result<(Vec, bool), FilterError> { +/// [`decode_tolerant`] bounded and counted as [`decode_counted`] is. +pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool, usize), (FilterError, usize)> { + run(chain, data, max_output, false, true) +} + +/// Longest filter chain decoded. Real streams use one to three filters; every stage may produce +/// up to the output limit, so the chain length multiplies the work. +pub const MAX_CHAIN: usize = 8; + +/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and +/// the bytes decoded before it. `max` bounds each stage, or with `cumulative` all of them +/// together. +fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool, cumulative: bool) -> Result<(Vec, bool, usize), (FilterError, usize)> { + if chain.len() > MAX_CHAIN { + return Err((FilterError::Unsupported(format!("a chain of {} filters", chain.len())), 0)); + } if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) { - return Err(FilterError::Unsupported(f.name().to_owned())); + return Err((FilterError::Unsupported(f.name().to_owned()), 0)); } let mut cur: Cow<[u8]> = Cow::Borrowed(data); let mut partial = false; + let mut spent = 0usize; for (filter, params) in chain { - cur = Cow::Owned(match decode_one(filter, params, &cur, max) { + #[cfg(test)] + STAGE_BYTES.with(|c| c.set(c.get() + cur.len())); + let limit = if cumulative { max.saturating_sub(spent) } else { max }; + let (step, work) = decode_one(filter, params, &cur, limit); + spent = spent.saturating_add(work); + cur = Cow::Owned(match step { Ok(v) => v, Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => { partial = true; out } - Err(f) => return Err(f.error), + Err(Failure { error: FilterError::LimitExceeded(_), .. }) => return Err((FilterError::LimitExceeded(max), spent)), + Err(f) => return Err((f.error, spent)), }); } - Ok((cur.into_owned(), partial)) + Ok((cur.into_owned(), partial, spent)) } -fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> Step { +/// One stage's result and the bytes its decoder produced (before any predictor). +fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> (Step, usize) { match filter { Filter::Flate => with_predictor(flate::decode(data, max), params, "FlateDecode"), Filter::Lzw => with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode"), - Filter::AsciiHex => asciihex::decode(data, max), - Filter::Ascii85 => ascii85::decode(data, max), - Filter::RunLength => runlength::decode(data, max), - other => Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }), + Filter::AsciiHex => counted(asciihex::decode(data, max)), + Filter::Ascii85 => counted(ascii85::decode(data, max)), + Filter::RunLength => counted(runlength::decode(data, max)), + other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }), 0), } } -/// Applies the predictor to a decoder's result, including to partial output on failure. -fn with_predictor(step: Step, params: &Params, name: &'static str) -> Step { - match step { +/// A step with the bytes it produced, its output or its partial output. +fn counted(step: Step) -> (Step, usize) { + let n = match &step { + Ok(v) => v.len(), + Err(f) => f.partial.len(), + }; + (step, n) +} + +/// Applies the predictor to a decoder's result, including to partial output on failure. The +/// count is the decoder's output: a PNG predictor drops a tag byte per row, but the decoder +/// produced it. +fn with_predictor(step: Step, params: &Params, name: &'static str) -> (Step, usize) { + let (step, n) = counted(step); + let step = match step { Ok(v) => predictor::decode(params, v, name), Err(Failure { error, partial }) => { let partial = match predictor::decode(params, partial, name) { @@ -191,7 +241,8 @@ fn with_predictor(step: Step, params: &Params, name: &'static str) -> Step { }; Err(Failure { error, partial }) } - } + }; + (step, n) } /// Encodes `data` with `filter`. Supported: Flate (zlib, level 6), LZW, ASCIIHex, @@ -218,8 +269,27 @@ pub(crate) fn is_pdf_whitespace(b: u8) -> bool { matches!(b, 0 | 9 | 10 | 12 | 13 | 32) } +#[cfg(test)] +thread_local! { + /// Bytes handed to decoding stages on this thread. + static STAGE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests { + #[test] + fn a_long_filter_chain_is_refused_before_decoding() { + // `FF FF` run-length decodes to itself, so every stage of the chain repeats the work. + let data = vec![0xFF; 4096]; + let chain = |n: usize| vec![(super::Filter::RunLength, super::Params::default()); n]; + super::STAGE_BYTES.with(|c| c.set(0)); + let long = super::decode(&chain(1000), &data, 1 << 20); + let read = super::STAGE_BYTES.with(|c| c.get()); + assert!(read <= 16 * data.len(), "{read} bytes decoded through the chain"); + assert!(matches!(long, Err(super::FilterError::Unsupported(_))), "{long:?}"); + assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data)); + } + use super::*; #[test] @@ -343,4 +413,35 @@ mod tests { assert!(partial); assert_eq!(&data[..out.len()], &out[..]); } + + /// A counted decode bounds what all stages decode together: Flate inflating 1 MiB of hex + /// that ASCIIHex halves is 1.5 MiB of work, past a 1.25 MiB allowance. A plain decode keeps + /// the limit per stage. + #[test] + fn a_counted_decode_bounds_all_stages_together() { + let hex: Vec = std::iter::repeat_n(b"ab".as_slice(), 1 << 19).flatten().copied().collect(); + let data = encode_flate(&hex); + let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())]; + let max = (1 << 20) + (1 << 18); + assert_eq!(decode(&chain, &data, max).map(|d| d.len()).ok(), Some(1 << 19)); + assert!(matches!(decode_counted(&chain, &data, max), Err((FilterError::LimitExceeded(m), _)) if m == max)); + assert_eq!(decode_counted(&chain, &data, 2 << 20).map(|(_, spent)| spent).ok(), Some((1 << 20) + (1 << 19))); + } + + /// A PNG predictor drops a tag byte per row, but Flate produced it: the count includes it, + /// also when the predictor fails on a bad tag. + #[test] + fn a_counted_decode_counts_output_before_the_predictor() { + let params = Params { predictor: 12, ..Params::default() }; + let rows = vec![7u8; 1 << 16]; + let data = encode(&Filter::Flate, ¶ms, &rows).unwrap(); + let chain = [(Filter::Flate, params.clone())]; + let (out, spent) = decode_counted(&chain, &data, usize::MAX).unwrap(); + assert!(out == rows); + assert_eq!(spent, 2 << 16); + let bad: Vec = std::iter::repeat_n([0u8, 7], 1 << 15).flatten().chain([9, 7]).collect(); + let (e, spent) = decode_counted(&chain, &encode_flate(&bad), usize::MAX).unwrap_err(); + assert!(matches!(e, FilterError::Corrupt { .. }), "{e}"); + assert_eq!(spent, bad.len()); + } } diff --git a/crates/filters/src/mq.rs b/crates/filters/src/mq.rs new file mode 100644 index 0000000000..04dda2bd9a --- /dev/null +++ b/crates/filters/src/mq.rs @@ -0,0 +1,209 @@ +//! The MQ arithmetic encoder of JBIG2 (ITU-T T.88 | ISO/IEC 14492, Annex E), written from the +//! procedures in its Figures E.2–E.12 and the probability estimates of Table E.1. + +/// Table E.1: for each index, the LPS probability estimate Qe, the next index after an MPS and +/// after an LPS renormalisation, and whether an LPS switches the MPS sense. +const STATES: [(u32, u8, u8, bool); 47] = [ + (0x5601, 1, 1, true), + (0x3401, 2, 6, false), + (0x1801, 3, 9, false), + (0x0AC1, 4, 12, false), + (0x0521, 5, 29, false), + (0x0221, 38, 33, false), + (0x5601, 7, 6, true), + (0x5401, 8, 14, false), + (0x4801, 9, 14, false), + (0x3801, 10, 14, false), + (0x3001, 11, 17, false), + (0x2401, 12, 18, false), + (0x1C01, 13, 20, false), + (0x1601, 29, 21, false), + (0x5601, 15, 14, true), + (0x5401, 16, 14, false), + (0x5101, 17, 15, false), + (0x4801, 18, 16, false), + (0x3801, 19, 17, false), + (0x3401, 20, 18, false), + (0x3001, 21, 19, false), + (0x2801, 22, 19, false), + (0x2401, 23, 20, false), + (0x2201, 24, 21, false), + (0x1C01, 25, 22, false), + (0x1801, 26, 23, false), + (0x1601, 27, 24, false), + (0x1401, 28, 25, false), + (0x1201, 29, 26, false), + (0x1101, 30, 27, false), + (0x0AC1, 31, 28, false), + (0x09C1, 32, 29, false), + (0x08A1, 33, 30, false), + (0x0521, 34, 31, false), + (0x0441, 35, 32, false), + (0x02A1, 36, 33, false), + (0x0221, 37, 34, false), + (0x0141, 38, 35, false), + (0x0111, 39, 36, false), + (0x0085, 40, 37, false), + (0x0049, 41, 38, false), + (0x0025, 42, 39, false), + (0x0015, 43, 40, false), + (0x0009, 44, 41, false), + (0x0005, 45, 42, false), + (0x0001, 45, 43, false), + (0x5601, 46, 46, false), +]; + +#[cfg(test)] +thread_local! { + /// Decisions coded on this thread. + pub(crate) static DECISIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// A context's adaptive state: the index into [`STATES`] (low 7 bits) and the MPS sense (bit 7). +/// 0, the initial value, is index 0 with MPS 0 (E.2.8). +pub(crate) type Context = u8; + +/// The encoder's registers (E.2.1) and the bytes written so far. +pub(crate) struct MqEncoder { + a: u32, + c: u32, + ct: u32, + /// The output, after the byte before the first one (B at INITENC), which is 0x00 and is + /// dropped by [`MqEncoder::finish`]: a carry can't reach it before the first byte is out. + out: Vec, +} + +impl MqEncoder { + /// INITENC (Figure E.10). The byte before the first is 0x00, not 0xFF, so CT starts at 12. + pub(crate) fn new() -> Self { + MqEncoder { a: 0x8000, c: 0, ct: 12, out: vec![0] } + } + + /// The byte B, the last one written (or the one before the first). + fn b(&self) -> u8 { + self.out.last().copied().unwrap_or(0) + } + + /// ENCODE (Figures E.3–E.7): code decision `d` in context `cx`, updating its state. + pub(crate) fn encode(&mut self, cx: &mut Context, d: bool) { + #[cfg(test)] + DECISIONS.with(|n| n.set(n.get() + 1)); + let (index, mps) = (usize::from(*cx & 0x7F), *cx >> 7 == 1); + let Some(&(qe, nmps, nlps, switch)) = STATES.get(index) else { return }; + self.a -= qe; + if d == mps { + // CODEMPS. + if self.a & 0x8000 != 0 { + self.c += qe; + return; + } + if self.a < qe { + self.a = qe; + } else { + self.c += qe; + } + *cx = nmps | (*cx & 0x80); + } else { + // CODELPS. + if self.a < qe { + self.c += qe; + } else { + self.a = qe; + } + let mps = mps != switch; + *cx = nlps | (u8::from(mps) << 7); + } + self.renorm(); + } + + /// RENORME (Figure E.8). + fn renorm(&mut self) { + loop { + self.a <<= 1; + self.c <<= 1; + self.ct -= 1; + if self.ct == 0 { + self.byte_out(); + } + if self.a & 0x8000 != 0 { + break; + } + } + } + + /// BYTEOUT (Figure E.9), with bit stuffing after 0xFF and carry into the byte before. + fn byte_out(&mut self) { + if self.b() == 0xFF { + self.stuffed(); + } else if self.c < 0x800_0000 { + self.plain(); + } else { + let b = self.b().wrapping_add(1); + if let Some(last) = self.out.last_mut() { + *last = b; + } + if b == 0xFF { + self.c &= 0x7FF_FFFF; + self.stuffed(); + } else { + self.plain(); + } + } + } + + fn plain(&mut self) { + self.out.push(((self.c >> 19) & 0xFF) as u8); + self.c &= 0x7_FFFF; + self.ct = 8; + } + + fn stuffed(&mut self) { + self.out.push(((self.c >> 20) & 0xFF) as u8); + self.c &= 0xF_FFFF; + self.ct = 7; + } + + /// FLUSH (Figures E.11 and E.12): the final bytes, then the 0xFF 0xAC marker. + pub(crate) fn finish(mut self) -> Vec { + let top = self.c + self.a; + self.c |= 0xFFFF; + if self.c >= top { + self.c -= 0x8000; + } + self.c <<= self.ct; + self.byte_out(); + self.c <<= self.ct; + self.byte_out(); + if self.b() != 0xFF { + self.out.push(0xFF); + } + self.out.push(0xAC); + self.out.split_off(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Annex H.2's test sequence: 256 decisions in one context give these 30 bytes. + #[test] + fn the_annex_h_test_sequence_encodes_to_its_bytes() { + let decisions = [ + 0x00, 0x02, 0x00, 0x51, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x52, 0x87, 0x2A, 0xAA, 0xAA, 0xAA, 0xAA, 0x82, 0xC0, 0x20, 0x00, 0xFC, 0xD7, 0x9E, + 0xF6, 0xBF, 0x7F, 0xED, 0x90, 0x4F, 0x46, 0xA3, 0xBF, + ]; + let mut mq = MqEncoder::new(); + let mut cx = 0; + for byte in decisions { + for bit in (0..8).rev() { + mq.encode(&mut cx, byte >> bit & 1 == 1); + } + } + let expected = [ + 0x84, 0xC7, 0x3B, 0xFC, 0xE1, 0xA1, 0x43, 0x04, 0x02, 0x20, 0x00, 0x00, 0x41, 0x0D, 0xBB, 0x86, 0xF4, 0x31, 0x7F, 0xFF, 0x88, 0xFF, 0x37, + 0x47, 0x1A, 0xDB, 0x6A, 0xDF, 0xFF, 0xAC, + ]; + assert_eq!(mq.finish(), expected); + } +} diff --git a/crates/fonts/README.md b/crates/fonts/README.md index 36b305bd9d..b20b930a91 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -72,7 +72,7 @@ needed. wasm32 builds embed only BIZ UDPGothic Regular and any `Arab`, `Telu`, `Hebr` or `Thai` face, to keep the web build small: the web build currently has no Chinese face, so Chinese there still shows the replacement glyph. Font files are never -committed here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal). +committed here (`AGENTS.md` §1.4). The full font subsystem (parsing, shaping, subsetting and embedding) arrives with M2.2/M7. diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 9be57030e7..980c195709 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -1,24 +1,32 @@ //! Embeds the fonts of the optional craft-fonts build input (`CRAFT_FONTS_DIR`) as `CRAFT_FONTS`. //! The recipe is craft-fonts' `docs/integration.md`; unset, `CRAFT_FONTS` is empty. It only reads //! the local checkout (no network). On wasm32 it embeds only the UI faces (BIZ UDPGothic Regular -//! and any `Arab`, `Telu`, `Hebr` or `Thai` face), to keep the web build within hosting limits (Cloudflare Pages: -//! 25 MiB per file). +//! and any `Arab`, `Telu`, `Hebr`, `Thai` or `Deva` face), to keep the web build within hosting +//! limits (Cloudflare Pages: 25 MiB per file). Native builds also read the optional +//! `fonts/simplified-chinese-manifest.txt` (LXGW WenKai Lite); it is never embedded on wasm. +//! `CRAFT_FONTS_REQUIRED` makes both the checkout and that native manifest required. use std::fmt::Write as _; use std::path::PathBuf; +#[path = "src/cjk_scripts.rs"] +mod cjk_scripts; + +/// Build-script entry point: embed craft-fonts from `CRAFT_FONTS_DIR`, make a missing or invalid input fatal +/// when `CRAFT_FONTS_REQUIRED` is set, and write `craft_fonts.rs` to `OUT_DIR`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - match craft_fonts(&dir) { + match craft_fonts(&dir, required) { Ok(entries) => src.push_str(&entries), - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } + Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()), Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), } + } else if required { + println!("cargo::error=CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing"); } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -27,27 +35,66 @@ fn main() { } } -/// Family-name prefixes of the faces AGENTS.md §1.1 forbids: Adobe's Source families and Noto -/// CJK, which is Source Han under another name. The test in `src/craft.rs` has the same list. -const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", "Noto Sans CJK", "Noto Serif CJK"]; +/// The main manifest, plus the native-only Simplified Chinese manifest. The latter is optional +/// for ordinary builds and required when `CRAFT_FONTS_REQUIRED` is set, so a required build can +/// never pass the Chinese UI tests only by skipping them. +fn craft_fonts(dir: &std::path::Path, required: bool) -> Result { + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut out = manifest(dir, &dir.join("fonts/manifest.txt"), wasm, false)?; + if !wasm { + let chinese = dir.join("fonts/simplified-chinese-manifest.txt"); + println!("cargo::rerun-if-changed={}", chinese.display()); + match chinese.try_exists() { + Ok(false) if required => { + return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display())); + } + Ok(false) => {} + Ok(true) => match manifest(dir, &chinese, false, required) { + Ok(entries) => out.push_str(&entries), + // A broken optional addition must never discard the main Japanese/symbol/RTL faces. + Err(e) if required => return Err(e), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"), + }, + Err(e) if required => return Err(format!("{}: {e}", chinese.display())), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display()), + } + } + Ok(out) +} /// One `CraftFont { .. }` initialiser per manifest line. -fn craft_fonts(dir: &std::path::Path) -> Result { - let manifest = dir.join("fonts/manifest.txt"); +/// +/// `strict_cap` is set for the required native Simplified Chinese manifest: a face that does not +/// fit its cap must fail the build rather than be silently left out, so a required build cannot +/// pass without the Chinese face it promises. +fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool, strict_cap: bool) -> Result { println!("cargo::rerun-if-changed={}", manifest.display()); - let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; - let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; let mut out = String::new(); + // A required native Chinese manifest must add the Hans face itself, not merely some other + // CJK face that happens to fit: otherwise CRAFT_FONTS_REQUIRED would pass while the promised + // Simplified Chinese fallback is still absent. + let mut has_hans = false; for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; - // AGENTS.md §1.1 bars Adobe's type designs whatever their licence, so a checkout that - // carries one (craft-fonts has Noto Sans CJK, which is Source Han) never embeds it. - if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) { - println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md §1.1)"); - continue; + // Keep the upstream general CJK size cap. The separately pinned native Chinese UI + // face has its own bound; web builds retain the smaller general limit. A required + // Chinese face over its cap fails the build instead of being silently omitted. + let cjk = scripts.split(',').any(cjk_scripts::is_cjk_script); + if cjk && let Ok(metadata) = std::fs::metadata(dir.join(file)) { + match cjk_scripts::cjk_size_decision(family, metadata.len(), wasm, strict_cap) { + cjk_scripts::CjkSizeDecision::Embed => {} + cjk_scripts::CjkSizeDecision::Skip => continue, + cjk_scripts::CjkSizeDecision::Reject => { + let cap = cjk_scripts::cjk_cap_bytes(family, wasm); + return Err(format!( + "{file}: the {family} face is larger than the {cap}-byte native Chinese UI cap; CRAFT_FONTS_REQUIRED cannot embed it" + )); + } + } } // Arabic interface faces are small, so the web build keeps them too. let arabic = scripts.split(',').any(|s| s.trim() == "Arab"); @@ -55,11 +102,18 @@ fn craft_fonts(dir: &std::path::Path) -> Result { let telugu = scripts.split(',').any(|s| s.trim() == "Telu"); // And the Hebrew ones (small), for Hebrew text and Edit text on the web. let hebrew = scripts.split(',').any(|s| s.trim() == "Hebr"); - let web_face = - (*family == "BIZ UDPGothic" && *style == "Regular") || arabic || telugu || hebrew || scripts.split(',').any(|s| s.trim() == "Thai"); + // Devanagari too, for the Hindi interface (one small face). + let devanagari = scripts.split(',').any(|s| s.trim() == "Deva"); + let web_face = (*family == "BIZ UDPGothic" && *style == "Regular") + || arabic + || telugu + || hebrew + || scripts.split(',').any(|s| s.trim() == "Thai") + || devanagari; if wasm && !web_face { continue; } + has_hans |= cjk_scripts::has_script_tag(scripts, "Hans"); let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; println!("cargo::rerun-if-changed={}", path.display()); let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); @@ -70,5 +124,36 @@ fn craft_fonts(dir: &std::path::Path) -> Result { path.display().to_string(), ); } + if strict_cap && !has_hans { + return Err(format!( + "{}: CRAFT_FONTS_REQUIRED is set but the manifest emits no Hans face (a Simplified Chinese face is required)", + manifest.display() + )); + } Ok(out) } + +#[cfg(test)] +mod manifest_tests { + use super::*; + + /// Regression test: a required native-font manifest must declare at least one Hans face, while optional + /// input accepts another script and a missing asset still fails. + #[test] + fn required_native_manifest_rejects_non_hans_faces_after_parsing() { + let root = std::env::temp_dir().join(format!("pdfcraft-required-hans-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("face.ttf"), b"synthetic manifest fixture").unwrap(); + let path = root.join("manifest.txt"); + for scripts in ["Hant,Latn", "Hans-HK,Latn", "Latn"] { + std::fs::write(&path, format!("Fixture | Regular | face.ttf | {scripts}\n")).unwrap(); + assert!(manifest(&root, &path, false, true).unwrap_err().contains("no Hans face"), "{scripts}"); + assert!(manifest(&root, &path, false, false).unwrap().contains("CraftFont {"), "optional input stays usable"); + } + std::fs::write(&path, "Fixture | Regular | face.ttf | Latn, Hans\n").unwrap(); + assert!(manifest(&root, &path, false, true).unwrap().contains("CraftFont {")); + std::fs::remove_file(root.join("face.ttf")).unwrap(); + assert!(manifest(&root, &path, false, true).is_err(), "a missing Hans asset cannot satisfy required input"); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/fonts/src/cjk_scripts.rs b/crates/fonts/src/cjk_scripts.rs new file mode 100644 index 0000000000..1058c6e61b --- /dev/null +++ b/crates/fonts/src/cjk_scripts.rs @@ -0,0 +1,97 @@ +//! The rule `build.rs` follows for CJK faces: which manifest script tags are CJK, which families +//! are embedded whole, and the size cap for the others. The build script includes this file with +//! `#[path]` and the crate's tests use it as a module, so the two can't drift. + +/// The CJK families embedded whole: the Japanese faces documents and the renderer use. +pub const WHOLE_CJK_FAMILIES: [&str; 3] = ["Shippori Mincho", "BIZ UDPGothic", "BIZ UDMincho"]; + +/// Any other CJK face over this size is left out. +pub const MAX_OTHER_CJK_BYTES: u64 = 4 << 20; + +/// Reviewed native-only UI fallback from the separate Chinese manifest. +/// It is never included in the browser payload. Other CJK families keep the 4 MiB cap. +pub const NATIVE_UI_CJK_FAMILIES: [&str; 1] = ["LXGW WenKai Lite"]; +pub const MAX_NATIVE_UI_CJK_BYTES: u64 = 16 << 20; + +/// ISO 15924 codes of the Han, Japanese and Korean scripts, Hangul on its own included. +const CJK_SCRIPTS: [&str; 6] = ["Hani", "Hans", "Hant", "Jpan", "Kore", "Hang"]; + +/// Whether a craft-fonts manifest script tag is CJK. A tag can carry a region after the code +/// (craft-fonts tags Noto Sans HK UI `Hant-HK`), so only the part before `-` is compared. +pub fn is_cjk_script(tag: &str) -> bool { + let code = tag.trim().split('-').next().unwrap_or_default(); + CJK_SCRIPTS.contains(&code) +} + +/// Whether a manifest's comma-separated script list contains `script` exactly, ignoring spaces. +/// The interface and document lookups use exact tags (`CraftFont::covers`), so a regional tag +/// such as `Hans-HK` is not a `Hans` entry. +pub fn has_script_tag(scripts: &str, script: &str) -> bool { + scripts.split(',').any(|tag| tag.trim() == script) +} + +/// What `build.rs` does with one CJK face from a manifest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CjkSizeDecision { + /// The face fits its cap (or is a Japanese family embedded whole). + Embed, + /// An optional face over its cap: leave it out and keep the rest. + Skip, + /// A required face over its cap: fail the build rather than silently omit it. + Reject, +} + +/// The size cap that applies to `family` in this build. +pub fn cjk_cap_bytes(family: &str, wasm: bool) -> u64 { + if !wasm && NATIVE_UI_CJK_FAMILIES.contains(&family) { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES } +} + +/// Decide whether a CJK face is embedded, skipped, or rejected. +/// +/// The Japanese document/UI families are embedded whole. Every other CJK family uses its cap; +/// `required` marks the native Simplified Chinese manifest of a `CRAFT_FONTS_REQUIRED` build, +/// where an over-cap face must fail instead of quietly disappearing. +pub fn cjk_size_decision(family: &str, bytes: u64, wasm: bool, required: bool) -> CjkSizeDecision { + if WHOLE_CJK_FAMILIES.contains(&family) { + return CjkSizeDecision::Embed; + } + if bytes <= cjk_cap_bytes(family, wasm) { + CjkSizeDecision::Embed + } else if required { + CjkSizeDecision::Reject + } else { + CjkSizeDecision::Skip + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Check that a required Chinese manifest needs an exact `Hans` script tag and does not accept + /// Traditional or region-suffixed tags. + #[test] + fn a_required_chinese_manifest_needs_an_exact_hans_entry() { + assert!(has_script_tag("Hans,Latn", "Hans")); + assert!(has_script_tag(" Latn , Hans ", "Hans")); + assert!(!has_script_tag("Hant,Latn", "Hans"), "Traditional Chinese is not the required Simplified face"); + assert!(!has_script_tag("Hans-HK,Latn", "Hans"), "the UI looks for the exact Hans tag"); + assert!(!has_script_tag("Hani,Jpan,Kore", "Hans")); + } + + /// Check that a native Chinese face over its cap is rejected when required, skipped when optional, and + /// uses the web cap on the web. + #[test] + fn a_required_native_chinese_face_over_its_cap_is_rejected_not_skipped() { + let at_cap = MAX_NATIVE_UI_CJK_BYTES; + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap, false, true), CjkSizeDecision::Embed); + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, true), CjkSizeDecision::Reject); + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, false), CjkSizeDecision::Skip); + // The web build keeps the smaller general cap even for the native Chinese family. + assert_eq!(cjk_size_decision("LXGW WenKai Lite", MAX_OTHER_CJK_BYTES + 1, true, true), CjkSizeDecision::Reject); + // Japanese document/UI families are embedded whole. + assert_eq!(cjk_size_decision("BIZ UDPGothic", u64::MAX, false, true), CjkSizeDecision::Embed); + assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", false), MAX_NATIVE_UI_CJK_BYTES); + assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", true), MAX_OTHER_CJK_BYTES); + } +} diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index 099179cf2e..dff96762aa 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -75,6 +75,20 @@ pub fn document_thai_font() -> Option<&'static CraftFont> { ui_thai_fonts().into_iter().next() } +/// The `Deva` craft-fonts faces for Devanagari interface text (the Hindi catalog, file names, +/// document titles), in manifest order. Empty when built without craft-fonts or when its revision +/// has no Devanagari face; a desktop build then falls back to one font already installed on the +/// machine (`SYSTEM_DEVANAGARI_FALLBACK`), and the web build shows replacement boxes. +pub fn ui_devanagari_fonts() -> Vec<&'static CraftFont> { + CRAFT_FONTS.iter().filter(|f| f.covers("Deva")).collect() +} + +/// The face for Devanagari text written into PDFs: the first `Deva` face. `None` when built +/// without craft-fonts or when its revision has no Devanagari face. +pub fn document_devanagari_font() -> Option<&'static CraftFont> { + ui_devanagari_fonts().into_iter().next() +} + /// The `Hebr` craft-fonts faces for Hebrew interface text (file names, document titles, text /// being edited), in manifest order. Empty when built without craft-fonts or when it has no /// Hebrew face. @@ -138,6 +152,24 @@ pub fn document_japanese_fonts_for_style(serif: bool, bold: bool) -> Vec<&'stati document_faces(CRAFT_FONTS, serif, bold) } +/// The scripts the bundled Inter faces are for. +static LATIN_SCRIPTS: &[&str] = &["Latn", "Cyrl", "Grek"]; + +/// The bundled Latin document faces (`assets/fonts`, OFL): every build has them, unlike +/// craft-fonts. The Japanese faces carry only part of the Cyrillic (`привет` yes, Uzbek `қизил` +/// and Kazakh `әже` no), so letters like қ, ғ, ҳ, ә, ө, ү, ұ and һ have no face at all without +/// these. +static INTER_REGULAR_FACE: CraftFont = + CraftFont { family: "Inter", style: "Regular", scripts: LATIN_SCRIPTS, bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf") }; +static INTER_SEMIBOLD_FACE: CraftFont = + CraftFont { family: "Inter", style: "SemiBold", scripts: LATIN_SCRIPTS, bytes: include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf") }; + +/// The face for Latin, Greek and Cyrillic text written into PDFs: the bundled Inter Regular, or +/// SemiBold for bold text. Present in every build, crafted ones included. +pub fn document_latin_fonts(bold: bool) -> Vec<&'static CraftFont> { + vec![if bold { &INTER_SEMIBOLD_FACE } else { &INTER_REGULAR_FACE }] +} + fn document_face(faces: &[CraftFont], serif: bool, bold: bool) -> Option<&CraftFont> { document_faces(faces, serif, bold).into_iter().next() } @@ -265,20 +297,6 @@ mod tests { assert_eq!(ui_arabic_fonts().len(), CRAFT_FONTS.iter().filter(|f| f.covers("Arab")).count()); } - /// Family-name prefixes `build.rs` never embeds (its own `BARRED_FAMILIES`): Adobe's type - /// designs, which includes Noto CJK (Source Han under another name). - const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", "Noto Sans CJK", "Noto Serif CJK"]; - - /// Whatever the craft-fonts checkout holds, no Adobe type design is embedded (AGENTS.md §1.1). - #[test] - fn barred_families_are_never_embedded() { - for face in CRAFT_FONTS { - assert!(!BARRED_FAMILIES.iter().any(|barred| face.family.starts_with(barred)), "{} is embedded", face.name()); - } - assert!(BARRED_FAMILIES.iter().any(|barred| "Noto Sans CJK SC".starts_with(barred))); - assert!(!BARRED_FAMILIES.iter().any(|barred| "Noto Sans Arabic".starts_with(barred))); - } - #[test] fn hebrew_document_faces_follow_the_weight() { static HEBR_LATN: &[&str] = &["Hebr", "Latn"]; @@ -296,6 +314,44 @@ mod tests { assert_eq!(document_hebrew_font(false).is_some(), CRAFT_FONTS.iter().any(|f| f.covers("Hebr"))); } + /// `build.rs` embeds the Japanese CJK faces whole and any other CJK face only up to + /// `MAX_OTHER_CJK_BYTES` (the explicitly pinned native-only LXGW UI fallback has a separate + /// 16 MiB cap), so a checkout with a large Chinese or Korean face (craft-fonts has the + /// 16.4 MB Noto Sans CJK SC) leaves it out. The four Japanese faces of the pinned craft-fonts + /// are 24.1 MB. + #[test] + fn cjk_faces_follow_the_size_rule() { + use crate::cjk_scripts::{MAX_NATIVE_UI_CJK_BYTES, MAX_OTHER_CJK_BYTES, NATIVE_UI_CJK_FAMILIES, WHOLE_CJK_FAMILIES, is_cjk_script}; + // A tag can carry a region after the code: craft-fonts tags Noto Sans HK UI `Hant-HK`. + assert!(["Hant-HK", "Hans", "Jpan", "Hang"].iter().all(|s| is_cjk_script(s))); + assert!(!["Latn", "Arab", "Hebr"].iter().any(|s| is_cjk_script(s))); + let cjk = CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| is_cjk_script(s))); + let (whole, other): (Vec<_>, Vec<_>) = cjk.partition(|f| WHOLE_CJK_FAMILIES.contains(&f.family)); + for face in &other { + let len = face.bytes.len(); + let native_ui = NATIVE_UI_CJK_FAMILIES.contains(&face.family); + assert!(!cfg!(target_arch = "wasm32") || !native_ui, "native UI face in web payload"); + let cap = if native_ui { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES }; + assert!(u64::try_from(len).is_ok_and(|n| n <= cap), "{} is embedded at {len} bytes", face.name()); + } + let bytes: usize = whole.iter().map(|f| f.bytes.len()).sum(); + assert!(bytes < 25_000_000, "{bytes} bytes of Japanese faces"); + } + + /// The bundled Inter faces carry the Latin, Greek and Cyrillic letters the craft-fonts faces + /// lack: Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ, Ukrainian ї є ґ, Greek. This one runs with or without + /// CRAFT_FONTS_DIR (#2593). + #[test] + fn latin_faces_carry_the_letters_the_japanese_faces_lack() { + for face in document_latin_fonts(false).into_iter().chain(document_latin_fonts(true)) { + for ch in ['қ', 'ғ', 'ҳ', 'ә', 'ө', 'ү', 'ұ', 'һ', 'ї', 'є', 'ґ', 'ё', 'α', 'β', 'γ', '≥', '≈'] { + assert!(crate::japanese_glyph_from(face, ch).is_ok(), "{} has no {ch}", face.name()); + } + } + assert_eq!(document_latin_fonts(false)[0].style, "Regular"); + assert_eq!(document_latin_fonts(true)[0].style, "SemiBold"); + } + #[test] fn craft_fonts_are_optional_and_consistent() { // Holds with or without CRAFT_FONTS_DIR. diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index a5bb011e00..2b7b4e17e1 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -1,9 +1,10 @@ //! Unicode embedding of an explicitly supplied, embeddable TrueType face. Font files are //! document inputs or the optional craft-fonts build input, never system-interface fallbacks. +use std::collections::HashMap; use std::fmt::Write as _; -use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use skrifa::{ FontRef, MetadataProvider, instance::{LocationRef, Size}, @@ -173,7 +174,8 @@ pub fn embed_mapping( return Err("the font family name is invalid".into()); } let glyph_map = if cff { cid_encoding.as_bytes() } else { gids.as_slice() }; - if let Some(resource) = same_resource(doc, fonts, &name, bytes, glyph_map, cmap.as_bytes()) { + let mut programs = ProgramMatch::new(bytes); + if let Some(resource) = same_resource(doc, fonts, &name, &mut programs, glyph_map, cmap.as_bytes()) { return Ok(resource); } let mut descriptor = Dict::new(); @@ -193,12 +195,7 @@ pub fn embed_mapping( ); // Reuse the program another resource of this page already has, else one registered on another // page; source subset programs stay untouched. - let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| { - doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { - Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)), - _ => None, - }) - }); + let existing = existing_program(doc, fonts, &mut programs); let program = existing.unwrap_or_else(|| { let mut d = Dict::new(); d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); @@ -246,12 +243,64 @@ pub fn embed_mapping( Ok(resource) } -/// The decoded data of the stream `object` refers to. -fn stream_data(doc: &Document, object: Option<&Object>) -> Option> { - match doc.resolve(object?).as_ref() { - Object::Stream(s) => s.decoded_within(17 << 20).ok(), - _ => None, +/// Whether the stream `object` refers to decodes to `expected`. +fn stream_is(doc: &Document, object: Option<&Object>, expected: &[u8]) -> bool { + object.is_some_and(|o| match doc.resolve(o).as_ref() { + Object::Stream(s) => s.decoded_within(expected.len()).is_ok_and(|d| d == expected), + _ => false, + }) +} + +/// Full programs one embedding decodes at most while looking for its own. A real match passes +/// the cheap checks and decodes once; many candidates that pass them come from a hostile file. +const MAX_PROGRAM_DECODES: usize = 16; + +/// Recognises the full program written for `bytes`: each candidate stream decodes at most once +/// and within `bytes.len()`, and at most [`MAX_PROGRAM_DECODES`] decode in all. +struct ProgramMatch<'a> { + bytes: &'a [u8], + known: HashMap, + decodes: usize, +} + +impl<'a> ProgramMatch<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, known: HashMap::new(), decodes: 0 } } + + /// Whether `r`, holding `object`, is the program. + fn is(&mut self, doc: &Document, r: ObjRef, object: &Object) -> bool { + if let Some(&known) = self.known.get(&r) { + return known; + } + let found = match object { + Object::Stream(s) + if s.dict.get(b"PCFullFont").is_some() + && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(self.bytes.len()).ok() + && self.decodes < MAX_PROGRAM_DECODES => + { + self.decodes = self.decodes.saturating_add(1); + s.decoded_within(self.bytes.len()).is_ok_and(|data| data == self.bytes) + } + _ => false, + }; + self.known.insert(r, found); + found + } + + /// Whether the resource's program is this one. + fn written(&mut self, doc: &Document, w: &Written) -> bool { + w.program.as_ref().is_some_and(|r| self.is(doc, r, &doc.get(r))) + } +} + +/// The program for `programs.bytes` that this page's `PCFull` resources, or else the first +/// objects of the document, already have. +fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) -> Option { + written(doc, fonts) + .find(|w| programs.written(doc, w)) + .map(|w| w.program) + .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &o).then_some(Object::Ref(r)))) } /// A `PCFull` resource that [`embed_mapping`] wrote. @@ -263,21 +312,6 @@ struct Written { program: Object, } -impl Written { - /// Whether its program is `bytes`. - fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool { - matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes)) - } -} - -/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was -/// written with is compared before decoding. -fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool { - s.dict.get(b"PCFullFont").is_some() - && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() - && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) -} - /// The `PCFull` resources in `fonts`, in order. fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator + 'a { fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| { @@ -291,14 +325,14 @@ fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator Option { +fn same_resource(doc: &Document, fonts: &Dict, name: &str, programs: &mut ProgramMatch, glyph_map: &[u8], unicode: &[u8]) -> Option { written(doc, fonts) .find(|w| { let glyphs = w.cid.get(b"CIDToGIDMap").or_else(|| w.top.get(b"Encoding")); w.top.name(b"BaseFont") == Some(name.as_bytes()) - && stream_data(doc, w.top.get(b"ToUnicode")).is_some_and(|d| d == unicode) - && stream_data(doc, glyphs).is_some_and(|d| d == glyph_map) - && w.has_program(doc, bytes) + && stream_is(doc, w.top.get(b"ToUnicode"), unicode) + && stream_is(doc, glyphs, glyph_map) + && programs.written(doc, w) }) .map(|w| w.resource) } @@ -385,6 +419,48 @@ mod tests { assert_eq!(programs(&doc), 2); } + #[test] + fn the_program_search_decodes_each_candidate_once_and_few_in_all() { + let bytes = b"the program".as_slice(); + let decoy = |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"PCFullFont".to_vec(), Object::Bool(true)); + d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64)); + doc.add(Object::Stream(Stream::flate(d, &[0; 4096]))) + }; + let resource = |doc: &mut Document, program: ObjRef| { + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut cid = Dict::new(); + cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + let mut top = Dict::new(); + top.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)])); + doc.add(Object::Dict(top)) + }; + // 200 resources sharing one decoy program decode it once. + let mut doc = Document::new_empty(); + let shared = decoy(&mut doc); + let mut fonts = Dict::new(); + for i in 0..200 { + let r = resource(&mut doc, shared); + fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r)); + } + let mut programs = ProgramMatch::new(bytes); + assert_eq!(existing_program(&doc, &fonts, &mut programs), None); + assert_eq!(programs.decodes, 1); + // 100 distinct decoys decode a bounded number of times. + let mut doc = Document::new_empty(); + let mut fonts = Dict::new(); + for i in 0..100 { + let program = decoy(&mut doc); + let r = resource(&mut doc, program); + fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r)); + } + let mut programs = ProgramMatch::new(bytes); + assert_eq!(existing_program(&doc, &fonts, &mut programs), None); + assert_eq!(programs.decodes, MAX_PROGRAM_DECODES); + } + #[test] fn the_full_program_is_written_compressed_and_another_page_reuses_it() { let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs index 64069a3de1..d309c6ba8f 100644 --- a/crates/fonts/src/fallback.rs +++ b/crates/fonts/src/fallback.rs @@ -156,8 +156,7 @@ impl Substitution { fn script_for(ordering: &str) -> Option<&'static str> { match ordering { "Japan1" => Some("Jpan"), - // Registered orderings we have no openly licensed face for. AGENTS.md 1.1 rules out - // Source Han and Noto CJK (Source Han rebranded), which is why these have none. + // Registered orderings the chain maps to no craft-fonts face. "GB1" | "CNS1" | "Korea1" | "KR" => Some(""), _ => None, } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index a1f2ff2779..61061f5509 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -7,6 +7,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(test)] +mod cjk_scripts; mod craft; pub mod embedding; mod encodings; @@ -18,9 +20,9 @@ pub mod source_shaping; mod std14; mod std14_metrics; pub use craft::{ - CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style, - document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts, - ui_telugu_fonts, ui_thai_fonts, + CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_devanagari_font, document_hebrew_font, document_japanese_font, + document_japanese_font_for_style, document_japanese_fonts_for_style, document_latin_fonts, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, + ui_cjk_fonts, ui_devanagari_fonts, ui_hebrew_fonts, ui_japanese_fonts, ui_telugu_fonts, ui_thai_fonts, }; pub use script::{ GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline, @@ -51,6 +53,18 @@ pub fn cjk_width(s: &str, size: f64) -> f64 { .sum() } +/// Approximate advance of `s` in Inter Regular at `size` points. +pub fn inter_width(s: &str, size: f64) -> f64 { + use skrifa::MetadataProvider as _; + let Ok(font) = skrifa::FontRef::new(INTER_REGULAR) else { return helvetica_width(s, size) }; + let loc = skrifa::instance::LocationRef::default(); + let upem = font.metrics(skrifa::instance::Size::unscaled(), loc).units_per_em.max(1) as f64; + let scale = size / upem; + let charmap = font.charmap(); + let advances = font.glyph_metrics(skrifa::instance::Size::unscaled(), loc); + s.chars().map(|c| charmap.map(c).and_then(|gid| advances.advance_width(gid)).unwrap_or(0.0) as f64 * scale).sum() +} + /// East Asian wide and full-width characters (Unicode UAX #11 W and F, by block). fn is_wide(c: char) -> bool { matches!(c, @@ -69,23 +83,35 @@ fn is_wide(c: char) -> bool { ) } -/// Greedy line breaking within `width` points (paragraphs split on newlines; words longer +/// Greedy line breaking within `width` points (paragraphs split on line breaks, where CRLF counts as one; words longer /// than a line are broken by character). pub fn wrap(text: &str, size: f64, width: f64) -> Vec { wrap_with(text, size, width, helvetica_width) } /// [`wrap`], measuring with `measure` (for example [`cjk_width`]). +/// +/// A line measures as the sum of its words, the spaces between them and the characters of a broken +/// word, each measured once, so the work stays linear in `text` however much fits on a line. pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec { let mut lines = Vec::new(); - for para in text.split(['\n', '\r']) { + let space = measure(" ", size); + let mut buf = [0u8; 4]; + // A CRLF pair is one break, a lone CR or LF is one break each. + for para in text.replace("\r\n", "\n").split(['\n', '\r']) { let mut line = String::new(); + let mut line_width = 0.0; // No word is on the line yet; an empty word (a leading space) still starts it. let mut fresh = true; for word in para.split(' ') { - let candidate = if fresh { word.to_string() } else { format!("{line} {word}") }; - if measure(&candidate, size) <= width || fresh && measure(word, size) <= width { - line = candidate; + let word_width = measure(word, size); + let candidate = if fresh { word_width } else { line_width + space + word_width }; + if candidate <= width { + if !fresh { + line.push(' '); + } + line.push_str(word); + line_width = candidate; fresh = false; continue; } @@ -95,11 +121,15 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) } else { lines.push(std::mem::take(&mut line)); } + line_width = 0.0; for ch in word.chars() { - if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width { + let ch_width = measure(ch.encode_utf8(&mut buf), size); + if !line.is_empty() && line_width + ch_width > width { lines.push(std::mem::take(&mut line)); + line_width = 0.0; } line.push(ch); + line_width += ch_width; } fresh = line.is_empty(); } @@ -277,6 +307,15 @@ mod tests { assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0)); } + #[test] + fn crlf_is_one_line_break() { + assert_eq!(wrap("a\r\nb", 12.0, 100.0), ["a", "b"]); + assert_eq!(wrap("a\rb", 12.0, 100.0), ["a", "b"]); + assert_eq!(wrap("a\nb", 12.0, 100.0), ["a", "b"]); + assert_eq!(wrap("a\r\n\r\nb", 12.0, 100.0), ["a", "", "b"]); + assert_eq!(wrap("a\n\rb", 12.0, 100.0), ["a", "", "b"]); + } + #[test] fn win_ansi_encodability_matches_the_encoder() { for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] { @@ -313,6 +352,14 @@ mod tests { assert_eq!(undrawn_mark(&d), None); } + #[test] + fn inter_width_measures_latin_extended_and_ascii() { + assert!(inter_width("Luka Petravić", 12.0) > 0.0); + assert_eq!(inter_width("", 12.0), 0.0); + let w = inter_width("čžšć", 10.0); + assert!(w > 10.0 && w < 100.0); + } + #[test] fn widths_wrap_and_encode() { assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0); @@ -412,4 +459,20 @@ mod tests { let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width); assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}"); } + + /// Wrapping measures each word and character a bounded number of times, however many fit on a line. + #[test] + fn wrapping_measures_each_word_a_few_times() { + let measured = std::cell::Cell::new(0usize); + let measure = |s: &str, size: f64| { + measured.set(measured.get() + s.len()); + helvetica_width(s, size) + }; + let text = format!("{} {}", "word ".repeat(5_000), "x".repeat(5_000)); + let lines = wrap_with(&text, 1.0, 13_000.0, measure); + assert_eq!(lines.len(), 2, "the words fit on one line, the long word on another"); + assert!(measured.get() <= 4 * text.len(), "{} bytes measured", measured.get()); + let narrow = wrap_with(&text, 10.0, 100.0, helvetica_width); + assert!(narrow.iter().all(|l| helvetica_width(l, 10.0) <= 100.0), "{narrow:?}"); + } } diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 26685866b3..240b1c4d55 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::collections::{BinaryHeap, HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; /// How the bytes of a string map to codes. #[derive(Clone, Debug, PartialEq)] @@ -33,9 +33,9 @@ pub struct Metrics { widths: Vec, /// Composite fonts: CID → width, and CID ranges with one width. cid_widths: HashMap, - cid_ranges: Vec<(u32, u32, f64)>, + cid_ranges: Spans, /// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`). - cid_map: Vec<(u32, u32, u32)>, + cid_map: Spans, default: f64, /// Composite fonts with an explicit `/DW`: `/W` may leave out the glyphs that have that /// width (Chrome and Skia do), so a CID missing from `/W` is not a missing glyph. @@ -92,6 +92,15 @@ fn legacy_indic_font(base_font: &str) -> bool { /// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export /// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer. pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { + embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0)) +} + +/// The largest embedded sfnt program read for the editor. +const MAX_SFNT: usize = 16 << 20; + +/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares: +/// each program decoded is charged whether or not it turns out to be usable. +pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option> { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref()); let descriptor = dict(doc, descendant.as_ref().unwrap_or(font).get(b"FontDescriptor"))?; @@ -99,7 +108,7 @@ pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> { let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue }; let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; @@ -275,6 +284,9 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20; /// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few /// thousand. const MAX_CMAP_RANGES: usize = 1 << 16; +/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger +/// one is not decoded at all. +const MAX_CMAP_BYTES: usize = 16 << 20; /// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range /// costs one record however many codes it covers; where two overlap, the one declared later wins. @@ -598,8 +610,8 @@ impl Metrics { first: 0, widths: Vec::new(), cid_widths: HashMap::new(), - cid_ranges: Vec::new(), - cid_map: Vec::new(), + cid_ranges: Spans::default(), + cid_map: Spans::default(), default: 500.0, explicit_default: false, std14: Some(crate::Std14::Helvetica), @@ -679,13 +691,13 @@ impl Metrics { m.codes = Codes::Two; if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) && let Object::Stream(s) = &*e - && let Ok(data) = s.decoded() + && let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { let (spaces, cids) = parse_cmap(&data); if !spaces.is_empty() { m.codes = Codes::Ranges(spaces); } - m.cid_map = cids; + m.cid_map = Spans::new(cids); } let desc = font .get(b"DescendantFonts") @@ -698,6 +710,7 @@ impl Metrics { m.explicit_default = true; } if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) { + let mut ranges = Vec::new(); let mut i = 0; while i < w.len() { let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break }; @@ -713,14 +726,17 @@ impl Metrics { i += 2; } Some(c1) => { - if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) { - m.cid_ranges.push((c0, c1 as u32, v)); + if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) + && ranges.len() < MAX_CMAP_ENTRIES + { + ranges.push((c0, c1 as u32, v)); } i += 3; } None => break, } } + m.cid_ranges = Spans::new(ranges); } descriptor = dict(doc, desc.get(b"FontDescriptor")); } else { @@ -791,14 +807,14 @@ impl Metrics { if self.cid_map.is_empty() { return code; } - self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo)) + self.cid_map.find(code).map_or(0, |(lo, _, c)| c.saturating_add(code.saturating_sub(*lo))) } /// The advance of `code` in text space per unit of font size. pub fn width(&self, code: u32) -> f64 { if self.composite { let cid = self.cid(code); - let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2)); + let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.find(cid).map(|r| r.2)); return w.unwrap_or(self.default) * self.scale; } if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { @@ -807,7 +823,7 @@ impl Metrics { match self.std14 { // A font with no /Widths is one of the standard 14; use its published metrics. // Codes the font's encoding leaves undefined fall back to /MissingWidth. - Some(f) => match u8::try_from(code).map(|c| f.width(c)).unwrap_or(0.0) { + Some(f) => match u8::try_from(self.std14_code(f, code)).map(|c| f.width(c)).unwrap_or(0.0) { 0.0 => self.default * self.scale, w => w / 1000.0, }, @@ -815,6 +831,17 @@ impl Metrics { } } + /// Where the standard-14 table, indexed by WinAnsi, holds the glyph `code` draws in this font: + /// its encoding (base and `/Differences`) names the glyph, so a remapped code is measured as + /// the glyph it now shows (#2894). Symbol and ZapfDingbats are indexed by their own encodings, + /// and a glyph WinAnsi has no code for keeps the code itself. + fn std14_code(&self, face: crate::Std14, code: u32) -> u32 { + if matches!(face, crate::Std14::Symbol | crate::Std14::ZapfDingbats) { + return code; + } + self.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(crate::win_ansi_byte).map_or(code, u32::from) + } + /// Word spacing applies to the single-byte code 32 (§9.3.3). pub fn is_space(&self, code: u32, len: usize) -> bool { code == 32 && len == 1 @@ -847,7 +874,7 @@ impl Metrics { // A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for // those, only the subset's program says whether the glyph is there. return self.cid_widths.contains_key(&cid) - || self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b) + || self.cid_ranges.find(cid).is_some() || !self.subset || self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1) // No readable TrueType program to ask (e.g. CFF or missing): an explicit /DW @@ -894,9 +921,13 @@ impl Metrics { } } -/// The UTF-16 code units of CMap hex bytes. +/// Most UTF-16 units kept from one ToUnicode destination: the CMap format allows destinations of up +/// to 512 bytes, and every glyph a sequential range covers builds its text from the whole of one. +const MAX_DESTINATION_UNITS: usize = 256; + +/// The UTF-16 code units of CMap hex bytes, at most [`MAX_DESTINATION_UNITS`] of them. fn code_units(b: &[u8]) -> Vec { - b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect() + b.chunks(2).take(MAX_DESTINATION_UNITS).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect() } /// UTF-16BE (with surrogates) from CMap hex bytes. @@ -943,16 +974,69 @@ fn add_range(out: &mut Unicode, budget: &mut usize, seq: &mut u32, lo: u32, hi: true } +#[cfg(test)] +thread_local! { + /// Code span records read by CID and width lookups, for the tests that keep a lookup logarithmic. + static SPAN_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts one span record read (tests only). +#[inline] +fn count_span_reads() { + #[cfg(test)] + SPAN_READS.with(|c| c.set(c.get().saturating_add(1))); +} + +/// Code spans `(lo, hi, value)` in declaration order, where the first-declared span covering a code +/// wins, indexed so that a lookup is one binary search. +#[derive(Clone, Debug, Default, PartialEq)] +struct Spans { + spans: Vec<(u32, u32, T)>, + /// The span covering each stretch of codes; see [`stretches`]. + cover: Vec<(u64, Option)>, +} + +impl Spans { + fn new(spans: Vec<(u32, u32, T)>) -> Self { + let bounds: Vec<(u32, u32)> = spans.iter().map(|s| (s.0, s.1)).collect(); + Spans { cover: stretches(&bounds, false), spans } + } + + fn is_empty(&self) -> bool { + self.spans.is_empty() + } + + /// The first-declared span covering `code`. + fn find(&self, code: u32) -> Option<&(u32, u32, T)> { + let step = self.cover.partition_point(|&(start, _)| { + count_span_reads(); + start <= u64::from(code) + }); + let &(_, record) = self.cover.get(step.checked_sub(1)?)?; + self.spans.get(record?) + } +} + /// For each stretch of codes, from its first code on, the latest-declared range covering it (`None` /// where no range does), sorted by code; a lookup is one binary search over it. fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> { + let spans: Vec<(u32, u32)> = ranges.iter().map(|r| (r.lo, r.hi)).collect(); + stretches(&spans, true) +} + +/// For each stretch of codes, from its first code on, the span `(lo, hi)` covering it (`None` where +/// none does), sorted by code: the latest-declared one when `later_wins`, else the first-declared. +/// At most `u32::MAX` spans are indexed. +fn stretches(spans: &[(u32, u32)], later_wins: bool) -> Vec<(u64, Option)> { + // Heap keys, largest for the winning record; the mapping is its own inverse. + let key = |record: u32| if later_wins { record } else { !record }; // Openings (first code, record) and closings (one past the last code), each in code order. The cover // can only change at one of their codes, so the sweep visits those codes in order. - let mut opens: Vec<(u32, u32)> = ranges.iter().enumerate().map(|(i, r)| (r.lo, i as u32)).collect(); + let mut opens: Vec<(u32, u32)> = spans.iter().zip(0..u32::MAX).map(|(&(lo, _), i)| (lo, i)).collect(); opens.sort_unstable(); - let mut closes: Vec = ranges.iter().map(|r| u64::from(r.hi) + 1).collect(); + let mut closes: Vec = spans.iter().take(opens.len()).map(|&(_, hi)| u64::from(hi) + 1).collect(); closes.sort_unstable(); - // Records that have opened and not yet closed; the top is the latest declared one. + // Keys of the records that have opened and not yet closed; the top is the winning one. let mut open: BinaryHeap = BinaryHeap::new(); let mut cover: Vec<(u64, Option)> = Vec::new(); let (mut next_open, mut next_close) = (0, 0); @@ -966,16 +1050,16 @@ fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> { while let Some(&(lo, record)) = opens.get(next_open) && u64::from(lo) <= p { - open.push(record); + open.push(key(record)); next_open += 1; } while closes.get(next_close).is_some_and(|&end| end <= p) { next_close += 1; } - while open.peek().is_some_and(|&top| ranges.get(top as usize).is_none_or(|r| u64::from(r.hi) < p)) { + while open.peek().is_some_and(|&top| spans.get(key(top) as usize).is_none_or(|&(_, hi)| u64::from(hi) < p)) { open.pop(); } - let winner = open.peek().map(|&top| top as usize); + let winner = open.peek().map(|&top| key(top) as usize); if cover.last().map(|&(_, w)| w) != Some(winner) { cover.push((p, winner)); } @@ -1143,7 +1227,8 @@ pub fn glyph_unicode(name: &str) -> Option { chars.next().filter(|_| chars.len() == 0) } -/// Code → Unicode for a font: its ToUnicode CMap, else (simple fonts) its encoding. +/// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name +/// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding. fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { let mut out = Unicode::default(); if !composite { @@ -1206,648 +1291,47 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { } } } - if let Some(Object::Stream(s)) = font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() - && let Ok(data) = s.decoded() - { - parse_to_unicode(&data, &mut out); + match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() { + Some(Object::Stream(s)) => { + if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) { + parse_to_unicode(&data, &mut out); + } + } + // `/ToUnicode /Identity-H` is not a stream, as ISO 32000-2 §9.10.3 requires, but CAD + // exporters (WSCAD among them) write it for composite fonts whose two-byte codes are the + // UTF-16 code units. Acrobat shows them as code = Unicode, and so does the renderer's + // text layer; without it the text could be seen but not edited or found. The same rule + // (code = Unicode, no controls or lone surrogates) is in the vendored renderer, + // `Type0Font::char_code_to_unicode` in vendor/hayro-interpret/src/font/cid.rs: keep both alike. + Some(Object::Name(n)) if composite && matches!(n.as_slice(), b"Identity-H" | b"Identity-V") => { + parse_to_unicode(&identity_to_unicode(), &mut out); + } + _ => {} } out } -#[cfg(test)] -mod tests { - use pdfcraft_cos::{Dict, Document, Object, Stream}; - - use super::*; - - /// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. - #[test] - fn low_code_tables_end_with_the_last_covered_code() { - // One range over codes 0x20..=0x21, then nothing. - let cover = [(0x20u64, Some(0usize)), (0x22, None)]; - let owner = super::owners_below_64k(&cover); - assert_eq!(owner.len(), 0x22); - assert_eq!((owner[0x1F], owner[0x20], owner[0x21]), (u32::MAX, 0, 0)); - assert!(super::owners_below_64k(&[(0x2_0000, Some(0))]).is_empty(), "no code below 65 536"); - let bits = super::bit_set_below_64k([3u32, 130, 0x2_0000].into_iter()); - assert_eq!(bits.len(), 3); - assert_eq!((bits[0], bits[2]), (1 << 3, 1 << 2)); - assert!(super::bit_set_below_64k([0x1_0000u32].into_iter()).is_empty()); - } - - fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Font")); - for (k, v) in entries { - d.set(k.as_bytes().to_vec(), v); +/// A ToUnicode CMap mapping each two-byte code to the same UTF-16 code unit, leaving out the +/// surrogates (which are not characters on their own) and the controls (C0, DEL and C1, as the +/// renderer's text layer does). No range crosses a last-byte boundary (§9.10.3). +fn identity_to_unicode() -> Vec { + let mut ranges = vec![(0x20u32, 0x7Eu32), (0xA0, 0xFF)]; + ranges.extend((0x01u32..=0xFF).filter(|h| !(0xD8..=0xDF).contains(h)).map(|h| (h << 8, (h << 8) | 0xFF))); + let mut cmap = String::from("1 begincodespacerange <0000> endcodespacerange\n"); + // At most 100 entries per section (§9.7.5.2 limits for CMap files). + for chunk in ranges.chunks(100) { + cmap.push_str(&format!("{} beginbfrange\n", chunk.len())); + for (lo, hi) in chunk { + cmap.push_str(&format!("<{lo:04X}> <{hi:04X}> <{lo:04X}>\n")); } - let _ = doc; - d - } - - #[test] - fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { - use pdfcraft_cos::Stream; - let mut doc = Document::new_empty(); - let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor.clone()))]); - assert_eq!(embedded_sfnt(&doc, &simple).unwrap(), bytes); - let composite = font(&mut doc, vec![("DescendantFonts", Object::Array(vec![Object::Dict(simple.clone())]))]); - assert_eq!(embedded_sfnt(&doc, &composite).unwrap(), bytes); - let broken = doc.add(Object::Stream(Stream::flate(Dict::new(), b"broken font"))); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(broken)); - let broken = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); - assert!(embedded_sfnt(&doc, &broken).is_none()); - assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); - } - - /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name - /// ("Mono" would mean Courier, 600 units for every glyph). - #[test] - fn a_font_without_widths_measures_with_its_embedded_program() { - use pdfcraft_cos::Stream; - use skrifa::MetadataProvider as _; - let mut doc = Document::new_empty(); - let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let face = skrifa::FontRef::new(bytes).unwrap(); - let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); - let upem = f64::from(face.metrics(size, location).units_per_em); - let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("TrueType")), - ("BaseFont", Object::name("HouseMono")), - ("Encoding", Object::name("WinAnsiEncoding")), - ("FontDescriptor", Object::Dict(descriptor)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - for c in ['i', 'l', 'W', ' '] { - assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); - } - assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); - } - - #[test] - fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { - use skrifa::{MetadataProvider as _, raw::TableProvider as _}; - let original = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); - let face = skrifa::FontRef::new(original).unwrap(); - let expected = face.charmap().map('Ω').unwrap(); - let mut subset = original.to_vec(); - let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; - let entry = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"cmap").unwrap(); - let mut cmap = vec![0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 12]; - cmap.extend_from_slice(&[0, 6, 0, 12, 0, 0, 0, 65, 0, 1]); - cmap.extend_from_slice(&u16::try_from(expected.to_u32()).unwrap().to_be_bytes()); - let offset = subset.len() as u32; - subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); - subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); - subset.extend_from_slice(&cmap); - let face = skrifa::FontRef::new(&subset).unwrap(); - assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); - assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); - let mut doc = Document::new_empty(); - let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); - let mut descriptor = Dict::new(); - descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); - let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfchar <41> <03A9> endbfchar"))); - let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); - let editor = embedded_sfnt(&doc, &source).unwrap(); - let face = skrifa::FontRef::new(&editor).unwrap(); - assert_eq!(face.charmap().map('Ω'), Some(expected), "Unicode selects the original glyph"); - let Object::Stream(source) = doc.resolve(&Object::Ref(program)).as_ref().clone() else { - panic!("source stream"); - }; - assert_eq!(source.decoded().unwrap(), subset, "only an in-memory editor copy is adapted"); - let mut broken = vec![0; 16]; - assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); - } - - fn metrics_with_flags(flags: Option) -> Metrics { - let mut doc = Document::new_empty(); - let mut descriptor = Dict::new(); - if let Some(flags) = flags { - descriptor.set(b"Flags".to_vec(), flags); - } - let font = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); - Metrics::from_dict(&doc, &font) - } - - #[test] - fn font_descriptor_flags_stay_within_the_u32_domain() { - let valid = metrics_with_flags(Some(Object::Int(64 | 262_144))); - assert!(valid.italic && valid.bold); - - for flags in [Some(Object::Int(-1)), Some(Object::Int(4_294_967_296)), Some(Object::Bool(true)), None] { - let metrics = metrics_with_flags(flags); - assert!(!metrics.italic && !metrics.bold); - } - } - - #[test] - fn type3_descriptor_names_the_font_without_changing_its_metrics() { - let mut doc = Document::new_empty(); - let mut f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type3")), - ( - "FontMatrix", - Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), - ), - ("FontBBox", Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])), - ], - ); - let before = Metrics::from_dict(&doc, &f); - let mut descriptor = Dict::new(); - descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); - descriptor.set(b"FontName".to_vec(), Object::name("ExampleMincho-Regular")); - descriptor.set(b"Flags".to_vec(), Object::Int(6)); - descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); - let reference = doc.add(Object::Dict(descriptor)); - f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); - let after = Metrics::from_dict(&doc, &f); - assert_eq!(after.base_font, "ExampleMincho-Regular"); - assert_eq!((after.ascent, after.descent, after.scale), (before.ascent, before.descent, before.scale)); - assert!(!after.bold && !after.italic && !after.subset); - f.set(b"BaseFont".to_vec(), Object::name("ExplicitName")); - assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence"); - } - - #[test] - fn simple_fonts_decode_and_encode_through_their_encoding() { - let mut doc = Document::new_empty(); - let f = font( - &mut doc, - vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::name("WinAnsiEncoding"))], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(b"Caf\xe9 \x80 \x93x\x94"), "Café € “x”"); - assert_eq!(m.encode("Café €").as_deref(), Some(&b"Caf\xe9 \x80"[..])); - assert_eq!(m.encode("Ω"), None, "not in WinAnsi"); - // Differences rename codes by glyph name. - let mut enc = Dict::new(); - enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(65), Object::name("eacute"), Object::name("uni03A9")])); - let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Custom")), ("Encoding", Object::Dict(enc))]); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(b"AB"), "éΩ"); - assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..])); - } - - #[test] - fn subset_fonts_only_encode_the_glyphs_they_have() { - let mut doc = Document::new_empty(); - // Widths for a, b only (97, 98); c (99) is 0. - let widths = Object::Array(vec![Object::Int(500), Object::Int(520), Object::Int(0)]); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("TrueType")), - ("BaseFont", Object::name("ABCDEF+Arial")), - ("FirstChar", Object::Int(97)), - ("Widths", widths), - ("Encoding", Object::name("WinAnsiEncoding")), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert!(m.subset); - assert_eq!(m.encode("ab").as_deref(), Some(&b"ab"[..])); - assert_eq!(m.encode("abc"), None); - } - - #[test] - fn composite_fonts_use_to_unicode() { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 2 beginbfchar <0003> <0020> <0010> <00E9> endbfchar 1 beginbfrange <0024> <0026> <0041> endbfrange \ - 1 beginbfrange <0030> <0031> [<0048> <0069>] endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set( - b"W".to_vec(), - Object::Array(vec![ - Object::Int(3), - Object::Array(vec![Object::Int(250)]), - Object::Int(16), - Object::Int(16), - Object::Int(500), - Object::Int(36), - Object::Int(49), - Object::Int(600), - ]), - ); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("QWERTY+Noto")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.decode(&[0, 0x24, 0, 0x25, 0, 3, 0, 0x30, 0, 0x31, 0, 0x10]), "AB Hié"); - assert_eq!(m.encode("Hi A").as_deref(), Some(&[0, 0x30, 0, 0x31, 0, 3, 0, 0x24][..])); - assert_eq!(m.encode("Z"), None); - } - - /// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a - /// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). - #[test] - fn composite_fonts_never_encode_with_cid_0() { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ - 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); - desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); - assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); - } - - /// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is - /// still in the font, but nothing is when the font gives no default width to point at. - #[test] - fn composite_subset_glyphs_of_the_default_width_are_present() { - let metrics = |dw: bool| { - let mut doc = Document::new_empty(); - let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ - 1 beginbfrange <0055> <0057> <0072> endbfrange end"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - if dw { - desc.set(b"DW".to_vec(), Object::Int(500)); - } - desc.set( - b"W".to_vec(), - Object::Array(vec![ - Object::Int(0x55), - Object::Array(vec![Object::Int(333)]), - Object::Int(0x57), - Object::Array(vec![Object::Int(277)]), - ]), - ); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("AAAAAA+LiberationSans")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ("ToUnicode", Object::Ref(tu)), - ], - ); - Metrics::from_dict(&doc, &f) - }; - let chrome = metrics(true); - assert_eq!(chrome.encode("rst").as_deref(), Some(&[0, 0x55, 0, 0x56, 0, 0x57][..])); - assert_eq!(chrome.width(0x56), 0.5); - assert_eq!(chrome.encode("x"), None, "a character the ToUnicode map doesn't give"); - assert_eq!(metrics(false).encode("s"), None); - } - - /// A `/W` run that starts at the last CID stops there instead of overflowing onto CID 0. - #[test] - fn composite_width_run_past_the_last_cid_is_dropped() { - let widths = |first: i64| { - let mut doc = Document::new_empty(); - let mut desc = Dict::new(); - desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); - desc.set(b"DW".to_vec(), Object::Int(1000)); - desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(first), Object::Array(vec![Object::Int(100), Object::Int(200)])])); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("Encoding", Object::name("Identity-H")), - ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), - ], - ); - Metrics::from_dict(&doc, &f) - }; - let ordinary = widths(0); - assert_eq!((ordinary.width(0), ordinary.width(1)), (0.1, 0.2)); - let last = widths(i64::from(u32::MAX)); - assert_eq!((last.width(0), last.width(u32::MAX)), (1.0, 0.1)); - } - - #[test] - fn glyph_names() { - assert_eq!(glyph_unicode("quotedblleft"), Some('“')); - assert_eq!(glyph_unicode("uni20AC"), Some('€')); - assert_eq!(glyph_unicode("u1F600"), Some('😀')); - assert_eq!(glyph_unicode("nonsense"), None); - } - - fn to_unicode(cmap: &[u8]) -> HashMap { - let mut u = Unicode::default(); - parse_to_unicode(cmap, &mut u); - let codes: Vec = u.explicit.keys().copied().chain(u.ranges.iter().flat_map(|r| r.lo..=r.hi)).collect(); - codes.into_iter().filter_map(|c| u.text(c).map(|t| (c, t.into_owned()))).collect() - } - - #[test] - fn cmap_tokens_need_no_space_between_them() { - // Hex strings, brackets and operators may touch; comments and literal strings are skipped. - let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ - 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ - 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; - let map = to_unicode(cmap); - let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); - got.sort_unstable(); - assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); - let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); - assert_eq!(spaces, [(1, vec![0], vec![0xff])]); - assert_eq!(cids, [(0x20, 0x7e, 1)]); - } - - #[test] - fn malformed_cmaps_never_panic() { - // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. - for cmap in [ - "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), - "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), - b"1 beginbfchar <0001> <004", - b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", - b"1 beginbfrange <0001> [<0041>] endbfrange", - b"1 beginbfrange <0001> <0002> [<0041> (", - b"1 beginbfrange <0001>", - b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", - ] { - to_unicode(cmap); - parse_cmap(cmap); - } - // Code counters at the top of the range stop instead of overflowing. - let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); - assert_eq!(map.len(), 2); - assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); - // A malformed array entry keeps its place, so the next one maps the next code. - let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); - assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); - let mut m = Metrics::fallback(); - (_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange"); - assert_eq!(m.cid(5), u32::MAX); - let mut doc = Document::new_empty(); - let mut enc = Dict::new(); - enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); - let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); - assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); - } - - #[test] - fn sequential_ranges_past_the_entry_cap_still_map() { - // 17 ranges of 65 536 codes: 1 114 112 codes from 0.4 KB of CMap, more than MAX_CMAP_ENTRIES - // (the first 16 ranges use up the whole cap). Each range maps its codes upward from 'A', so - // the last code of the last range, 0x100019, is 'A' + 0x19 = 'Z'. - let mut cmap = b"17 beginbfrange ".to_vec(); - for k in 0..17u32 { - cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); - } - cmap.extend(b"endbfrange"); - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Wide")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.text_of(0x0010_0019).as_deref(), Some("Z")); - } - - #[test] - fn a_cmap_adds_at_most_max_entries() { - // 200 ranges of 65 536 codes would be 13 million codes from 4 KB of stream: 200 records. - let mut cmap = b"200 beginbfrange ".to_vec(); - for k in 0..200u32 { - cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); - } - cmap.extend(b"endbfrange"); - let mut u = Unicode::default(); - parse_to_unicode(&cmap, &mut u); - assert_eq!((u.ranges.len(), u.explicit.len()), (200, 0)); - // Explicit entries are what the budget counts: 250 of the 300 here are kept. - let mut many = b"300 beginbfchar ".to_vec(); - for k in 0..300u32 { - many.extend(format!("<{k:04X}> <{:04X}> ", 0x4E00 + k).bytes()); - } - many.extend(b"endbfchar"); - let mut u = Unicode::default(); - parse_to_unicode_within(&many, &mut u, 250); - assert_eq!((u.explicit.len(), u.text(249).is_some(), u.text(250).is_some()), (250, true, false)); - let mut many = b"begincidrange ".to_vec(); - for k in 0..MAX_CMAP_RANGES + 10 { - many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); - } - assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); - } - - #[test] - fn a_range_is_one_record_however_many_codes() { - let mut u = Unicode::default(); - parse_to_unicode(b"1 beginbfrange <0000> <0041> endbfrange", &mut u); - assert_eq!((u.ranges.len(), u.explicit.len()), (1, 0)); - assert_eq!(u.text(0xFFFF).as_deref(), Some("@"), "0x41 + 0xFFFF wraps to U+0040"); - } - - #[test] - fn sequential_ranges_count_against_the_budget() { - // Range k maps codes k and k + 1 and costs two, so a budget of 250 keeps ranges 0 to 124. - let mut cmap = Vec::new(); - for k in 0..300u32 { - cmap.extend(format!("1 beginbfrange <{k:08X}> <{:08X}> <0041> endbfrange ", k + 1).bytes()); - } - let mut u = Unicode::default(); - parse_to_unicode_within(&cmap, &mut u, 250); - assert_eq!((u.ranges.len(), u.text(125).is_some(), u.text(126).is_none()), (125, true, true)); - } - - #[test] - fn explicit_and_range_mappings_override_the_encoding() { - let mut doc = Document::new_empty(); - let cmap = b"1 beginbfchar <41> <0058> endbfchar 1 beginbfrange <42> <43> <0059> endbfrange"; - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type1")), - ("BaseFont", Object::name("Custom")), - ("Encoding", Object::name("WinAnsiEncoding")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - let shown: Vec> = (0x41..=0x44).map(|c| m.text_of(c).map(|t| t.into_owned())).collect(); - assert_eq!(shown, [Some("X".to_string()), Some("Y".to_string()), Some("Z".to_string()), Some("D".to_string())]); - } - - /// Deterministic input for the differential test below. - struct Gen(u64); - - impl Gen { - fn below(&mut self, n: u32) -> u32 { - self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); - ((self.0 >> 33) % u64::from(n)) as u32 - } - - /// A destination: a unit from a pool of edge cases, a surrogate pair, or a lone high surrogate. - fn units(&mut self) -> Vec { - const POOL: [u16; 12] = [0x41, 0x42, 0xE9, 0x0, 0xFFFD, 0xFFFE, 0xFFFF, 0x100, 0x3042, 0xD800, 0xDC00, 0xDFFF]; - match self.below(6) { - 0 => vec![0xD835, 0xDC00 + self.below(0x400) as u16], - 1 => vec![0xD800, 0x41], - _ => vec![POOL[self.below(POOL.len() as u32) as usize]], - } - } - } - - fn hex(units: &[u16]) -> String { - units.iter().map(|u| format!("{u:04X}")).collect() - } - - #[test] - fn lookups_match_the_expanded_mappings() { - // Random ToUnicode CMaps; `expected` is what expanding their entries in order gives. - let mut g = Gen(0x2545_f491_4f6c_dd1d); - for _ in 0..300 { - let mut cmap = String::from("1 begincodespacerange <0000> endcodespacerange "); - let mut expected: HashMap = HashMap::new(); - for _ in 0..1 + g.below(12) { - match g.below(3) { - 0 => { - let (code, u) = (g.below(300), g.units()); - cmap += &format!("1 beginbfchar <{code:04X}> <{}> endbfchar ", hex(&u)); - expected.insert(code, String::from_utf16_lossy(&u)); - } - 1 => { - let (lo, width, u) = (g.below(300), g.below(13), g.units()); - cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> <{}> endbfrange ", lo + width, hex(&u)); - for k in 0..=width { - let mut v = u.clone(); - if let Some(last) = v.last_mut() { - *last = last.wrapping_add(k as u16); - } - expected.insert(lo + k, String::from_utf16_lossy(&v)); - } - } - _ => { - let (lo, width) = (g.below(300), g.below(5)); - let dests: Vec> = (0..=width).map(|_| g.units()).collect(); - let list: Vec = dests.iter().map(|d| format!("<{}>", hex(d))).collect(); - cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> [{}] endbfrange ", lo + width, list.join(" ")); - for (k, d) in dests.iter().enumerate() { - expected.insert(lo + k as u32, String::from_utf16_lossy(d)); - } - } - } - } - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.clone().into_bytes()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Gen")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - for code in 0..=320u32 { - let got = m.text_of(code).map(|t| t.into_owned()); - assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); - } - let mut reverse: HashMap = HashMap::new(); - // Code 0 is CID 0, `.notdef`, which no character is encoded with. - for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { - if let Some(c) = only_char(text) { - keep(&mut reverse, c, code); - } - } - let mut chars: Vec = expected.values().flat_map(|t| t.chars()).chain(['A', '\u{FFFD}', '\u{0}', '\u{1D400}']).collect(); - chars.sort_unstable(); - chars.dedup(); - for c in chars { - let want = reverse.get(&c).map(|code| code.to_be_bytes()[2..].to_vec()); - assert_eq!(m.encode(&c.to_string()), want, "char {c:?} in {cmap}"); - } - } - } - - #[test] - fn fffd_lookups_skip_overridden_surrogates() { - // Each record maps every code to itself, so its surrogate offsets decode to U+FFFD. The last - // record overrides the surrogates with letters, so each lookup walks past them to U+FFFD itself. - let mut cmap = "1 beginbfrange <0000> <0000> endbfrange ".repeat(300); - cmap.push_str("1 beginbfrange <0041> endbfrange"); - let mut doc = Document::new_empty(); - let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.into_bytes()))); - let f = font( - &mut doc, - vec![ - ("Subtype", Object::name("Type0")), - ("BaseFont", Object::name("Gen")), - ("Encoding", Object::name("Identity-H")), - ("ToUnicode", Object::Ref(tu)), - ], - ); - let m = Metrics::from_dict(&doc, &f); - assert_eq!(m.encode("\u{FFFD}"), Some(vec![0xFF, 0xFD])); - } - - #[test] - fn hostile_ranges_stay_bounded() { - // A range wider than 65 536 codes is ignored, a reversed one maps nothing, and a maximal - // one-code range maps just its own code. - let mut u = Unicode::default(); - let cmap = b"1 beginbfrange <00000000> <0041> endbfrange \ - 1 beginbfrange <0005> <0001> <0041> endbfrange \ - 1 beginbfrange endbfrange"; - parse_to_unicode(cmap, &mut u); - assert_eq!((u.ranges.len(), u.text(u32::MAX).as_deref()), (0, Some("\u{FFFF}"))); - } - - #[test] - fn low_codes_resolve_to_the_covering_range() { - let mut u = Unicode::default(); - let cmap = b"1 beginbfrange <0005> <0007> <0041> endbfrange 1 beginbfrange <0064> <0065> <0042> endbfrange \ - 1 beginbfrange <0000FFFE> <00010001> <0043> endbfrange"; - parse_to_unicode(cmap, &mut u); - let covered: Vec = (0..0x70).filter(|&c| u.covering(c).is_some()).collect(); - assert_eq!(covered, [5, 6, 7, 100, 101]); - assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); + cmap.push_str("endbfrange\n"); } + cmap.into_bytes() } +#[cfg(test)] +mod tests; + #[cfg(test)] mod encoding_tests { use pdfcraft_cos::{Dict, Document, Object}; @@ -2006,4 +1490,20 @@ mod encoding_tests { assert_eq!(m.text_of(0x61).as_deref(), Some("a")); assert_eq!(m.decode(b"abc"), "abc"); } + + #[test] + fn unusable_programs_still_spend_the_decode_budget() { + // 128 resources naming one 1 MiB program that isn't a font: each decode is charged, + // so an enumeration stops decoding once the shared budget is spent. + let mut doc = Document::new_empty(); + let program = doc.add(pdfcraft_cos::Stream::flate(Dict::new(), &vec![0; 1 << 20])); + let descriptor: Dict = [(b"FontFile2".to_vec(), Object::Ref(program))].into_iter().collect(); + let descriptor = doc.add(descriptor); + let font: Dict = [(b"FontDescriptor".to_vec(), Object::Ref(descriptor))].into_iter().collect(); + let mut budget = DecodeBudget::new(64 << 20, 0); + for _ in 0..128 { + assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_none()); + } + assert_eq!(budget.remaining(), 0); + } } diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs new file mode 100644 index 0000000000..c2acef191c --- /dev/null +++ b/crates/fonts/src/pdf/tests.rs @@ -0,0 +1,797 @@ +use pdfcraft_cos::{Dict, Document, Object, Stream}; + +use super::*; + +/// Span records read while `f` runs. +fn span_reads(f: impl FnOnce()) -> usize { + SPAN_READS.with(|c| c.set(0)); + f(); + SPAN_READS.with(|c| c.get()) +} + +/// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`. +fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict { + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"W".to_vec(), Object::Array(w)); + let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes())))); + let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes())))); + font( + doc, + vec![("Subtype", Object::name("Type0")), ("Encoding", enc), ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), ("ToUnicode", tu)], + ) +} + +/// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all. +#[test] +fn widths_of_a_code_no_range_covers_take_few_steps() { + let mut doc = Document::new_empty(); + let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect(); + let f = composite(&mut doc, "", w, ""); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.width(4), 0.5); + let reads = span_reads(|| { + for _ in 0..1000 { + assert_eq!(m.width(3), 1.0); + } + }); + assert!(reads <= 1000 * 64, "{reads} span reads"); +} + +/// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it. +#[test] +fn overlapping_spans_resolve_to_the_first_declared() { + let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]); + let found = |code| spans.find(code).map(|s| s.2); + let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))]; + for (code, value) in want { + assert_eq!(found(code), value, "code {code}"); + } +} + +/// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map. +#[test] +fn many_unicode_entries_look_up_their_cids_in_few_steps() { + let n = 2000; + let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect(); + let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar"); + let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect(); + let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar"); + let mut doc = Document::new_empty(); + let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode); + let reads = span_reads(|| { + Metrics::from_dict(&doc, &f); + }); + assert!(reads <= n * 64, "{reads} span reads"); +} + +/// Every glyph of a sequential range builds its text from the destination, so a long one is cut +/// to the CMap limit once, when it is read. +#[test] +fn unicode_destinations_are_cut_to_the_cmap_limit() { + let long = "0041".repeat(4096); + let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode(cmap.as_bytes(), &mut u); + for code in [0x10, 0x20, 0x30, 0x40] { + let text = u.text(code).unwrap_or_default(); + assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len()); + } +} + +/// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one. +#[test] +fn low_code_tables_end_with_the_last_covered_code() { + // One range over codes 0x20..=0x21, then nothing. + let cover = [(0x20u64, Some(0usize)), (0x22, None)]; + let owner = super::owners_below_64k(&cover); + assert_eq!(owner.len(), 0x22); + assert_eq!((owner[0x1F], owner[0x20], owner[0x21]), (u32::MAX, 0, 0)); + assert!(super::owners_below_64k(&[(0x2_0000, Some(0))]).is_empty(), "no code below 65 536"); + let bits = super::bit_set_below_64k([3u32, 130, 0x2_0000].into_iter()); + assert_eq!(bits.len(), 3); + assert_eq!((bits[0], bits[2]), (1 << 3, 1 << 2)); + assert!(super::bit_set_below_64k([0x1_0000u32].into_iter()).is_empty()); +} + +fn font(doc: &mut Document, entries: Vec<(&str, Object)>) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Font")); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v); + } + let _ = doc; + d +} + +#[test] +fn embedded_editor_fonts_are_validated_without_installing_or_exporting() { + use pdfcraft_cos::Stream; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor.clone()))]); + assert_eq!(embedded_sfnt(&doc, &simple).unwrap(), bytes); + let composite = font(&mut doc, vec![("DescendantFonts", Object::Array(vec![Object::Dict(simple.clone())]))]); + assert_eq!(embedded_sfnt(&doc, &composite).unwrap(), bytes); + let broken = doc.add(Object::Stream(Stream::flate(Dict::new(), b"broken font"))); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(broken)); + let broken = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + assert!(embedded_sfnt(&doc, &broken).is_none()); + assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); +} + +/// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name +/// ("Mono" would mean Courier, 600 units for every glyph). +#[test] +fn a_font_without_widths_measures_with_its_embedded_program() { + use pdfcraft_cos::Stream; + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let upem = f64::from(face.metrics(size, location).units_per_em); + let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("HouseMono")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for c in ['i', 'l', 'W', ' '] { + assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); + } + assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); +} + +#[test] +fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(original).unwrap(); + let expected = face.charmap().map('Ω').unwrap(); + let mut subset = original.to_vec(); + let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; + let entry = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"cmap").unwrap(); + let mut cmap = vec![0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 12]; + cmap.extend_from_slice(&[0, 6, 0, 12, 0, 0, 0, 65, 0, 1]); + cmap.extend_from_slice(&u16::try_from(expected.to_u32()).unwrap().to_be_bytes()); + let offset = subset.len() as u32; + subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); + subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); + subset.extend_from_slice(&cmap); + let face = skrifa::FontRef::new(&subset).unwrap(); + assert!(!face.charmap().has_map(), "egui cannot directly use Mac format 6"); + assert_eq!(face.cmap().unwrap().map_codepoint(65_u32), Some(expected)); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let unicode = doc.add(Object::Stream(Stream::flate(Dict::new(), b"1 beginbfchar <41> <03A9> endbfchar"))); + let source = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor)), ("ToUnicode", Object::Ref(unicode))]); + let editor = embedded_sfnt(&doc, &source).unwrap(); + let face = skrifa::FontRef::new(&editor).unwrap(); + assert_eq!(face.charmap().map('Ω'), Some(expected), "Unicode selects the original glyph"); + let Object::Stream(source) = doc.resolve(&Object::Ref(program)).as_ref().clone() else { + panic!("source stream"); + }; + assert_eq!(source.decoded().unwrap(), subset, "only an in-memory editor copy is adapted"); + let mut broken = vec![0; 16]; + assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); +} + +fn metrics_with_flags(flags: Option) -> Metrics { + let mut doc = Document::new_empty(); + let mut descriptor = Dict::new(); + if let Some(flags) = flags { + descriptor.set(b"Flags".to_vec(), flags); + } + let font = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + Metrics::from_dict(&doc, &font) +} + +#[test] +fn font_descriptor_flags_stay_within_the_u32_domain() { + let valid = metrics_with_flags(Some(Object::Int(64 | 262_144))); + assert!(valid.italic && valid.bold); + + for flags in [Some(Object::Int(-1)), Some(Object::Int(4_294_967_296)), Some(Object::Bool(true)), None] { + let metrics = metrics_with_flags(flags); + assert!(!metrics.italic && !metrics.bold); + } +} + +#[test] +fn type3_descriptor_names_the_font_without_changing_its_metrics() { + let mut doc = Document::new_empty(); + let mut f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type3")), + ( + "FontMatrix", + Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), + ), + ("FontBBox", Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])), + ], + ); + let before = Metrics::from_dict(&doc, &f); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name("ExampleMincho-Regular")); + descriptor.set(b"Flags".to_vec(), Object::Int(6)); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + let reference = doc.add(Object::Dict(descriptor)); + f.set(b"FontDescriptor".to_vec(), Object::Ref(reference)); + let after = Metrics::from_dict(&doc, &f); + assert_eq!(after.base_font, "ExampleMincho-Regular"); + assert_eq!((after.ascent, after.descent, after.scale), (before.ascent, before.descent, before.scale)); + assert!(!after.bold && !after.italic && !after.subset); + f.set(b"BaseFont".to_vec(), Object::name("ExplicitName")); + assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence"); +} + +#[test] +fn simple_fonts_decode_and_encode_through_their_encoding() { + let mut doc = Document::new_empty(); + let f = font( + &mut doc, + vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::name("WinAnsiEncoding"))], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(b"Caf\xe9 \x80 \x93x\x94"), "Café € “x”"); + assert_eq!(m.encode("Café €").as_deref(), Some(&b"Caf\xe9 \x80"[..])); + assert_eq!(m.encode("Ω"), None, "not in WinAnsi"); + // Differences rename codes by glyph name. + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(65), Object::name("eacute"), Object::name("uni03A9")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Custom")), ("Encoding", Object::Dict(enc))]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(b"AB"), "éΩ"); + assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..])); +} + +/// A standard-14 font with no `/Widths` is measured by the glyph each code draws, not by the +/// glyph WinAnsi gives that code (#2894); `/Widths`, when present, still go by code. +#[test] +fn standard_fonts_without_widths_measure_the_glyph_their_differences_name() { + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding")); + let diffs = vec![Object::Int(65), Object::name("W"), Object::Int(87), Object::name("A"), Object::name("uni03A9"), Object::name("not.a.glyph")]; + enc.set(b"Differences".to_vec(), Object::Array(diffs)); + let f = + font(&mut doc, vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::Dict(enc.clone()))]); + let m = Metrics::from_dict(&doc, &f); + assert_eq!((m.width(65), m.width(87)), (0.944, 0.667), "A and W swap their widths"); + // Omega has no WinAnsi code and the last name reads as nothing: both keep their code's width. + assert_eq!((m.width(88), m.width(89)), (0.667, 0.667)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type1")), + ("BaseFont", Object::name("Helvetica")), + ("FirstChar", Object::Int(87)), + ("Widths", Object::Array(vec![Object::Int(500)])), + ("Encoding", Object::Dict(enc)), + ], + ); + assert_eq!(Metrics::from_dict(&doc, &f).width(87), 0.5, "/Widths go by code"); +} + +#[test] +fn subset_fonts_only_encode_the_glyphs_they_have() { + let mut doc = Document::new_empty(); + // Widths for a, b only (97, 98); c (99) is 0. + let widths = Object::Array(vec![Object::Int(500), Object::Int(520), Object::Int(0)]); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("ABCDEF+Arial")), + ("FirstChar", Object::Int(97)), + ("Widths", widths), + ("Encoding", Object::name("WinAnsiEncoding")), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert!(m.subset); + assert_eq!(m.encode("ab").as_deref(), Some(&b"ab"[..])); + assert_eq!(m.encode("abc"), None); +} + +#[test] +fn cmap_streams_decode_within_a_size_cap() { + // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole. + let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange"; + let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000> 0 endcidrange"; + for padded in [false, true] { + let mut doc = Document::new_empty(); + let mut stream = |cmap: &[u8]| { + let mut data = cmap.to_vec(); + if padded { + data.resize(MAX_CMAP_BYTES + 1, b' '); + } + Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data)))) + }; + let (tu, enc) = (stream(to_unicode), stream(encoding)); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", enc), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", tu), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}"); + assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}"); + } +} + +#[test] +fn composite_fonts_use_to_unicode() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 2 beginbfchar <0003> <0020> <0010> <00E9> endbfchar 1 beginbfrange <0024> <0026> <0041> endbfrange \ + 1 beginbfrange <0030> <0031> [<0048> <0069>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set( + b"W".to_vec(), + Object::Array(vec![ + Object::Int(3), + Object::Array(vec![Object::Int(250)]), + Object::Int(16), + Object::Int(16), + Object::Int(500), + Object::Int(36), + Object::Int(49), + Object::Int(600), + ]), + ); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("QWERTY+Noto")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, 0x24, 0, 0x25, 0, 3, 0, 0x30, 0, 0x31, 0, 0x10]), "AB Hié"); + assert_eq!(m.encode("Hi A").as_deref(), Some(&[0, 0x30, 0, 0x31, 0, 3, 0, 0x24][..])); + assert_eq!(m.encode("Z"), None); +} + +/// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a +/// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). +#[test] +fn composite_fonts_never_encode_with_cid_0() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ + 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); + assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á"); +} + +/// WSCAD writes `/ToUnicode /Identity-H` (a name, not a CMap stream) on composite fonts whose +/// codes are UTF-16 code units: the codes read as the same Unicode, as in Acrobat. Controls +/// and lone surrogates map to nothing, and a simple font's name is not a ToUnicode map. +#[test] +fn identity_to_unicode_names_read_codes_as_unicode() { + let doc = Document::new_empty(); + for name in ["Identity-H", "Identity-V"] { + let f = font( + &mut Document::new_empty(), + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("RobotoMono-Regular")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(Dict::new())])), + ("ToUnicode", Object::name(name)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.decode(&[0, b'Z', 0, 0xFC, 0, b'r', 0x20, 0xAC, 0x4E, 0x2D]), "Zür€中", "{name}"); + assert_eq!(m.text_of(0x0001), None); + assert_eq!(m.text_of(0x007F), None, "DEL is a control"); + assert_eq!(m.text_of(0x0085), None, "a C1 control"); + assert_eq!(m.text_of(0x00A0).as_deref(), Some("\u{A0}"), "the range resumes after C1"); + assert_eq!(m.text_of(0x007E).as_deref(), Some("~")); + assert_eq!(m.text_of(0xD800), None); + assert_eq!(m.text_of(0xFFFD).as_deref(), Some("\u{FFFD}")); + } + let simple = font( + &mut Document::new_empty(), + vec![("Subtype", Object::name("TrueType")), ("BaseFont", Object::name("Arial")), ("ToUnicode", Object::name("Identity-H"))], + ); + let m = Metrics::from_dict(&doc, &simple); + assert_eq!(m.text_of(0x41).as_deref(), Some("A"), "the encoding still applies"); +} + +/// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is +/// still in the font, but nothing is when the font gives no default width to point at. +#[test] +fn composite_subset_glyphs_of_the_default_width_are_present() { + let metrics = |dw: bool| { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0055> <0057> <0072> endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + if dw { + desc.set(b"DW".to_vec(), Object::Int(500)); + } + desc.set( + b"W".to_vec(), + Object::Array(vec![Object::Int(0x55), Object::Array(vec![Object::Int(333)]), Object::Int(0x57), Object::Array(vec![Object::Int(277)])]), + ); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("AAAAAA+LiberationSans")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + Metrics::from_dict(&doc, &f) + }; + let chrome = metrics(true); + assert_eq!(chrome.encode("rst").as_deref(), Some(&[0, 0x55, 0, 0x56, 0, 0x57][..])); + assert_eq!(chrome.width(0x56), 0.5); + assert_eq!(chrome.encode("x"), None, "a character the ToUnicode map doesn't give"); + assert_eq!(metrics(false).encode("s"), None); +} + +/// A `/W` run that starts at the last CID stops there instead of overflowing onto CID 0. +#[test] +fn composite_width_run_past_the_last_cid_is_dropped() { + let widths = |first: i64| { + let mut doc = Document::new_empty(); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + desc.set(b"DW".to_vec(), Object::Int(1000)); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(first), Object::Array(vec![Object::Int(100), Object::Int(200)])])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ], + ); + Metrics::from_dict(&doc, &f) + }; + let ordinary = widths(0); + assert_eq!((ordinary.width(0), ordinary.width(1)), (0.1, 0.2)); + let last = widths(i64::from(u32::MAX)); + assert_eq!((last.width(0), last.width(u32::MAX)), (1.0, 0.1)); +} + +#[test] +fn glyph_names() { + assert_eq!(glyph_unicode("quotedblleft"), Some('“')); + assert_eq!(glyph_unicode("uni20AC"), Some('€')); + assert_eq!(glyph_unicode("u1F600"), Some('😀')); + assert_eq!(glyph_unicode("nonsense"), None); +} + +fn to_unicode(cmap: &[u8]) -> HashMap { + let mut u = Unicode::default(); + parse_to_unicode(cmap, &mut u); + let codes: Vec = u.explicit.keys().copied().chain(u.ranges.iter().flat_map(|r| r.lo..=r.hi)).collect(); + codes.into_iter().filter_map(|c| u.text(c).map(|t| (c, t.into_owned()))).collect() +} + +#[test] +fn cmap_tokens_need_no_space_between_them() { + // Hex strings, brackets and operators may touch; comments and literal strings are skipped. + let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ + 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ + 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; + let map = to_unicode(cmap); + let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); + got.sort_unstable(); + assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); + let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); + assert_eq!(spaces, [(1, vec![0], vec![0xff])]); + assert_eq!(cids, [(0x20, 0x7e, 1)]); +} + +#[test] +fn malformed_cmaps_never_panic() { + // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. + for cmap in [ + "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), + "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), + b"1 beginbfchar <0001> <004", + b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", + b"1 beginbfrange <0001> [<0041>] endbfrange", + b"1 beginbfrange <0001> <0002> [<0041> (", + b"1 beginbfrange <0001>", + b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", + ] { + to_unicode(cmap); + parse_cmap(cmap); + } + // Code counters at the top of the range stop instead of overflowing. + let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); + assert_eq!(map.len(), 2); + assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); + // A malformed array entry keeps its place, so the next one maps the next code. + let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); + assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); + let mut m = Metrics::fallback(); + m.cid_map = Spans::new(parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange").1); + assert_eq!(m.cid(5), u32::MAX); + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); + assert_eq!(unicode_map(&doc, &f, false).text(u32::MAX).as_deref(), Some("B")); +} + +#[test] +fn sequential_ranges_past_the_entry_cap_still_map() { + // 17 ranges of 65 536 codes: 1 114 112 codes from 0.4 KB of CMap, more than MAX_CMAP_ENTRIES + // (the first 16 ranges use up the whole cap). Each range maps its codes upward from 'A', so + // the last code of the last range, 0x100019, is 'A' + 0x19 = 'Z'. + let mut cmap = b"17 beginbfrange ".to_vec(); + for k in 0..17u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Wide")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.text_of(0x0010_0019).as_deref(), Some("Z")); +} + +#[test] +fn a_cmap_adds_at_most_max_entries() { + // 200 ranges of 65 536 codes would be 13 million codes from 4 KB of stream: 200 records. + let mut cmap = b"200 beginbfrange ".to_vec(); + for k in 0..200u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + let mut u = Unicode::default(); + parse_to_unicode(&cmap, &mut u); + assert_eq!((u.ranges.len(), u.explicit.len()), (200, 0)); + // Explicit entries are what the budget counts: 250 of the 300 here are kept. + let mut many = b"300 beginbfchar ".to_vec(); + for k in 0..300u32 { + many.extend(format!("<{k:04X}> <{:04X}> ", 0x4E00 + k).bytes()); + } + many.extend(b"endbfchar"); + let mut u = Unicode::default(); + parse_to_unicode_within(&many, &mut u, 250); + assert_eq!((u.explicit.len(), u.text(249).is_some(), u.text(250).is_some()), (250, true, false)); + let mut many = b"begincidrange ".to_vec(); + for k in 0..MAX_CMAP_RANGES + 10 { + many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); + } + assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); +} + +#[test] +fn a_range_is_one_record_however_many_codes() { + let mut u = Unicode::default(); + parse_to_unicode(b"1 beginbfrange <0000> <0041> endbfrange", &mut u); + assert_eq!((u.ranges.len(), u.explicit.len()), (1, 0)); + assert_eq!(u.text(0xFFFF).as_deref(), Some("@"), "0x41 + 0xFFFF wraps to U+0040"); +} + +#[test] +fn sequential_ranges_count_against_the_budget() { + // Range k maps codes k and k + 1 and costs two, so a budget of 250 keeps ranges 0 to 124. + let mut cmap = Vec::new(); + for k in 0..300u32 { + cmap.extend(format!("1 beginbfrange <{k:08X}> <{:08X}> <0041> endbfrange ", k + 1).bytes()); + } + let mut u = Unicode::default(); + parse_to_unicode_within(&cmap, &mut u, 250); + assert_eq!((u.ranges.len(), u.text(125).is_some(), u.text(126).is_none()), (125, true, true)); +} + +#[test] +fn explicit_and_range_mappings_override_the_encoding() { + let mut doc = Document::new_empty(); + let cmap = b"1 beginbfchar <41> <0058> endbfchar 1 beginbfrange <42> <43> <0059> endbfrange"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type1")), + ("BaseFont", Object::name("Custom")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + let shown: Vec> = (0x41..=0x44).map(|c| m.text_of(c).map(|t| t.into_owned())).collect(); + assert_eq!(shown, [Some("X".to_string()), Some("Y".to_string()), Some("Z".to_string()), Some("D".to_string())]); +} + +/// Deterministic input for the differential test below. +struct Gen(u64); + +impl Gen { + fn below(&mut self, n: u32) -> u32 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + ((self.0 >> 33) % u64::from(n)) as u32 + } + + /// A destination: a unit from a pool of edge cases, a surrogate pair, or a lone high surrogate. + fn units(&mut self) -> Vec { + const POOL: [u16; 12] = [0x41, 0x42, 0xE9, 0x0, 0xFFFD, 0xFFFE, 0xFFFF, 0x100, 0x3042, 0xD800, 0xDC00, 0xDFFF]; + match self.below(6) { + 0 => vec![0xD835, 0xDC00 + self.below(0x400) as u16], + 1 => vec![0xD800, 0x41], + _ => vec![POOL[self.below(POOL.len() as u32) as usize]], + } + } +} + +fn hex(units: &[u16]) -> String { + units.iter().map(|u| format!("{u:04X}")).collect() +} + +#[test] +fn lookups_match_the_expanded_mappings() { + // Random ToUnicode CMaps; `expected` is what expanding their entries in order gives. + let mut g = Gen(0x2545_f491_4f6c_dd1d); + for _ in 0..300 { + let mut cmap = String::from("1 begincodespacerange <0000> endcodespacerange "); + let mut expected: HashMap = HashMap::new(); + for _ in 0..1 + g.below(12) { + match g.below(3) { + 0 => { + let (code, u) = (g.below(300), g.units()); + cmap += &format!("1 beginbfchar <{code:04X}> <{}> endbfchar ", hex(&u)); + expected.insert(code, String::from_utf16_lossy(&u)); + } + 1 => { + let (lo, width, u) = (g.below(300), g.below(13), g.units()); + cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> <{}> endbfrange ", lo + width, hex(&u)); + for k in 0..=width { + let mut v = u.clone(); + if let Some(last) = v.last_mut() { + *last = last.wrapping_add(k as u16); + } + expected.insert(lo + k, String::from_utf16_lossy(&v)); + } + } + _ => { + let (lo, width) = (g.below(300), g.below(5)); + let dests: Vec> = (0..=width).map(|_| g.units()).collect(); + let list: Vec = dests.iter().map(|d| format!("<{}>", hex(d))).collect(); + cmap += &format!("1 beginbfrange <{lo:04X}> <{:04X}> [{}] endbfrange ", lo + width, list.join(" ")); + for (k, d) in dests.iter().enumerate() { + expected.insert(lo + k as u32, String::from_utf16_lossy(d)); + } + } + } + } + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.clone().into_bytes()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Gen")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for code in 0..=320u32 { + let got = m.text_of(code).map(|t| t.into_owned()); + assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); + } + let mut reverse: HashMap = HashMap::new(); + // Code 0 is CID 0, `.notdef`, which no character is encoded with. + for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { + if let Some(c) = only_char(text) { + keep(&mut reverse, c, code); + } + } + let mut chars: Vec = expected.values().flat_map(|t| t.chars()).chain(['A', '\u{FFFD}', '\u{0}', '\u{1D400}']).collect(); + chars.sort_unstable(); + chars.dedup(); + for c in chars { + let want = reverse.get(&c).map(|code| code.to_be_bytes()[2..].to_vec()); + assert_eq!(m.encode(&c.to_string()), want, "char {c:?} in {cmap}"); + } + } +} + +#[test] +fn fffd_lookups_skip_overridden_surrogates() { + // Each record maps every code to itself, so its surrogate offsets decode to U+FFFD. The last + // record overrides the surrogates with letters, so each lookup walks past them to U+FFFD itself. + let mut cmap = "1 beginbfrange <0000> <0000> endbfrange ".repeat(300); + cmap.push_str("1 beginbfrange <0041> endbfrange"); + let mut doc = Document::new_empty(); + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.into_bytes()))); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("Gen")), + ("Encoding", Object::name("Identity-H")), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("\u{FFFD}"), Some(vec![0xFF, 0xFD])); +} + +#[test] +fn hostile_ranges_stay_bounded() { + // A range wider than 65 536 codes is ignored, a reversed one maps nothing, and a maximal + // one-code range maps just its own code. + let mut u = Unicode::default(); + let cmap = b"1 beginbfrange <00000000> <0041> endbfrange \ + 1 beginbfrange <0005> <0001> <0041> endbfrange \ + 1 beginbfrange endbfrange"; + parse_to_unicode(cmap, &mut u); + assert_eq!((u.ranges.len(), u.text(u32::MAX).as_deref()), (0, Some("\u{FFFF}"))); +} + +#[test] +fn low_codes_resolve_to_the_covering_range() { + let mut u = Unicode::default(); + let cmap = b"1 beginbfrange <0005> <0007> <0041> endbfrange 1 beginbfrange <0064> <0065> <0042> endbfrange \ + 1 beginbfrange <0000FFFE> <00010001> <0043> endbfrange"; + parse_to_unicode(cmap, &mut u); + let covered: Vec = (0..0x70).filter(|&c| u.covering(c).is_some()).collect(); + assert_eq!(covered, [5, 6, 7, 100, 101]); + assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true)); +} diff --git a/crates/fonts/src/std14.rs b/crates/fonts/src/std14.rs index 0556b112c3..13bb5b7711 100644 --- a/crates/fonts/src/std14.rs +++ b/crates/fonts/src/std14.rs @@ -59,7 +59,7 @@ impl Std14 { /// report that called it a standard-14 name would be telling the reader something untrue. pub fn recognise(base: &str) -> Option<(Self, Match)> { let base = base.rsplit('+').next().unwrap_or(base); - let flat: String = base.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect(); + let flat: String = base.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect(); let has = |w: &str| flat.contains(w); let (bold, italic) = (has("bold"), has("italic") || has("oblique")); // Two bools index a four-element array, so the subscript is provably 0..=3. @@ -81,7 +81,7 @@ impl Std14 { if has("times") { return Some((times(), Match::Alias)); } - if has("serif") || has("roman") { + if (has("serif") && !has("sans")) || has("roman") { return Some((times(), Match::FamilyHint)); } if has("helvetica") || has("arial") { @@ -125,6 +125,12 @@ impl Std14 { mod tests { use super::*; + #[test] + fn sans_serif_names_are_not_mapped_to_times() { + assert!(!matches!(Std14::recognise("SansSerif"), Some((Std14::TimesRoman, _)))); + assert_eq!(Std14::recognise("Arial-SansSerif"), Some((Std14::Helvetica, Match::Alias))); + } + /// Widths from the published Core14 metrics, typed in here by hand so the table is checked /// against the specification rather than against whatever produced it. #[test] diff --git a/crates/fonts/tests/build_input.rs b/crates/fonts/tests/build_input.rs new file mode 100644 index 0000000000..832cc2c16c --- /dev/null +++ b/crates/fonts/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Required manifest checks execute the same parser as the production build script. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/forms/Cargo.toml b/crates/forms/Cargo.toml index 3f085ee6cc..fcecbb5f87 100644 --- a/crates/forms/Cargo.toml +++ b/crates/forms/Cargo.toml @@ -17,3 +17,4 @@ thiserror = { workspace = true } [dev-dependencies] hayro-syntax = { workspace = true } +proptest = { workspace = true } diff --git a/crates/forms/README.md b/crates/forms/README.md index 66fb919fe7..ba9dc18f48 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -14,6 +14,8 @@ set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?; set_value(&mut doc, "agree", &FieldValue::Check(true))?; set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?; set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?; +// Many values: as set_value one by one, but reading the form once and recalculating once. +let results: Vec> = set_values(&mut doc, &[("name".into(), FieldValue::Text("Ada".into()))])?; reset(&mut doc, None)?; // Clear form (back to /DV) // Prepare a form @@ -25,14 +27,23 @@ set_props(&mut doc, "email", &FieldProps { ..Default::default() })?; delete_field(&mut doc, "email")?; + +// A table of text fields (view space, y down), laid out and named before any of it is added: +// refused whole for a cell under MIN_FIELD_SIDE or a name with a period, repeated or taken. +let names = all.iter().map(|f| f.name.as_str()); +let base = table::free_base(names.clone()); // "Table1", or the next one free +let mut grid = table::FieldTable::new([40.0, 240.0, 240.0, 360.0], &base); +grid.set_columns(2, &base); +let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table1_c2_1", … ``` ## Behaviour - Inheritable attributes (`/FT`, `/Ff`, `/V`, `/DV`, `/DA`, `/Q`, `/MaxLen`) are resolved down the field tree; the form's `/DA` and `/Q` are the last fallback. -- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb, - multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection. +- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb + (cells parted in the border colour), multiline wrapping, password masking, quadding, `/MK` + background and border, list-box selection. The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold (Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat @@ -42,8 +53,20 @@ delete_field(&mut doc, "email")?; that have them. Not in a document that declares PDF/A, which forbids that flag (`declares_pdfa`). `undrawn_fields` lists the marked fields; the engine reports each edit that draws one, and flattening refuses to make that `?` permanent. +- Displaying a form that leaves its appearances to the viewer (`/NeedAppearances`, or a text or + choice widget with no `/AP /N`): `with_field_appearances` returns a copy with those widgets + drawn from their values, as typing them would draw them; the engine shows and prints that copy + and saves the file as it was (#3470). `with_field_appearances_cached` keeps each widget's + drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in + one field redraws that field, not the form. Check boxes and radio buttons with no appearance for + their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats + symbol in the `/DA` colour), and with `/NeedAppearances` so is every one that has an `/MK`, + over the appearance the file carries; push buttons with none are drawn from `/MK`, `/BS` and + their caption. - Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed). +- Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the + original entry is preserved. Positive limits still apply. - Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a failed call changes nothing. - `FieldProps::appearance` applies a `LookPatch` independently to each widget. `None` keeps @@ -56,6 +79,26 @@ delete_field(&mut doc, "email")?; Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio buttons joining a group become kid widgets with their own export state. Renaming changes only the last part of a dotted name. +- Renaming a field renames it in every Calculate script that reads it + (`af::rename_in_calculation`): `AFSimple_Calculate` field lists, simplified field notation + (the `BVCALC` comment and its translation), and `getField` calls whose argument is the name as + one string literal. The rest of each script is kept as written. Names built at run time, a + script stream that isn't UTF-8, other events' scripts and a name another field still has are + left alone. `calculations_changed` compares two readings of a form to say which formulas an + edit rewrote. +- The calculation order (`/CO`, §12.7.3): `calculation_order` lists the calculated fields in the + order `recalculate` runs them, each with its field object (two can share a name in a malformed + form), with the fields each formula reads (`Calculate::reads`, + `af::calculation_reads`; names built at run time aren't known) and what is wrong with the + order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO` + entries that calculate nothing. Two issues are about the formula itself: it reads a field the + form doesn't have (`MissingField`; the field of that name and a field under it count as there, + as recalculation and `getField` find them, but not an Acrobat widget name such as `price.0`, + which neither resolves), or its script builds names at run time (`BuiltNames`, + `af::reads_built_names`), so its reads are incomplete. `CalculationOrder::fixed` is Fix order: a stable topological + sort that keeps the current order wherever it can and leaves loops as they are. + `set_calculation_order` writes `/CO` afresh. A field that gets a calculation joins the end of + the order; one whose calculation changes keeps its place. - `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values). `FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears that label, independently of the field name. The index is 0-based. A label-only edit redraws @@ -70,3 +113,7 @@ Rich-text values and exact appearance metrics/layout remain incomplete. JavaScri execution and FDF/XFDF exchange are integrated through the engine; this crate provides field actions and native AF rules. `/MK /R` rotation (0, 90, 180, 270) is drawn into the appearance. + +## Button export values + +`FieldProps::export_value` edits a zero-based widget's `/Opt` value while preserving appearance names, `/AP`, `/AS`, `/V` and `/DV`. Widgets sharing an on-state remain consistent. Export-only changes do not regenerate appearances. Invalid indices, empty/Off values and ambiguous/conflicting mappings are rejected. `Field::export_values` resolves selected states for data export. diff --git a/crates/forms/proptest-regressions/af/tests.txt b/crates/forms/proptest-regressions/af/tests.txt new file mode 100644 index 0000000000..70c4d0b525 --- /dev/null +++ b/crates/forms/proptest-regressions/af/tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 81dc3893ad0b46189d4e80199f44f8e58fcf0d193f67e266e37f3846f756a11e # shrinks to names = [" "] diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 8128b6a719..5ebe3269cc 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -105,11 +105,11 @@ fn texts(doc: &Document, o: &Object) -> Vec { } } -fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef]) -> Option { +fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option { let a = doc.resolve(o); let d = a.as_dict()?; Some(match d.name(b"S")? { - b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()), + b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()), b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()), b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()), b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()), @@ -145,19 +145,74 @@ pub fn field_actions(doc: &Document, name: &str) -> Result wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; // An action chain (/Next) counts as its first action here. - if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) { + if let Some(a) = o.and_then(|o| read(doc, &o, &pages, &mut scripts)) { out.push((t, a)); } } Ok(out) } +/// Most chained actions [`field_action_chains`] walks per trigger. +const MAX_CHAIN: usize = 1000; + +/// How many actions follow `action` through `/Next` (a dictionary or an array of them, each with +/// its own `/Next`), counting each object once. +fn chained(doc: &Document, action: &Object) -> usize { + let mut seen = std::collections::HashSet::new(); + if let Object::Ref(r) = action { + seen.insert(*r); + } + let mut stack: Vec = doc.resolve(action).as_dict().and_then(|d| d.get(b"Next").cloned()).into_iter().collect(); + let mut count = 0; + while let Some(o) = stack.pop() { + if count >= MAX_CHAIN { + break; + } + if let Object::Ref(r) = &o + && !seen.insert(*r) + { + continue; + } + match &*doc.resolve(&o) { + Object::Array(items) => stack.extend(items.iter().cloned()), + Object::Dict(d) => { + count += 1; + stack.extend(d.get(b"Next").cloned()); + } + _ => {} + } + } + count +} + +/// For each trigger of field `name` (its first widget) whose action has others chained after it, +/// how many there are. +pub fn field_action_chains(doc: &Document, name: &str) -> Result, FormError> { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let Some(w) = f.widgets.first() else { return Ok(Vec::new()) }; + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + Ok(Trigger::ALL + .into_iter() + .filter_map(|t| { + let o = match t.key() { + None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), + Some(k) => aa.get(k).cloned(), + }?; + let n = chained(doc, &o); + (n > 0).then_some((t, n)) + }) + .collect()) +} + fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> Result, FormError> { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Action")); @@ -205,12 +260,59 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> Ok(Some(Object::Ref(doc.add(Object::Dict(d))))) } +/// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from +/// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place. +struct TriggerUpdate<'a> { + trigger: Trigger, + replacement: Option<&'a FieldAction>, + previous: Option, +} + +fn replace_trigger( + doc: &mut Document, + pages: &[pdfcraft_cos::ObjRef], + scripts: &mut pdfcraft_cos::DecodeBudget, + update: TriggerUpdate<'_>, + aa: &mut Dict, + a_entry: &mut Option, +) -> Result<(), FormError> { + let TriggerUpdate { trigger: t, replacement: new, previous: old } = update; + let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts)); + if new.is_some() && new == old_action.as_ref() { + return Ok(()); + } + // The list shows a trigger's first action: replacing it keeps the ones chained after it. + let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned())); + let obj = match new { + Some(a) => write(doc, a, pages)?, + None => None, + }; + if let (Some(Object::Ref(r)), Some(next)) = (&obj, next) { + doc.update_dict(*r, |d| d.set(b"Next".to_vec(), next))?; + } + match t.key() { + None => { + *a_entry = obj; + aa.remove(b"U"); + } + Some(k) => match obj { + Some(o) => aa.set(k.to_vec(), o), + None => { + aa.remove(k); + } + }, + } + Ok(()) +} + /// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose -/// theirs. `Other` actions are left as they are. +/// theirs. `Other` actions, and actions that read the same as before, are left as they are (with +/// their chain and the keys this module doesn't read); a replaced action keeps its chain. pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, FieldAction)]) -> Result<(), FormError> { let all = fields(doc); let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); let pages = crate::page_refs(doc); + let mut scripts = crate::script_budget(); for w in &f.widgets { let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); let mut aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); @@ -220,22 +322,11 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi if matches!(new, Some(FieldAction::Other(_))) { continue; } - let obj = match new { - Some(a) => write(doc, a, &pages)?, - None => None, + let old = match t.key() { + None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), + Some(k) => aa.get(k).cloned(), }; - match t.key() { - None => { - a_entry = obj; - aa.remove(b"U"); - } - Some(k) => match obj { - Some(o) => aa.set(k.to_vec(), o), - None => { - aa.remove(k); - } - }, - } + replace_trigger(doc, &pages, &mut scripts, TriggerUpdate { trigger: t, replacement: new, previous: old }, &mut aa, &mut a_entry)?; } doc.update_dict(w.obj, |d| { match &a_entry { diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 129f7fa751..86c7971c31 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -9,7 +9,20 @@ //! writes them back when a form is authored. Other scripts are kept untouched and reported as //! unsupported; they need the JavaScript engine (M6.4). +use std::collections::VecDeque; use std::fmt::Write as _; +use std::ops::Range; + +mod calc; +mod format; +mod script; + +#[cfg(test)] +use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; +pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation}; +pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; +use script::{Arg, call}; +pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js}; /// The Format tab. #[derive(Clone, Debug, PartialEq, Default)] @@ -110,1039 +123,8 @@ impl Actions { } } -// ── parsing scripts ───────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Arg { - Num(f64), - Str(String), - Bool(bool), - List(Vec), -} - -impl Arg { - fn num(&self) -> Option { - match self { - Arg::Num(n) => Some(*n), - Arg::Bool(b) => Some(f64::from(u8::from(*b))), - Arg::Str(s) => s.trim().parse().ok(), - Arg::List(_) => None, - } - } - - fn string(&self) -> Option { - match self { - Arg::Str(s) => Some(s.clone()), - Arg::Num(n) => Some(n.to_string()), - _ => None, - } - } - - fn truthy(&self) -> bool { - match self { - Arg::Bool(b) => *b, - Arg::Num(n) => *n != 0.0, - Arg::Str(s) => !s.is_empty(), - Arg::List(l) => !l.is_empty(), - } - } -} - -/// How deeply script arguments and field notation may nest; deeper input is rejected rather -/// than recursed into (a hostile file could otherwise overflow the stack). -const MAX_NESTING: usize = 64; - -fn hex4(bytes: &[u8]) -> Option { - if bytes.len() != 4 { - return None; - } - bytes.iter().try_fold(0u16, |value, byte| { - let digit = match byte { - b'0'..=b'9' => byte - b'0', - b'a'..=b'f' => byte - b'a' + 10, - b'A'..=b'F' => byte - b'A' + 10, - _ => return None, - }; - value.checked_mul(16)?.checked_add(u16::from(digit)) - }) -} - -struct Parser<'a> { - s: &'a [u8], - i: usize, - depth: usize, -} - -impl Parser<'_> { - fn ws(&mut self) { - while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { - self.i += 1; - } - } - - fn string(&mut self) -> Option { - let q = self.s[self.i]; - self.i += 1; - let mut out = Vec::new(); - while self.i < self.s.len() { - let c = self.s[self.i]; - self.i += 1; - match c { - b'\\' if self.i < self.s.len() => { - let e = self.s[self.i]; - self.i += 1; - match e { - b'n' => out.push(b'\n'), - b'r' => out.push(b'\r'), - b't' => out.push(b'\t'), - b'u' => { - let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; - self.i += 4; - let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { - let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; - if (0xDC00..=0xDFFF).contains(&low) { - self.i += 6; - 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) - } else { - u32::from(code) - } - } else { - u32::from(code) - }; - let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); - let mut encoded = [0u8; 4]; - out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); - } - other => out.push(other), - } - } - c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), - c => out.push(c), - } - } - None - } - - fn arg(&mut self) -> Option { - self.ws(); - let c = *self.s.get(self.i)?; - match c { - b'"' | b'\'' => self.string().map(Arg::Str), - b'[' => { - self.i += 1; - self.nested(b']').map(Arg::List) - } - _ if self.s[self.i..].starts_with(b"new Array") => { - self.i += b"new Array".len(); - self.ws(); - if self.s.get(self.i) != Some(&b'(') { - return None; - } - self.i += 1; - self.nested(b')').map(Arg::List) - } - _ if self.s[self.i..].starts_with(b"true") => { - self.i += 4; - Some(Arg::Bool(true)) - } - _ if self.s[self.i..].starts_with(b"false") => { - self.i += 5; - Some(Arg::Bool(false)) - } - _ => { - let start = self.i; - while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { - self.i += 1; - } - std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) - } - } - } - - /// A list inside another, at most [`MAX_NESTING`] deep. - fn nested(&mut self, close: u8) -> Option> { - if self.depth >= MAX_NESTING { - return None; - } - self.depth += 1; - let out = self.list(close); - self.depth -= 1; - out - } - - fn list(&mut self, close: u8) -> Option> { - let mut out = Vec::new(); - loop { - self.ws(); - if self.s.get(self.i) == Some(&close) { - self.i += 1; - return Some(out); - } - out.push(self.arg()?); - self.ws(); - match self.s.get(self.i) { - Some(b',') => self.i += 1, - Some(c) if *c == close => { - self.i += 1; - return Some(out); - } - _ => return None, - } - } - } -} - -/// The first call to `name(` in `js` and its arguments. -fn call(js: &str, name: &str) -> Option> { - let at = js.find(&format!("{name}("))?; - // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). - if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { - return None; - } - let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; - p.list(b')') -} - -/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets. -pub const DATE_PRESETS: [&str; 14] = [ - "m/d", - "m/d/yy", - "mm/dd/yy", - "mm/yy", - "d-mmm", - "d-mmm-yy", - "dd-mmm-yy", - "yy-mm-dd", - "mmm-yy", - "mmmm-yy", - "mmm d, yyyy", - "mmmm d, yyyy", - "m/d/yy h:MM tt", - "m/d/yy HH:MM", -]; -pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"]; - -pub fn parse_format(js: &str) -> Option { - let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; - if let Some(args) = call(js, "AFNumber_Format") { - return Some(Format::Number { - decimals: a(&args, 0), - sep: a(&args, 1), - neg: a(&args, 2), - currency: args.get(4).and_then(Arg::string).unwrap_or_default(), - prepend: args.get(5).is_none_or(Arg::truthy), - }); - } - if let Some(args) = call(js, "AFPercent_Format") { - return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); - } - if let Some(args) = call(js, "AFDate_FormatEx") { - return Some(Format::Date(args.first().and_then(Arg::string)?)); - } - if let Some(args) = call(js, "AFDate_Format") { - return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); - } - if let Some(args) = call(js, "AFTime_FormatEx") { - return Some(Format::Time(args.first().and_then(Arg::string)?)); - } - if let Some(args) = call(js, "AFTime_Format") { - return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); - } - if let Some(args) = call(js, "AFSpecial_Format") { - return Some(Format::Special(a(&args, 0).min(3))); - } - if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { - return Some(Format::Mask(args.first().and_then(Arg::string)?)); - } - None -} - -pub fn parse_validate(js: &str) -> Option { - let args = call(js, "AFRange_Validate")?; - let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); - let num = |i: usize| args.get(i).and_then(Arg::num); - Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) -} - -pub fn parse_calculate(js: &str) -> Option { - // Simplified field notation: Acrobat keeps the expression in a comment. - if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC")) { - let expr = js.get(a + 6..b)?.trim().to_string(); - return Some(Calculate::Notation(expr)); - } - let args = call(js, "AFSimple_Calculate")?; - let op = match args.first().and_then(Arg::string)?.as_str() { - "SUM" => CalcOp::Sum, - "PRD" => CalcOp::Product, - "AVG" => CalcOp::Average, - "MIN" => CalcOp::Minimum, - "MAX" => CalcOp::Maximum, - _ => return None, - }; - let fields = match args.get(1)? { - Arg::List(l) => l.iter().filter_map(Arg::string).collect(), - Arg::Str(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), - _ => return None, - }; - Some(Calculate::Simple { op, fields }) -} - -// ── writing scripts (what Acrobat's tabs generate) ───────────────────────────────────────── - -fn js_str(s: &str) -> String { - let mut o = String::from("\""); - for c in s.chars() { - match c { - '"' | '\\' => { - o.push('\\'); - o.push(c); - } - '\n' => o.push_str("\\n"), - '\r' => o.push_str("\\r"), - '\t' => o.push_str("\\t"), - c if c.is_ascii() && !c.is_control() => o.push(c), - c => { - let mut units = [0u16; 2]; - for unit in c.encode_utf16(&mut units) { - o.push_str(&format!("\\u{unit:04X}")); - } - } - } - } - o.push('"'); - o -} - -/// The (format, keystroke) scripts for a format. -pub fn format_js(f: &Format) -> Option<(String, String)> { - Some(match f { - Format::None => return None, - Format::Number { decimals, sep, neg, currency, prepend } => { - let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); - (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) - } - Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), - Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), - Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), - Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), - Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), - }) -} - -pub fn validate_js(v: &Validate) -> Option { - match v { - Validate::None => None, - Validate::Range { min, max } => { - Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) - } - } -} - -pub fn calculate_js(c: &Calculate) -> Option { - match c { - Calculate::None => None, - Calculate::Simple { op, fields } => Some(format!( - "AFSimple_Calculate({}, new Array({}));", - js_str(op.code()), - fields.iter().map(|f| js_str(f)).collect::>().join(", ") - )), - // Acrobat's own translation follows the comment, so a viewer that runs the script instead - // of reading the comment computes the same value (a placeholder here showed 0). - Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), - } -} - -// ── running them ──────────────────────────────────────────────────────────────────────────── - -/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a -/// trailing % ignored; a comma decimal separator accepted). -pub fn make_number(s: &str) -> Option { - let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); - if t.is_empty() { - return None; - } - let neg = t.starts_with('(') && t.ends_with(')'); - let t = t.trim_start_matches('(').trim_end_matches(')'); - // "1.234,56" or "1234,56": comma is the decimal separator when it comes last. - let normalized = match (t.rfind(','), t.rfind('.')) { - (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), - (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), - _ => t.replace(',', ""), - }; - let v: f64 = normalized.parse().ok()?; - v.is_finite().then_some(if neg { -v } else { v }) -} - -/// A number as JavaScript prints it (integers without a fraction). -pub fn js_number(v: f64) -> String { - if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } -} - -fn group(int: &str, sep: char) -> String { - let mut out = String::new(); - for (i, c) in int.chars().enumerate() { - if i > 0 && (int.len() - i).is_multiple_of(3) { - out.push(sep); - } - out.push(c); - } - out -} - -/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56", -/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56"). -fn number_text(v: f64, decimals: u8, sep: u8) -> String { - // Round half away from zero, as JavaScript's toFixed does for these values. - let p = 10f64.powi(i32::from(decimals)); - let s = format!("{:.*}", decimals as usize, (v.abs() * p).round() / p); - let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); - let (g, d) = match sep { - 1 => (None, '.'), - 2 => (Some('.'), ','), - 3 => (None, ','), - 4 => (Some('\''), '.'), - _ => (Some(','), '.'), - }; - let mut out = match g { - Some(c) => group(int, c), - None => int.to_string(), - }; - if !frac.is_empty() { - out.push(d); - out.push_str(frac); - } - out -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// A date and time read from text. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct DateTime { - pub y: i32, - pub m: u32, - pub d: u32, - pub hh: u32, - pub mm: u32, - pub ss: u32, -} - -fn days_in(y: i32, m: u32) -> u32 { - match m { - 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - } -} - -fn weekday(y: i32, m: u32, d: u32) -> usize { - // Sakamoto's method, in i64 so no year or day can overflow it. - const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; - let y = i64::from(y) - i64::from(m < 3); - let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); - (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize -} - -/// The order of the year/month/day fields in a format, as letters. A run of literal digits -/// (the fixed year in "dd.mm.26") is `'#'` with its value; the others carry 0. -fn order(fmt: &str) -> Vec<(char, u32)> { - let mut out: Vec<(char, u32)> = Vec::new(); - // Weekday names (ddd, dddd) are shown, not typed: leave them out. - let mut cleaned = fmt.replace("dddd", " "); - cleaned = cleaned.replace("ddd", " "); - let mut prev = ' '; - for c in cleaned.chars() { - if let Some(digit) = c.to_digit(10) { - match out.last_mut() { - Some(('#', n)) if prev.is_ascii_digit() => *n = n.saturating_mul(10).saturating_add(digit), - _ => out.push(('#', digit)), - } - prev = c; - continue; - } - prev = c; - let k = match c { - 'y' => 'y', - 'm' => 'm', - 'd' => 'd', - 'H' | 'h' => 'H', - 'M' => 'M', - 's' => 's', - _ => continue, - }; - if out.last().map(|&(l, _)| l) != Some(k) { - out.push((k, 0)); - } - } - out -} - -/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any -/// separators, month names, two-digit years). `None` when it isn't a valid date. -pub fn parse_date(text: &str, fmt: &str) -> Option { - let t = text.trim(); - if t.is_empty() { - return None; - } - let lower = t.to_lowercase(); - // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". - let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); - let pm = lower.contains("pm") || bare_p; - let am = lower.contains("am"); - // Tokens: numbers and month names. - let mut nums: Vec = Vec::new(); - let mut month_name: Option = None; - let mut cur = String::new(); - let flush = |cur: &mut String, nums: &mut Vec, month: &mut Option| { - if cur.is_empty() { - return; - } - if let Ok(n) = cur.parse::() { - nums.push(n); - } else if cur.len() >= 3 - && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) - { - *month = Some(i as u32 + 1); - } - cur.clear(); - }; - for c in t.chars() { - // Digits and letters form separate tokens ("5Mar" is 5 and Mar). - let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit(); - if c.is_alphanumeric() && same_kind { - cur.push(c); - } else { - flush(&mut cur, &mut nums, &mut month_name); - if c.is_alphanumeric() { - cur.push(c); - } - } - } - flush(&mut cur, &mut nums, &mut month_name); - let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; - let mut it = nums.into_iter().peekable(); - let mut have_y = false; - for (k, literal) in order(fmt) { - match k { - // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" - // for "dd.mm.26"); a different number there is left over and rejected below. - '#' if it.peek() == Some(&literal) => { - it.next(); - } - 'm' => { - dt.m = match month_name { - Some(m) => m, - None => it.next()?, - } - } - 'd' => dt.d = it.next()?, - 'y' => { - let y = i32::try_from(it.next()?).ok()?; - dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y }; - have_y = true; - } - 'H' => dt.hh = it.next().unwrap_or(0), - 'M' => dt.mm = it.next().unwrap_or(0), - 's' => dt.ss = it.next().unwrap_or(0), - _ => {} - } - } - if it.next().is_some() { - return None; - } - if !have_y { - dt.y = 2000; - } - if pm && dt.hh < 12 { - dt.hh += 12; - } - if am && dt.hh == 12 { - dt.hh = 0; - } - let has_date = fmt.contains(['y', 'm', 'd']); - if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) { - return None; - } - if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 { - return None; - } - Some(dt) -} - -/// Format a date/time with Acrobat's date format letters. -pub fn format_date(dt: DateTime, fmt: &str) -> String { - let chars: Vec = fmt.chars().collect(); - let mut out = String::new(); - let mut i = 0; - while i < chars.len() { - let c = chars[i]; - let mut n = 1; - while i + n < chars.len() && chars[i + n] == c { - n += 1; - } - let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 }; - match (c, n) { - ('y', 4..) => { - let _ = write!(out, "{:04}", dt.y); - } - ('y', _) => { - let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); - } - ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { - Some(name) if n >= 4 => out.push_str(name), - Some(name) => out.push_str(name.get(..3).unwrap_or(name)), - None => { - let _ = write!(out, "{}", dt.m); - } - }, - ('m', 2) => { - let _ = write!(out, "{:02}", dt.m); - } - ('m', _) => { - let _ = write!(out, "{}", dt.m); - } - ('d', 3..) => { - let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); - out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); - } - ('d', 2) => { - let _ = write!(out, "{:02}", dt.d); - } - ('d', _) => { - let _ = write!(out, "{}", dt.d); - } - ('H', 2) => { - let _ = write!(out, "{:02}", dt.hh); - } - ('H', _) => { - let _ = write!(out, "{}", dt.hh); - } - ('h', 2) => { - let _ = write!(out, "{h12:02}"); - } - ('h', _) => { - let _ = write!(out, "{h12}"); - } - ('M', 2) => { - let _ = write!(out, "{:02}", dt.mm); - } - ('M', _) => { - let _ = write!(out, "{}", dt.mm); - } - ('s', 2) => { - let _ = write!(out, "{:02}", dt.ss); - } - ('s', _) => { - let _ = write!(out, "{}", dt.ss); - } - ('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }), - ('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }), - (c, n) => out.extend(std::iter::repeat_n(c, n)), - } - i += n; - } - out -} - -const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"]; - -fn mask_fits(mask: &str, text: &str) -> bool { - let t: Vec = text.chars().collect(); - let m: Vec = mask.chars().collect(); - t.len() == m.len() - && m.iter().zip(&t).all(|(mc, tc)| match mc { - '9' => tc.is_ascii_digit(), - 'A' => tc.is_alphabetic(), - 'O' => tc.is_alphanumeric(), - 'X' => true, - other => other == tc, - }) -} - -/// Fill a mask from the typed characters that fit its slots. -fn apply_mask(mask: &str, text: &str) -> Option { - let mut src = text.chars().filter(|c| c.is_alphanumeric()); - let mut out = String::new(); - for mc in mask.chars() { - match mc { - '9' | 'A' | 'O' | 'X' => { - let c = src.next()?; - let ok = match mc { - '9' => c.is_ascii_digit(), - 'A' => c.is_alphabetic(), - _ => true, - }; - if !ok { - return None; - } - out.push(c); - } - other => out.push(other), - } - } - src.next().is_none().then_some(out) -} - -/// The text shown for a value (the Format event). -pub fn format_value(f: &Format, value: &str) -> String { - if value.is_empty() { - return String::new(); - } - match f { - Format::None => value.to_string(), - Format::Number { decimals, sep, neg, currency, prepend } => { - let Some(v) = make_number(value) else { return value.to_string() }; - let body = number_text(v, *decimals, *sep); - let body = match (currency.is_empty(), *prepend) { - (true, _) => body, - (false, true) => format!("{currency}{body}"), - (false, false) => format!("{body}{currency}"), - }; - match (v < 0.0, neg) { - (false, _) => body, - // Styles 2 and 3 use parentheses (3 is also red, which the appearance can't - // know here); 0 and 1 a minus sign. - (true, 2 | 3) => format!("({body})"), - (true, _) => format!("-{body}"), - } - } - Format::Percent { decimals, sep } => match make_number(value) { - Some(v) => format!( - "{}%", - if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) } - ), - None => value.to_string(), - }, - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), - Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), - Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), - } -} - -/// The Keystroke event on commit: does the value fit the format? Returns the value to store -/// (numbers are normalised), or Acrobat's message. -pub fn keystroke(f: &Format, field: &str, value: &str) -> Result { - let bad = || format!("The value entered does not match the format of the field [ {field} ]"); - // Blank input clears a formatted field; without a format, whitespace is text like any other. - if *f != Format::None && value.trim().is_empty() { - return Ok(String::new()); - } - match f { - Format::None => Ok(value.to_string()), - Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad), - Format::Percent { .. } => { - // Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number - // as typed and shows it ×100: keep the typed number. - make_number(value).map(js_number).ok_or_else(bad) - } - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt) - .map(|_| value.trim().to_string()) - .ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")), - Format::Special(n) => { - let m = SPECIAL_MASKS[(*n).min(3) as usize]; - apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad) - } - Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad), - } -} - -/// The Validate event: `Err` with Acrobat's message when the value is out of range. -pub fn validate(v: &Validate, value: &str) -> Result<(), String> { - let Validate::Range { min, max } = v else { return Ok(()) }; - if value.trim().is_empty() { - return Ok(()); - } - let Some(x) = make_number(value) else { return Ok(()) }; - let fmt = |n: f64| js_number(n); - let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m); - if ok { - return Ok(()); - } - Err(match (min, max) { - (Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)), - (Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)), - (None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)), - (None, None) => String::new(), - }) -} - -/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: -/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. -pub fn notation_js(expr: &str) -> String { - let mut out = String::new(); - let cs: Vec = expr.chars().collect(); - let mut i = 0; - while let Some(&c) = cs.get(i) { - if c.is_whitespace() || "+-*/()".contains(c) { - out.push(c); - i += 1; - } else if c.is_ascii_digit() || c == '.' { - while let Some(&d) = cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.') { - out.push(d); - i += 1; - } - } else { - let mut name = String::new(); - while let Some(&n) = cs.get(i).filter(|n| !(n.is_whitespace() || "+-*/()".contains(**n))) { - // `\` escapes a space or an operator inside a name, as Acrobat writes them. - if n == '\\' - && let Some(&next) = cs.get(i + 1) - { - name.push(next); - i += 2; - continue; - } - name.push(n); - i += 1; - } - out.push_str(&format!("AFMakeNumber(this.getField({}).value)", js_str(&name))); - } - } - out -} - -/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names -/// (`\` escapes spaces and operators in names, as Acrobat writes them). -fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { - #[derive(Debug, Clone, PartialEq)] - enum T { - N(f64), - Op(char), - } - let mut toks = Vec::new(); - let cs: Vec = src.chars().collect(); - let mut i = 0; - while i < cs.len() { - let c = cs[i]; - if c.is_whitespace() { - i += 1; - } else if "+-*/()".contains(c) { - toks.push(T::Op(c)); - i += 1; - } else if c.is_ascii_digit() || c == '.' { - let s = i; - while i < cs.len() && (cs[i].is_ascii_digit() || cs[i] == '.') { - i += 1; - } - toks.push(T::N(cs[s..i].iter().collect::().parse().ok()?)); - } else { - let mut name = String::new(); - while i < cs.len() && !(cs[i].is_whitespace() || "+-*/()".contains(cs[i])) { - if cs[i] == '\\' && i + 1 < cs.len() { - i += 1; - } - name.push(cs[i]); - i += 1; - } - toks.push(T::N(value_of(&name))); - } - } - // Recursive descent, `d` levels deep (at most MAX_NESTING). - fn expr(t: &[T], i: &mut usize, d: usize) -> Option { - let mut v = term(t, i, d)?; - while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { - *i += 1; - let r = term(t, i, d)?; - v = if *o == '+' { v + r } else { v - r }; - } - Some(v) - } - fn term(t: &[T], i: &mut usize, d: usize) -> Option { - let mut v = factor(t, i, d)?; - while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { - *i += 1; - let r = factor(t, i, d)?; - v = if *o == '*' { v * r } else { v / r }; - } - Some(v) - } - fn factor(t: &[T], i: &mut usize, d: usize) -> Option { - if d > MAX_NESTING { - return None; - } - match t.get(*i)? { - T::N(n) => { - *i += 1; - Some(*n) - } - T::Op('-') => { - *i += 1; - factor(t, i, d + 1).map(|v| -v) - } - T::Op('(') => { - *i += 1; - let v = expr(t, i, d + 1)?; - (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; - Some(v) - } - _ => None, - } - } - let mut i = 0; - let v = expr(&toks, &mut i, 0)?; - (i == toks.len() && v.is_finite()).then_some(v) -} - -/// The Calculate event: the new value, given the current values of the other fields -/// (`values(name)` returns every field named `name` or below it, for groups). -pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { - let num = |s: &String| make_number(s).unwrap_or(0.0); - match c { - Calculate::None => None, - Calculate::Simple { op, fields } => { - let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); - let v = match op { - CalcOp::Sum => xs.iter().sum(), - CalcOp::Product => xs.iter().product(), - CalcOp::Average => { - if xs.is_empty() { - 0.0 - } else { - xs.iter().sum::() / xs.len() as f64 - } - } - CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), - CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), - }; - Some(if v.is_finite() { js_number(v) } else { "0".into() }) - } - Calculate::Notation(expr) => { - let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; - Some(js_number(v)) - } - } -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn scripts_are_recognised_and_written_back() { - assert_eq!( - parse_format("AFNumber_Format(2, 0, 0, 0, \"$\", true);"), - Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) - ); - assert_eq!(parse_format("AFPercent_Format(1, 0);"), Some(Format::Percent { decimals: 1, sep: 0 })); - assert_eq!(parse_format("AFDate_FormatEx(\"dd-mmm-yyyy\");"), Some(Format::Date("dd-mmm-yyyy".into()))); - assert_eq!(parse_format("AFDate_Format(2);"), Some(Format::Date("mm/dd/yy".into()))); - assert_eq!(parse_format("AFTime_Format(1);"), Some(Format::Time("h:MM tt".into()))); - assert_eq!(parse_format("AFSpecial_Format(2);"), Some(Format::Special(2))); - assert_eq!(parse_format("AFSpecial_KeystrokeEx(\"AA-9999\");"), Some(Format::Mask("AA-9999".into()))); - assert_eq!(parse_format("app.alert('hi')"), None); - assert_eq!(parse_validate("AFRange_Validate(true, 0, true, 100);"), Some(Validate::Range { min: Some(0.0), max: Some(100.0) })); - assert_eq!(parse_validate("AFRange_Validate(false, 0, true, 10);"), Some(Validate::Range { min: None, max: Some(10.0) })); - assert_eq!( - parse_calculate("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b.c\"));"), - Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b.c".into()] }) - ); - assert_eq!(parse_calculate("AFSimple_Calculate('AVG', ['x']);"), Some(Calculate::Simple { op: CalcOp::Average, fields: vec!["x".into()] })); - assert_eq!( - parse_calculate("/** BVCALC Price * Qty EVCALC **/ event.value = AFMakeNumber(getField(\"Price\").value) * …"), - Some(Calculate::Notation("Price * Qty".into())) - ); - // Round trips through what the tabs write. - for f in [ - Format::Number { decimals: 2, sep: 2, neg: 2, currency: "€".into(), prepend: false }, - Format::Percent { decimals: 0, sep: 0 }, - Format::Date("mmmm d, yyyy".into()), - Format::Special(3), - ] { - let (fj, _) = format_js(&f).unwrap(); - assert_eq!(parse_format(&fj), Some(f)); - } - let euro = format_js(&Format::Number { decimals: 2, sep: 2, neg: 0, currency: "€".into(), prepend: true }).unwrap().0; - assert!(euro.contains("\\u20AC"), "{euro}"); - assert!(!euro.contains('€'), "{euro}"); - let pound = format_js(&Format::Number { decimals: 2, sep: 0, neg: 0, currency: "£".into(), prepend: true }).unwrap().0; - assert!(pound.contains("\\u00A3"), "{pound}"); - assert!(!pound.contains('£'), "{pound}"); - let c = Calculate::Simple { op: CalcOp::Product, fields: vec!["q".into(), "p".into()] }; - assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); - let c = Calculate::Notation("a + b".into()); - assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); - // The JavaScript after the comment is Acrobat's translation, so viewers that run it - // instead of reading the comment compute the same value (not 0). - assert_eq!( - calculate_js(&Calculate::Notation("(Unit\\ Price * Qty) / 2".into())).unwrap(), - "/** BVCALC (Unit\\ Price * Qty) / 2 EVCALC **/ event.value = (AFMakeNumber(this.getField(\"Unit Price\").value) * AFMakeNumber(this.getField(\"Qty\").value)) / 2;" - ); - } - - #[test] - fn numbers_format_like_acrobat() { - let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; - assert_eq!(format_value(&n(2, 0, 0, "$", true), "1234.5"), "$1,234.50"); - assert_eq!(format_value(&n(0, 1, 0, "", true), "1234567"), "1234567"); - assert_eq!(format_value(&n(2, 2, 0, " €", false), "1234.5"), "1.234,50 €"); - assert_eq!(format_value(&n(2, 0, 2, "", true), "-12"), "(12.00)"); - assert_eq!(format_value(&n(1, 0, 0, "", true), "-0.25"), "-0.3"); - assert_eq!(format_value(&Format::Percent { decimals: 1, sep: 0 }, "0.125"), "12.5%"); - assert_eq!(make_number("$1,234.50"), Some(1234.5)); - assert_eq!(make_number("1.234,5"), Some(1234.5)); - assert_eq!(make_number("(7)"), Some(-7.0)); - assert_eq!(make_number("abc"), None); - assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "$1,000"), Ok("1000".into())); - assert_eq!( - keystroke(&n(2, 0, 0, "$", true), "Total", "ten"), - Err("The value entered does not match the format of the field [ Total ]".into()) - ); - // Blank input clears a formatted field (#1323); without a format it is text like any other. - assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", " \t"), Ok(String::new())); - assert_eq!(keystroke(&Format::None, "Name", " \t"), Ok(" \t".into())); - } - - #[test] - fn dates_times_and_masks() { - let d = Format::Date("mm/dd/yyyy".into()); - assert_eq!(format_value(&d, "1/2/24"), "01/02/2024"); - assert_eq!(format_value(&Format::Date("mmmm d, yyyy".into()), "10/1/2026"), "October 1, 2026"); - assert_eq!(format_value(&Format::Date("dddd, mmm d, yyyy".into()), "10/1/2026"), "Thursday, Oct 1, 2026"); - assert_eq!(format_value(&Format::Date("dd-mmm-yy".into()), "5-Mar-21"), "05-Mar-21", "month names are read"); - assert!(keystroke(&d, "Due", "2/30/2024").is_err(), "no 30 February"); - assert!(keystroke(&d, "Due", "13/1/2024").is_err()); - // Literal digits in the format (a leave form fixing the year as "dd.mm.26"). - let fixed = Format::Date("dd.mm.26".into()); - assert_eq!(keystroke(&fixed, "Von", "15.05.26"), Ok("15.05.26".into())); - assert_eq!(format_value(&fixed, "15.05.26"), "15.05.26"); - assert_eq!(format_value(&fixed, "3.1"), "03.01.26", "the literal may be left out"); - assert!(keystroke(&fixed, "Von", "15.05.27").is_err(), "a different number there is not the literal"); - assert!(keystroke(&fixed, "Von", "32.05.26").is_err()); - assert_eq!(format_value(&Format::Time("h:MM tt".into()), "14:05"), "2:05 pm"); - assert_eq!(format_value(&Format::Time("HH:MM".into()), "2:05 pm"), "14:05"); - assert_eq!(format_value(&Format::Special(2), "5551234567"), "(555) 123-4567"); - assert_eq!(format_value(&Format::Special(3), "123-45-6789"), "123-45-6789"); - assert!(keystroke(&Format::Special(0), "Zip", "1234").is_err()); - assert_eq!(format_value(&Format::Mask("AA-9999".into()), "ab1234"), "ab-1234"); - assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); - } - - #[test] - fn validation_and_calculation() { - let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; - assert!(validate(&r, "50").is_ok() && validate(&r, "").is_ok()); - assert_eq!(validate(&r, "120"), Err("Invalid value: must be greater than or equal to 0 and less than or equal to 100.".into())); - let vals = |name: &str| -> Vec { - match name { - "a" => vec!["2".into()], - "b" => vec!["$3.50".into()], - "row" => vec!["1".into(), "2".into(), "3".into()], - "Unit Price" => vec!["4".into()], - _ => vec![], - } - }; - let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); - assert_eq!(simple(CalcOp::Sum, &["a", "b"]), Some("5.5".into())); - assert_eq!(simple(CalcOp::Product, &["a", "b"]), Some("7".into())); - assert_eq!(simple(CalcOp::Average, &["row"]), Some("2".into()), "groups count every child"); - assert_eq!(simple(CalcOp::Maximum, &["a", "row"]), Some("3".into())); - assert_eq!(simple(CalcOp::Sum, &["missing"]), Some("0".into())); - assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); - assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); - } -} +mod tests; // ── push buttons ──────────────────────────────────────────────────────────────────────────── @@ -1309,3 +291,104 @@ mod pm_marker_tests { assert_eq!(parse_date("05/09/2026 5:30 pm", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); } } + +#[cfg(test)] +mod undelimited_tests { + use super::*; + + fn ymd(dt: Option) -> Option<(i32, u32, u32)> { + dt.map(|d| (d.y, d.m, d.d)) + } + + /// Fields written side by side in the format ("yyyymmdd") take their fixed widths from one + /// run of typed digits. Every such date used to be refused, because the run was read as a + /// single number. + #[test] + fn digits_typed_without_separators_fill_adjacent_fields() { + assert_eq!(ymd(parse_date("20261018", "yyyymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("261018", "yymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("10182026", "mmddyyyy")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("18102026", "ddmmyyyy")), Some((2026, 10, 18))); + let f = Format::Date("yyyymmdd".into()); + assert_eq!(keystroke(&f, "Date", "20261018"), Ok("20261018".into())); + assert_eq!(format_value(&f, "20261018"), "20261018"); + } + + #[test] + fn separators_are_still_accepted_in_an_undelimited_format() { + assert_eq!(ymd(parse_date("2026-10-18", "yyyymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("2026 1018", "yyyymmdd")), Some((2026, 10, 18))); + } + + /// Separators mean one number per field, as before: a four-digit year typed with them stays + /// whole in a two-digit-year format. Only a run covering several fields is split. + #[test] + fn a_run_is_split_only_when_it_has_to_fill_several_fields() { + assert_eq!(ymd(parse_date("2026-10-18", "yymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("2026-10", "yymm")), Some((2026, 10, 1))); + assert_eq!(ymd(parse_date("2610", "yymm")), Some((2026, 10, 1))); + assert_eq!(ymd(parse_date("202610 18", "yyyymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("18Oct2026", "ddmmmyyyy")), Some((2026, 10, 18))); + } + + #[test] + fn impossible_undelimited_dates_are_refused() { + let f = Format::Date("yyyymmdd".into()); + assert!(keystroke(&f, "Date", "20261340").is_err(), "no month 13"); + assert!(keystroke(&f, "Date", "20260230").is_err(), "no 30 February"); + assert!(keystroke(&f, "Date", "2026101899").is_err(), "digits left over make no day"); + assert!(keystroke(&f, "Date", "2026").is_err(), "a year alone is not a date"); + } + + /// What is left of a split run fills its field exactly: "202610018" leaves "018" for the + /// day, one digit too many, and used to pass as 18 October. Numbers typed on their own + /// keep their leeway. + #[test] + fn the_rest_of_a_split_run_must_fill_its_field_exactly() { + assert_eq!(parse_date("202610018", "yyyymmdd"), None, "one digit too many"); + assert_eq!(parse_date("2026101", "yyyymmdd"), None, "one digit too few"); + assert_eq!(parse_date("2026101814305", "yyyymmddHHMM"), None, "one digit too many in the time"); + assert_eq!(ymd(parse_date("2026 10 018", "yyyymmdd")), Some((2026, 10, 18)), "typed on its own, 018 is still a day"); + assert_eq!(parse_date("202610181430", "yyyymmddHHMM").map(|d| (d.d, d.hh, d.mm)), Some((18, 14, 30))); + } + + /// Times may be left out, so a number typed on its own isn't split for them: "018" is the + /// day in "2026 10 018" even when the format goes on to HHMM. + #[test] + fn a_number_typed_on_its_own_is_split_only_when_the_rest_fills_the_next_field() { + let dt = parse_date("2026 10 018", "yyyymmddHHMM").expect("a date with the time left out"); + assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0)); + assert_eq!(ymd(parse_date("20261018", "yyyymmddHHMM")), Some((2026, 10, 18))); + assert_eq!(parse_date("2026 10 18 1430", "yyyymmdd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30))); + } + + /// The date's runs are matched against the date's fields, the time's against the time's: + /// optional times left out don't make a separated four-digit year look like it needs + /// splitting, and a time typed in one run still fills HHMM. + #[test] + fn date_and_time_fields_are_counted_apart() { + let dt = parse_date("2026-10-18", "yymmddHHMM").expect("a date with the time left out"); + assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0)); + assert_eq!(parse_date("20261018 1430", "yyyymmddHHMM").map(|d| (d.y, d.m, d.d, d.hh, d.mm)), Some((2026, 10, 18, 14, 30))); + assert_eq!(parse_date("2026-10-18 1430", "yyyy-mm-dd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30))); + assert_eq!(parse_date("20261018143005", "yyyymmddHHMMss").map(|d| (d.d, d.hh, d.mm, d.ss)), Some((18, 14, 30, 5))); + } + + /// A run of more digits than fit in a u32 ("yyyymmddHHMMss") is split, not dropped. + #[test] + fn long_runs_of_digits_split_into_dates_and_times() { + let dt = parse_date("20261018143005", "yyyymmddHHMMss").expect("valid date and time"); + assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm, dt.ss), (2026, 10, 18, 14, 30, 5)); + assert_eq!(parse_date("1430", "HHMM").map(|d| (d.hh, d.mm)), Some((14, 30))); + assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None); + assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored"); + } + + #[test] + fn huge_finite_numbers_do_not_print_inf() { + let n = format_value(&Format::Number { decimals: 2, sep: 1, neg: 0, currency: String::new(), prepend: true }, "1e307"); + assert!(n.len() > 300 && n.ends_with(".00") && !n.contains("inf"), "got {n}"); + let p = format_value(&Format::Percent { decimals: 2, sep: 1 }, "1e306"); + assert!(!p.contains("inf"), "got {p}"); + } +} diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs new file mode 100644 index 0000000000..d29956cbc2 --- /dev/null +++ b/crates/forms/src/af/calc.rs @@ -0,0 +1,556 @@ +use super::format::{js_number, make_number}; +use super::script::{MAX_NESTING, Parser, js_str}; +use super::*; + +// ── renaming fields in calculations ───────────────────────────────────────────────────────── + +/// A JavaScript token, as far as finding the field names a calculation reads needs. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Js { + Ident, + /// A string literal, its escapes undone. + Str(String), + Punct(char), + /// A number, a regular expression or a template literal. + Other, +} + +/// A [`Js`] token with its byte range in the script. +type Token = (Range, Js); + +/// Words after which `/` starts a regular expression instead of dividing. +const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; + +/// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a +/// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so +/// a `/` after it starts the next statement, and can't divide. +const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; + +/// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. +fn line_ends_at(s: &[u8], j: usize) -> bool { + matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) +} + +/// JavaScript's tokens with their byte ranges, and apart from them the text of each comment +/// (inside its `//`, or its `/*` and `*/`): enough of the language to tell code from strings, +/// comments and regular expressions. The tokens stop where a string, comment or template literal +/// never ends; a comment that never ends runs to the end of the script. +pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { + let s = js.as_bytes(); + // Whether `s[j]` is part of a word. Bytes past ASCII are parts of identifiers, except + // U+2028 and U+2029, which end a line: a range never ends inside a character. + let word = |j: usize| s.get(j).is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii()) && !line_ends_at(s, j); + let mut out: Vec = Vec::new(); + let mut comments = Vec::new(); + let mut i = 0; + // Where a regular expression that never ended stopped being read: every `/` before it + // divides, so a line of them is read once, not once for each. + let mut divides_until = 0; + // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or + // `with`; and whether the last token is the `)` that closed one. + let mut conditions: Vec = Vec::new(); + let mut after_condition = false; + 'scan: while let Some(&c) = s.get(i) { + let start = i; + let token = match c { + _ if c.is_ascii_whitespace() => { + i += 1; + continue; + } + // U+2028 or U+2029 between tokens ends a line, and is skipped whole: read a byte at a + // time, the rest of it would be read from inside the character. + 0xE2 if line_ends_at(s, i) => { + i += 3; + continue; + } + b'/' if s.get(i + 1) == Some(&b'/') => { + i = (i + 2..s.len()).find(|&j| line_ends_at(s, j)).unwrap_or(s.len()); + comments.push(start + 2..i); + continue; + } + b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { + Some(n) => { + comments.push(i + 2..i + 2 + n); + i += n + 4; + continue; + } + None => { + comments.push(i + 2..s.len()); + break 'scan; + } + }, + b'"' | b'\'' => { + let mut p = Parser { s, i, depth: 0 }; + let Some(text) = p.string() else { break 'scan }; + i = p.i; + Js::Str(text) + } + b'`' => { + let mut j = i + 1; + loop { + match s.get(j) { + None => break 'scan, + Some(b'\\') => j += 2, + Some(b'`') => break, + Some(_) => j += 1, + } + } + i = j + 1; + Js::Other + } + b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { + // A regular expression ends at a `/` outside a class; a line break first means it + // was a division after all. + let (mut j, mut class) = (i + 1, false); + let end = loop { + match s.get(j) { + None => break None, + Some(_) if line_ends_at(s, j) => break None, + Some(b'\\') => j += 2, + Some(b'/') if !class => break Some(j), + Some(b) => { + class = match b { + b'[' => true, + b']' => false, + _ => class, + }; + j += 1; + } + } + }; + match end { + Some(e) => { + i = e + 1; + while word(i) { + i += 1; + } + Js::Other + } + None => { + divides_until = j; + i += 1; + Js::Punct('/') + } + } + } + _ if c.is_ascii_digit() => { + while word(i) || s.get(i) == Some(&b'.') { + i += 1; + } + Js::Other + } + _ if word(i) => { + while word(i) { + i += 1; + } + Js::Ident + } + _ => { + i += 1; + Js::Punct(char::from(c)) + } + }; + after_condition = match token { + Js::Punct('(') => { + conditions.push(opens_condition(js, &out)); + false + } + Js::Punct(')') => conditions.pop().unwrap_or(false), + _ => false, + }; + out.push((start..i, token)); + } + (out, comments) +} + +/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening +/// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not +/// after a value. `after_condition` says the last token is the `)` that ends such a condition. +fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { + match tokens.last() { + None => true, + Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), + Some((_, Js::Punct(')'))) => after_condition, + Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), + Some((_, Js::Str(_) | Js::Other)) => false, + } +} + +/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not +/// the arguments of a method that has one of those names (`o.if(x)`). +fn opens_condition(js: &str, tokens: &[Token]) -> bool { + let mut back = tokens.iter().rev(); + let Some((range, Js::Ident)) = back.next() else { return false }; + js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) +} + +/// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the +/// `EVCALC` after it, in the first of `comments` that holds both, as Acrobat writes it +/// (`/** BVCALC a + b EVCALC **/`). A string or code that spells the markers is not the notation. +pub(super) fn notation_range(js: &str, comments: &[Range]) -> Option> { + comments.iter().find_map(|comment| { + let text = js.get(comment.clone())?; + let a = text.find("BVCALC")? + 6; + let b = a + text.get(a..)?.find("EVCALC")?; + Some(comment.start + a..comment.start + b) + }) +} + +/// A place where a Calculate script names a field. +#[derive(Clone, Debug, PartialEq)] +enum Site { + /// A string literal holding one name: `getField("a")`, or an item of an + /// `AFSimple_Calculate` list. + Literal(String), + /// An `AFSimple_Calculate` list written as one string of comma-separated names. + Commas(String), + /// A name in the simplified field notation of a `BVCALC` comment. + Notation(String), + /// A field read by a name built at run time: a `getField` whose argument is more than one + /// string literal (`getField("row" + i)`), or an `AFSimple_Calculate` list that isn't + /// written out. Which field it is isn't known. + Built, +} + +impl Site { + fn names(&self) -> Vec<&str> { + match self { + Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], + Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), + Site::Built => Vec::new(), + } + } + + /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. + fn renamed(&self, old: &str, new: &str) -> Option { + match self { + Site::Literal(name) => (name == old).then(|| js_str(new)), + Site::Notation(name) => (name == old).then(|| notation_name(new)), + Site::Commas(list) => { + let parts: Vec<&str> = list.split(',').collect(); + parts.iter().any(|p| p.trim() == old).then(|| { + // The spaces around each name stay as they were. + let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); + js_str(&parts.join(",")) + }) + } + Site::Built => None, + } + } +} + +/// Where a Calculate script names fields, in order: the simplified field notation in its comment +/// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of +/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. A name +/// built at run time (`getField("row" + i)`) is a [`Site::Built`], at its `getField` or +/// `AFSimple_Calculate`. +fn calculation_sites(js: &str) -> Vec<(Range, Site)> { + let mut sites = Vec::new(); + let (tokens, comments) = js_tokens(js); + if let Some(at) = notation_range(js, &comments) + && let Some(expr) = js.get(at.clone()) + { + for (range, token) in notation_tokens(expr) { + if let Notation::Name(name) = token { + sites.push((at.start + range.start..at.start + range.end, Site::Notation(name))); + } + } + } + let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); + let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); + let string = |k: usize| match tokens.get(k) { + Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), + _ => None, + }; + let built = |k: usize| tokens.get(k).map(|(range, _)| (range.clone(), Site::Built)); + for k in 0..tokens.len() { + if ident(k, "getField") && punct(k + 1, '(') { + match string(k + 2) { + Some((range, name)) if punct(k + 3, ')') => sites.push((range, Site::Literal(name))), + _ => sites.extend(built(k)), + } + } + if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(')) { + continue; + } + if !(string(k + 2).is_some() && punct(k + 3, ',')) { + sites.extend(built(k)); + continue; + } + // One string of comma-separated names, when it is the whole list (not `"row" + suffix`). + // An argument after it leaves the list as written, as after an array: its names are read, + // and what follows is a name built at run time. + if let Some((range, list)) = string(k + 4) { + if punct(k + 5, ')') || punct(k + 5, ',') { + sites.push((range, Site::Commas(list))); + } + if !punct(k + 5, ')') { + sites.extend(built(k)); + } + continue; + } + // `new Array("a", "b")` or `["a", "b"]`: each item that is one string, and the list has + // to end the call. An item that is more than a string (`"row" + i`), or anything after + // the list (`.concat(more)`), is a name built at run time. Nothing else is read, so no + // token is read for two calls. + let (first, close) = if punct(k + 4, '[') { + (k + 5, ']') + } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { + (k + 7, ')') + } else { + sites.extend(built(k)); + continue; + }; + let mut j = first; + loop { + match tokens.get(j) { + // An item ends at a comma or the list's end, or where a script cut short ends. + Some((range, Js::Str(name))) if punct(j + 1, ',') || punct(j + 1, close) || tokens.get(j + 1).is_none() => { + sites.push((range.clone(), Site::Literal(name.clone()))); + j += 1; + } + Some((_, Js::Punct(','))) => j += 1, + Some((_, Js::Punct(c))) if *c == close => { + if !punct(j + 1, ')') { + sites.extend(built(k)); + } + break; + } + _ => { + sites.extend(built(k)); + break; + } + } + } + } + sites.sort_by_key(|(range, _)| range.start); + sites +} + +/// A Calculate script with field `old` named `new` everywhere it reads it (see +/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also +/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end +/// the notation early), so a script is never left meaning something else. +pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { + let sites = calculation_sites(js); + let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); + if edits.is_empty() { + return None; + } + let out = splice(js, edits); + let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); + let written = calculation_sites(&out); + let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); + (got == expected).then_some(out) +} + +/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the +/// order it first names them. +pub fn calculation_reads(js: &str) -> Vec { + let sites = calculation_sites(js); + unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) +} + +/// Whether a Calculate script also reads fields by names it builds at run time +/// (`getField("row" + i)`), which [`calculation_reads`] can't list. +pub fn reads_built_names(js: &str) -> bool { + calculation_sites(js).iter().any(|(_, site)| *site == Site::Built) +} + +impl Calculate { + /// The fields it reads, each once, in the order it first names them. A name also stands for + /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. + pub fn reads(&self) -> Vec { + match self { + Calculate::None => Vec::new(), + Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { + Notation::Name(name) => Some(name), + _ => None, + })), + } + } +} + +/// `names` without repeats, in order. +pub(super) fn unique(names: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + names.into_iter().filter(|n| seen.insert(n.clone())).collect() +} + +/// A token of simplified field notation. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Notation { + /// A number, as written. + Number, + /// `+ - * / ( )`. + Op(char), + /// A field name, its `\` escapes undone. + Name(String), +} + +/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates +/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. +pub(super) fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { + let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); + let mut out = Vec::new(); + let mut cs = src.char_indices().peekable(); + while let Some(&(start, c)) = cs.peek() { + let mut end = start + c.len_utf8(); + if c.is_whitespace() { + cs.next(); + } else if breaks(c) { + cs.next(); + out.push((start..end, Notation::Op(c))); + } else if c.is_ascii_digit() || c == '.' { + while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { + end = i + d.len_utf8(); + } + out.push((start..end, Notation::Number)); + } else { + let mut name = String::new(); + while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { + end = i + n.len_utf8(); + if n == '\\' + && let Some((j, next)) = cs.next() + { + end = j + next.len_utf8(); + name.push(next); + } else { + name.push(n); + } + } + out.push((start..end, Notation::Name(name))); + } + } + out +} + +/// A field name as simplified field notation writes it: `\` before a space, an operator or a +/// `\`, and before a first character that would start a number. +pub(super) fn notation_name(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { + out.push('\\'); + } + out.push(c); + } + out +} + +/// `src` with each range replaced. The ranges come in order and don't overlap; one that does +/// is left out rather than written over another. +fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { + let mut out = String::with_capacity(src.len()); + let mut done = 0; + for (range, with) in edits { + let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; + out.push_str(kept); + out.push_str(&with); + done = range.end; + } + out.push_str(src.get(done..).unwrap_or_default()); + out +} + +/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: +/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. +pub fn notation_js(expr: &str) -> String { + let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { + Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), + _ => None, + }); + splice(expr, names) +} + +/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. +pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { + #[derive(Debug, Clone, PartialEq)] + enum T { + N(f64), + Op(char), + } + let toks = notation_tokens(src) + .into_iter() + .map(|(range, t)| match t { + Notation::Number => src.get(range)?.parse().ok().map(T::N), + Notation::Op(c) => Some(T::Op(c)), + Notation::Name(name) => Some(T::N(value_of(&name))), + }) + .collect::>>()?; + // Recursive descent, `d` levels deep (at most MAX_NESTING). + fn expr(t: &[T], i: &mut usize, d: usize) -> Option { + let mut v = term(t, i, d)?; + while let Some(T::Op(o @ ('+' | '-'))) = t.get(*i) { + *i += 1; + let r = term(t, i, d)?; + v = if *o == '+' { v + r } else { v - r }; + } + Some(v) + } + fn term(t: &[T], i: &mut usize, d: usize) -> Option { + let mut v = factor(t, i, d)?; + while let Some(T::Op(o @ ('*' | '/'))) = t.get(*i) { + *i += 1; + let r = factor(t, i, d)?; + v = if *o == '*' { v * r } else { v / r }; + } + Some(v) + } + fn factor(t: &[T], i: &mut usize, d: usize) -> Option { + if d > MAX_NESTING { + return None; + } + match t.get(*i)? { + T::N(n) => { + *i += 1; + Some(*n) + } + T::Op('-') => { + *i += 1; + factor(t, i, d + 1).map(|v| -v) + } + T::Op('(') => { + *i += 1; + let v = expr(t, i, d + 1)?; + (t.get(*i) == Some(&T::Op(')'))).then(|| *i += 1)?; + Some(v) + } + _ => None, + } + } + let mut i = 0; + let v = expr(&toks, &mut i, 0)?; + (i == toks.len() && v.is_finite()).then_some(v) +} + +/// The Calculate event: the new value, given the current values of the other fields +/// (`values(name)` returns every field named `name` or below it, for groups). +pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { + let num = |s: &String| make_number(s).unwrap_or(0.0); + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => { + let xs: Vec = fields.iter().flat_map(|f| values(f)).map(|v| num(&v)).collect(); + let v = match op { + CalcOp::Sum => xs.iter().sum(), + CalcOp::Product => xs.iter().product(), + CalcOp::Average => { + if xs.is_empty() { + 0.0 + } else { + xs.iter().sum::() / xs.len() as f64 + } + } + CalcOp::Minimum => xs.iter().copied().fold(f64::INFINITY, f64::min), + CalcOp::Maximum => xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + Some(if v.is_finite() { js_number(v) } else { "0".into() }) + } + Calculate::Notation(expr) => { + let v = eval_notation(expr, &|name| values(name).first().map(num).unwrap_or(0.0))?; + Some(js_number(v)) + } + } +} diff --git a/crates/forms/src/af/format.rs b/crates/forms/src/af/format.rs new file mode 100644 index 0000000000..df4720bec7 --- /dev/null +++ b/crates/forms/src/af/format.rs @@ -0,0 +1,533 @@ +use super::*; + +/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets. +pub const DATE_PRESETS: [&str; 14] = [ + "m/d", + "m/d/yy", + "mm/dd/yy", + "mm/yy", + "d-mmm", + "d-mmm-yy", + "dd-mmm-yy", + "yy-mm-dd", + "mmm-yy", + "mmmm-yy", + "mmm d, yyyy", + "mmmm d, yyyy", + "m/d/yy h:MM tt", + "m/d/yy HH:MM", +]; + +pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"]; + +// ── running them ──────────────────────────────────────────────────────────────────────────── + +/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a +/// trailing % ignored; a comma decimal separator accepted). +pub fn make_number(s: &str) -> Option { + let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect(); + if t.is_empty() { + return None; + } + let neg = t.starts_with('(') && t.ends_with(')'); + let t = t.trim_start_matches('(').trim_end_matches(')'); + // "1.234,56" or "1234,56": comma is the decimal separator when it comes last. + let normalized = match (t.rfind(','), t.rfind('.')) { + (Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."), + (Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."), + _ => t.replace(',', ""), + }; + let v: f64 = normalized.parse().ok()?; + v.is_finite().then_some(if neg { -v } else { v }) +} + +/// A number as JavaScript prints it (integers without a fraction). +pub fn js_number(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") } +} + +fn group(int: &str, sep: char) -> String { + let mut out = String::new(); + for (i, c) in int.chars().enumerate() { + if i > 0 && (int.len() - i).is_multiple_of(3) { + out.push(sep); + } + out.push(c); + } + out +} + +/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56", +/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56"). +fn number_text(v: f64, decimals: u8, sep: u8) -> String { + // Round half away from zero, as JavaScript's toFixed does for these values. + let p = 10f64.powi(i32::from(decimals)); + let scaled = v.abs() * p; + // Past 2^53 every value is already whole, and scaling could overflow to infinity. + let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() }; + let s = format!("{:.*}", decimals as usize, r); + let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); + let (g, d) = match sep { + 1 => (None, '.'), + 2 => (Some('.'), ','), + 3 => (None, ','), + 4 => (Some('\''), '.'), + _ => (Some(','), '.'), + }; + let mut out = match g { + Some(c) => group(int, c), + None => int.to_string(), + }; + if !frac.is_empty() { + out.push(d); + out.push_str(frac); + } + out +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// A date and time read from text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DateTime { + pub y: i32, + pub m: u32, + pub d: u32, + pub hh: u32, + pub mm: u32, + pub ss: u32, +} + +fn days_in(y: i32, m: u32) -> u32 { + match m { + 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +fn weekday(y: i32, m: u32, d: u32) -> usize { + // Sakamoto's method, in i64 so no year or day can overflow it. + const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]; + let y = i64::from(y) - i64::from(m < 3); + let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0); + (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize +} + +/// One field of a date format, in the order the format has them. +struct Part { + /// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in + /// "dd.mm.26") is `'#'`. + kind: char, + /// The value of a `'#'` run; 0 for the others. + literal: u32, + /// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for + /// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is + /// a name ("mmm"). + width: usize, + /// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits + /// can hold both. + joined: bool, +} + +/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in +/// order. +fn order(fmt: &str) -> Vec { + let mut out: Vec = Vec::new(); + // Weekday names (ddd, dddd) are shown, not typed: leave them out. + let mut cleaned = fmt.replace("dddd", " "); + cleaned = cleaned.replace("ddd", " "); + let mut prev = ' '; + // The letter of the last field's current run and its length so far. + let mut run: Option<(char, usize)> = None; + let fixed = |k: char, n: usize| match (k, n) { + ('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n, + _ => 0, + }; + for c in cleaned.chars() { + let continues = run.is_some_and(|(r, _)| r == c); + if let Some((r, n)) = run + && !continues + { + // The run ended; it was the last field's width. + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); + } + run = None; + } + if let Some(digit) = c.to_digit(10) { + match out.last_mut() { + Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit), + _ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }), + } + prev = c; + continue; + } + let k = match c { + 'y' => 'y', + 'm' => 'm', + 'd' => 'd', + 'H' | 'h' => 'H', + 'M' => 'M', + 's' => 's', + _ => { + prev = c; + continue; + } + }; + if continues { + run = run.map(|(r, n)| (r, n.saturating_add(1))); + } else { + // A field starting right where the last one's letters stopped is joined to it. + let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's'); + if out.last().map(|p| p.kind) != Some(k) { + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.joined = adjacent; + } + out.push(Part { kind: k, literal: 0, width: 0, joined: false }); + } + run = Some((c, 1)); + } + prev = c; + } + if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); + } + out +} + +/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any +/// separators, month names, two-digit years). `None` when it isn't a valid date. +pub fn parse_date(text: &str, fmt: &str) -> Option { + let t = text.trim(); + if t.is_empty() { + return None; + } + let lower = t.to_lowercase(); + // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep". + let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); + let pm = lower.contains("pm") || bare_p; + let bare_a = lower.strip_suffix('a').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit())); + let am = lower.contains("am") || bare_a; + // Tokens: runs of digits (kept as text, so a run can be split between fields typed without + // separators) and month names. + let mut nums: VecDeque = VecDeque::new(); + let mut month_name: Option = None; + let mut cur = String::new(); + let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { + if cur.is_empty() { + return; + } + if cur.starts_with(|c: char| c.is_ascii_digit()) { + nums.push_back(std::mem::take(cur)); + } else if cur.len() >= 3 + && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) + { + *month = Some(i as u32 + 1); + } + cur.clear(); + }; + for c in t.chars() { + // Digits and letters form separate tokens ("5Mar" is 5 and Mar). + let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit(); + if c.is_alphanumeric() && same_kind { + cur.push(c); + } else { + flush(&mut cur, &mut nums, &mut month_name); + if c.is_alphanumeric() { + cur.push(c); + } + } + } + flush(&mut cur, &mut nums, &mut month_name); + let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; + // The next number for a field. A field joined to the next one in the format ("yyyymmdd") + // takes only its own width from a longer run of digits and leaves the rest for the next. + // A run typed on its own is split only when fewer runs were typed than the fields of its kind + // still to fill (`need`, this one included: date fields for a date field, time fields for a + // time field, since times may be left out), and only when its rest can fill the next field + // (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its + // four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left + // of a split run (`tail`: the front of `nums` is one) goes on being split along the fields + // written side by side, and must fill its last field exactly, so "202610018" leaves "018" for + // dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a + // rest has the wrong width. + let next_number = |nums: &mut VecDeque, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { + let run = nums.pop_front()?; + let was_tail = std::mem::replace(tail, false); + let short = nums.len() < need.saturating_sub(1); + let digits = match (run.get(..part.width), run.get(part.width..)) { + (Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => { + nums.push_front(rest.to_string()); + *tail = true; + head + } + _ if was_tail && part.width > 0 && run.len() != part.width => return None, + _ => run.as_str(), + }; + digits.parse::().ok() + }; + let parts = order(fmt); + let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none()); + let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's'); + // The fields of each kind still to fill: [date, time]. + let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()]; + let mut tail = false; + let mut have_y = false; + for (i, part) in parts.iter().enumerate() { + let kind = usize::from(is_time(part)); + let need = wanted.get(kind).copied().unwrap_or(0); + let next_width = parts.get(i + 1).map_or(0, |p| p.width); + if takes_number(part) + && let Some(left) = wanted.get_mut(kind) + { + *left = left.saturating_sub(1); + } + let take = |nums: &mut VecDeque, tail: &mut bool| next_number(nums, tail, part, need, next_width); + match part.kind { + // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" + // for "dd.mm.26"); a different number there is left over and rejected below. + '#' if nums.front().and_then(|n| n.parse::().ok()) == Some(part.literal) => { + nums.pop_front(); + tail = false; + } + 'm' => { + dt.m = match month_name { + Some(m) => m, + None => take(&mut nums, &mut tail)?, + } + } + 'd' => dt.d = take(&mut nums, &mut tail)?, + 'y' => { + let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?; + dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y }; + have_y = true; + } + // Times may be left out; a number that is there must be readable. + 'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + _ => {} + } + } + if !nums.is_empty() { + return None; + } + if !have_y { + dt.y = 2000; + } + if pm && dt.hh < 12 { + dt.hh += 12; + } + if am && dt.hh == 12 { + dt.hh = 0; + } + let has_date = fmt.contains(['y', 'm', 'd']); + if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) { + return None; + } + if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 { + return None; + } + Some(dt) +} + +/// Format a date/time with Acrobat's date format letters. +pub fn format_date(dt: DateTime, fmt: &str) -> String { + let chars: Vec = fmt.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + let mut n = 1; + while i + n < chars.len() && chars[i + n] == c { + n += 1; + } + let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 }; + match (c, n) { + ('y', 4..) => { + let _ = write!(out, "{:04}", dt.y); + } + ('y', _) => { + let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); + } + ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { + Some(name) if n >= 4 => out.push_str(name), + Some(name) => out.push_str(name.get(..3).unwrap_or(name)), + None => { + let _ = write!(out, "{}", dt.m); + } + }, + ('m', 2) => { + let _ = write!(out, "{:02}", dt.m); + } + ('m', _) => { + let _ = write!(out, "{}", dt.m); + } + ('d', 3..) => { + let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); + out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); + } + ('d', 2) => { + let _ = write!(out, "{:02}", dt.d); + } + ('d', _) => { + let _ = write!(out, "{}", dt.d); + } + ('H', 2) => { + let _ = write!(out, "{:02}", dt.hh); + } + ('H', _) => { + let _ = write!(out, "{}", dt.hh); + } + ('h', 2) => { + let _ = write!(out, "{h12:02}"); + } + ('h', _) => { + let _ = write!(out, "{h12}"); + } + ('M', 2) => { + let _ = write!(out, "{:02}", dt.mm); + } + ('M', _) => { + let _ = write!(out, "{}", dt.mm); + } + ('s', 2) => { + let _ = write!(out, "{:02}", dt.ss); + } + ('s', _) => { + let _ = write!(out, "{}", dt.ss); + } + ('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }), + ('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }), + (c, n) => out.extend(std::iter::repeat_n(c, n)), + } + i += n; + } + out +} + +const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"]; + +fn mask_fits(mask: &str, text: &str) -> bool { + let t: Vec = text.chars().collect(); + let m: Vec = mask.chars().collect(); + t.len() == m.len() + && m.iter().zip(&t).all(|(mc, tc)| match mc { + '9' => tc.is_ascii_digit(), + 'A' => tc.is_alphabetic(), + 'O' => tc.is_alphanumeric(), + 'X' => true, + other => other == tc, + }) +} + +/// Fill a mask from the typed characters that fit its slots. +fn apply_mask(mask: &str, text: &str) -> Option { + let mut src = text.chars().filter(|c| c.is_alphanumeric()); + let mut out = String::new(); + for mc in mask.chars() { + match mc { + '9' | 'A' | 'O' | 'X' => { + let c = src.next()?; + let ok = match mc { + '9' => c.is_ascii_digit(), + 'A' => c.is_alphabetic(), + _ => true, + }; + if !ok { + return None; + } + out.push(c); + } + other => out.push(other), + } + } + src.next().is_none().then_some(out) +} + +/// The text shown for a value (the Format event). +pub fn format_value(f: &Format, value: &str) -> String { + if value.is_empty() { + return String::new(); + } + match f { + Format::None => value.to_string(), + Format::Number { decimals, sep, neg, currency, prepend } => { + let Some(v) = make_number(value) else { return value.to_string() }; + let body = number_text(v, *decimals, *sep); + let body = match (currency.is_empty(), *prepend) { + (true, _) => body, + (false, true) => format!("{currency}{body}"), + (false, false) => format!("{body}{currency}"), + }; + match (v < 0.0, neg) { + (false, _) => body, + // Styles 2 and 3 use parentheses (3 is also red, which the appearance can't + // know here); 0 and 1 a minus sign. + (true, 2 | 3) => format!("({body})"), + (true, _) => format!("-{body}"), + } + } + Format::Percent { decimals, sep } => match make_number(value) { + Some(v) => format!( + "{}%", + if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) } + ), + None => value.to_string(), + }, + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), + Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()), + Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()), + } +} + +/// The Keystroke event on commit: does the value fit the format? Returns the value to store +/// (numbers are normalised), or Acrobat's message. +pub fn keystroke(f: &Format, field: &str, value: &str) -> Result { + let bad = || format!("The value entered does not match the format of the field [ {field} ]"); + // Blank input clears a formatted field; without a format, whitespace is text like any other. + if *f != Format::None && value.trim().is_empty() { + return Ok(String::new()); + } + match f { + Format::None => Ok(value.to_string()), + Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad), + Format::Percent { .. } => { + // Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number + // as typed and shows it ×100: keep the typed number. + make_number(value).map(js_number).ok_or_else(bad) + } + Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt) + .map(|_| value.trim().to_string()) + .ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")), + Format::Special(n) => { + let m = SPECIAL_MASKS[(*n).min(3) as usize]; + apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad) + } + Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad), + } +} + +/// The Validate event: `Err` with Acrobat's message when the value is out of range. +pub fn validate(v: &Validate, value: &str) -> Result<(), String> { + let Validate::Range { min, max } = v else { return Ok(()) }; + if value.trim().is_empty() { + return Ok(()); + } + let Some(x) = make_number(value) else { return Ok(()) }; + let fmt = |n: f64| js_number(n); + let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m); + if ok { + return Ok(()); + } + Err(match (min, max) { + (Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)), + (Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)), + (None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)), + (None, None) => String::new(), + }) +} diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs new file mode 100644 index 0000000000..f15714c411 --- /dev/null +++ b/crates/forms/src/af/script.rs @@ -0,0 +1,363 @@ +use super::calc::{js_tokens, notation_js, notation_range, notation_tokens}; +use super::format::{DATE_PRESETS, TIME_PRESETS}; +use super::{CalcOp, Calculate, Format, Validate}; + +// ── parsing scripts ───────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Arg { + Num(f64), + Str(String), + Bool(bool), + List(Vec), +} + +impl Arg { + fn num(&self) -> Option { + match self { + Arg::Num(n) => Some(*n), + Arg::Bool(b) => Some(f64::from(u8::from(*b))), + Arg::Str(s) => s.trim().parse().ok(), + Arg::List(_) => None, + } + } + + pub(super) fn string(&self) -> Option { + match self { + Arg::Str(s) => Some(s.clone()), + Arg::Num(n) => Some(n.to_string()), + _ => None, + } + } + + fn truthy(&self) -> bool { + match self { + Arg::Bool(b) => *b, + Arg::Num(n) => *n != 0.0, + Arg::Str(s) => !s.is_empty(), + Arg::List(l) => !l.is_empty(), + } + } +} + +/// How deeply script arguments and field notation may nest; deeper input is rejected rather +/// than recursed into (a hostile file could otherwise overflow the stack). +pub(super) const MAX_NESTING: usize = 64; + +fn hex4(bytes: &[u8]) -> Option { + if bytes.len() != 4 { + return None; + } + bytes.iter().try_fold(0u16, |value, byte| { + let digit = match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + b'A'..=b'F' => byte - b'A' + 10, + _ => return None, + }; + value.checked_mul(16)?.checked_add(u16::from(digit)) + }) +} + +pub(super) struct Parser<'a> { + pub(super) s: &'a [u8], + pub(super) i: usize, + pub(super) depth: usize, +} + +impl Parser<'_> { + fn ws(&mut self) { + while self.i < self.s.len() && (self.s[self.i].is_ascii_whitespace()) { + self.i += 1; + } + } + + pub(super) fn string(&mut self) -> Option { + let q = self.s[self.i]; + self.i += 1; + let mut out = Vec::new(); + while self.i < self.s.len() { + let c = self.s[self.i]; + self.i += 1; + match c { + b'\\' if self.i < self.s.len() => { + let e = self.s[self.i]; + self.i += 1; + match e { + b'n' => out.push(b'\n'), + b'r' => out.push(b'\r'), + b't' => out.push(b'\t'), + b'b' => out.push(0x08), + b'f' => out.push(0x0C), + b'v' => out.push(0x0B), + b'0' => out.push(0), + b'x' => { + let digits = self.s.get(self.i..self.i.checked_add(2)?)?; + let code = hex4(&[b'0', b'0', digits[0], digits[1]])?; + self.i += 2; + let ch = char::from_u32(u32::from(code)).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } + b'u' => { + let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; + self.i += 4; + let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { + let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; + if (0xDC00..=0xDFFF).contains(&low) { + self.i += 6; + 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) + } else { + u32::from(code) + } + } else { + u32::from(code) + }; + let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } + other => out.push(other), + } + } + c if c == q => return Some(String::from_utf8_lossy(&out).into_owned()), + c => out.push(c), + } + } + None + } + + fn arg(&mut self) -> Option { + self.ws(); + let c = *self.s.get(self.i)?; + match c { + b'"' | b'\'' => self.string().map(Arg::Str), + b'[' => { + self.i += 1; + self.nested(b']').map(Arg::List) + } + _ if self.s[self.i..].starts_with(b"new Array") => { + self.i += b"new Array".len(); + self.ws(); + if self.s.get(self.i) != Some(&b'(') { + return None; + } + self.i += 1; + self.nested(b')').map(Arg::List) + } + _ if self.s[self.i..].starts_with(b"true") => { + self.i += 4; + Some(Arg::Bool(true)) + } + _ if self.s[self.i..].starts_with(b"false") => { + self.i += 5; + Some(Arg::Bool(false)) + } + _ => { + let start = self.i; + while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { + self.i += 1; + } + std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) + } + } + } + + /// A list inside another, at most [`MAX_NESTING`] deep. + fn nested(&mut self, close: u8) -> Option> { + if self.depth >= MAX_NESTING { + return None; + } + self.depth += 1; + let out = self.list(close); + self.depth -= 1; + out + } + + fn list(&mut self, close: u8) -> Option> { + let mut out = Vec::new(); + loop { + self.ws(); + if self.s.get(self.i) == Some(&close) { + self.i += 1; + return Some(out); + } + out.push(self.arg()?); + self.ws(); + match self.s.get(self.i) { + Some(b',') => self.i += 1, + Some(c) if *c == close => { + self.i += 1; + return Some(out); + } + _ => return None, + } + } + } +} + +/// The first call to `name(` in `js` and its arguments. +pub(super) fn call(js: &str, name: &str) -> Option> { + let at = js.find(&format!("{name}("))?; + // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). + if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { + return None; + } + let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; + p.list(b')') +} + +pub fn parse_format(js: &str) -> Option { + let a = |args: &[Arg], i: usize| args.get(i).and_then(Arg::num).unwrap_or(0.0).clamp(0.0, 255.0) as u8; + if let Some(args) = call(js, "AFNumber_Format") { + return Some(Format::Number { + decimals: a(&args, 0), + sep: a(&args, 1), + neg: a(&args, 2), + currency: args.get(4).and_then(Arg::string).unwrap_or_default(), + prepend: args.get(5).is_none_or(Arg::truthy), + }); + } + if let Some(args) = call(js, "AFPercent_Format") { + return Some(Format::Percent { decimals: a(&args, 0), sep: a(&args, 1) }); + } + if let Some(args) = call(js, "AFDate_FormatEx") { + return Some(Format::Date(args.first().and_then(Arg::string)?)); + } + if let Some(args) = call(js, "AFDate_Format") { + return Some(Format::Date(DATE_PRESETS.get(a(&args, 0) as usize)?.to_string())); + } + if let Some(args) = call(js, "AFTime_FormatEx") { + return Some(Format::Time(args.first().and_then(Arg::string)?)); + } + if let Some(args) = call(js, "AFTime_Format") { + return Some(Format::Time(TIME_PRESETS.get(a(&args, 0) as usize)?.to_string())); + } + if let Some(args) = call(js, "AFSpecial_Format") { + return Some(Format::Special(a(&args, 0).min(3))); + } + if let Some(args) = call(js, "AFSpecial_KeystrokeEx") { + return Some(Format::Mask(args.first().and_then(Arg::string)?)); + } + None +} + +pub fn parse_validate(js: &str) -> Option { + let args = call(js, "AFRange_Validate")?; + let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); + let num = |i: usize| args.get(i).and_then(Arg::num); + Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) +} + +pub fn parse_calculate(js: &str) -> Option { + // Simplified field notation: Acrobat keeps the expression in a comment. A script is read for + // its comments only when it spells the marker. + if js.contains("BVCALC") + && let Some(range) = notation_range(js, &js_tokens(js).1) + { + return Some(Calculate::Notation(notation_text(js.get(range)?).to_string())); + } + let args = call(js, "AFSimple_Calculate")?; + let op = match args.first().and_then(Arg::string)?.as_str() { + "SUM" => CalcOp::Sum, + "PRD" => CalcOp::Product, + "AVG" => CalcOp::Average, + "MIN" => CalcOp::Minimum, + "MAX" => CalcOp::Maximum, + _ => return None, + }; + let fields = match args.get(1)? { + Arg::List(l) => l.iter().filter_map(Arg::string).collect(), + Arg::Str(s) => s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), + _ => return None, + }; + Some(Calculate::Simple { op, fields }) +} + +// ── writing scripts (what Acrobat's tabs generate) ───────────────────────────────────────── + +pub(super) fn js_str(s: &str) -> String { + let mut o = String::from("\""); + for c in s.chars() { + match c { + '"' | '\\' => { + o.push('\\'); + o.push(c); + } + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + c if c.is_ascii() && !c.is_control() => o.push(c), + c => { + let mut units = [0u16; 2]; + for unit in c.encode_utf16(&mut units) { + o.push_str(&format!("\\u{unit:04X}")); + } + } + } + } + o.push('"'); + o +} + +/// The (format, keystroke) scripts for a format. +pub fn format_js(f: &Format) -> Option<(String, String)> { + Some(match f { + Format::None => return None, + Format::Number { decimals, sep, neg, currency, prepend } => { + let args = format!("{decimals}, {sep}, {neg}, 0, {}, {prepend}", js_str(currency)); + (format!("AFNumber_Format({args});"), format!("AFNumber_Keystroke({args});")) + } + Format::Percent { decimals, sep } => (format!("AFPercent_Format({decimals}, {sep});"), format!("AFPercent_Keystroke({decimals}, {sep});")), + Format::Date(f) => (format!("AFDate_FormatEx({});", js_str(f)), format!("AFDate_KeystrokeEx({});", js_str(f))), + Format::Time(f) => (format!("AFTime_FormatEx({});", js_str(f)), format!("AFTime_KeystrokeEx({});", js_str(f))), + Format::Special(n) => (format!("AFSpecial_Format({n});"), format!("AFSpecial_Keystroke({n});")), + Format::Mask(m) => (String::new(), format!("AFSpecial_KeystrokeEx({});", js_str(m))), + }) +} + +pub fn validate_js(v: &Validate) -> Option { + match v { + Validate::None => None, + Validate::Range { min, max } => { + Some(format!("AFRange_Validate({}, {}, {}, {});", min.is_some(), min.unwrap_or(0.0), max.is_some(), max.unwrap_or(0.0))) + } + } +} + +/// Why [`notation_storable`] refuses a notation, in the words the Calculate tab and the tools say it. +pub const NOTATION_NOT_STORABLE: &str = "the notation can't be stored as written: it is empty, or holds EVCALC or */, or ends in a lone \\"; + +/// Whether simplified field notation `expr` can be stored: the script keeps it in a comment, +/// between `BVCALC` and `EVCALC`, and it has to read back from there as the same names and +/// operators. One holding `EVCALC` or `*/` ends early, and one ending in a lone `\` escapes the +/// space after it. An empty one is refused too: it would be stored as `event.value = ;`. +pub fn notation_storable(expr: &str) -> bool { + let tokens = |s: &str| notation_tokens(s).into_iter().map(|(range, _)| s.get(range).unwrap_or_default().to_owned()).collect::>(); + let read = calculate_js(&Calculate::Notation(expr.to_owned())).and_then(|js| parse_calculate(&js)); + !tokens(expr).is_empty() && matches!(read, Some(Calculate::Notation(n)) if tokens(&n) == tokens(expr)) +} + +/// `src` from its first notation token to its last: the spaces around it left out, but not an +/// escaped space that ends the last name (`Total\ `), as `str::trim` would. +fn notation_text(src: &str) -> &str { + let tokens = notation_tokens(src); + match (tokens.first(), tokens.last()) { + (Some((first, _)), Some((last, _))) => src.get(first.start..last.end).unwrap_or_default(), + _ => "", + } +} + +pub fn calculate_js(c: &Calculate) -> Option { + match c { + Calculate::None => None, + Calculate::Simple { op, fields } => Some(format!( + "AFSimple_Calculate({}, new Array({}));", + js_str(op.code()), + fields.iter().map(|f| js_str(f)).collect::>().join(", ") + )), + // Acrobat's own translation follows the comment, so a viewer that runs the script instead + // of reading the comment computes the same value (a placeholder here showed 0). + Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), + } +} diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs new file mode 100644 index 0000000000..79c5c7be02 --- /dev/null +++ b/crates/forms/src/af/tests.rs @@ -0,0 +1,385 @@ +use super::*; + +#[test] +fn javascript_escapes_are_decoded() { + assert_eq!( + parse_format("AFNumber_Format(2, 0, 0, 0, \"\\x24\", true);"), + Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) + ); + assert_eq!(parse_format("AFDate_FormatEx(\"\\b\\f\\v\\0\\q\");"), Some(Format::Date("\u{8}\u{c}\u{b}\0q".into()))); +} + +#[test] +fn scripts_are_recognised_and_written_back() { + assert_eq!( + parse_format("AFNumber_Format(2, 0, 0, 0, \"$\", true);"), + Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }) + ); + assert_eq!(parse_format("AFPercent_Format(1, 0);"), Some(Format::Percent { decimals: 1, sep: 0 })); + assert_eq!(parse_format("AFDate_FormatEx(\"dd-mmm-yyyy\");"), Some(Format::Date("dd-mmm-yyyy".into()))); + assert_eq!(parse_format("AFDate_Format(2);"), Some(Format::Date("mm/dd/yy".into()))); + assert_eq!(parse_format("AFTime_Format(1);"), Some(Format::Time("h:MM tt".into()))); + assert_eq!(parse_format("AFSpecial_Format(2);"), Some(Format::Special(2))); + assert_eq!(parse_format("AFSpecial_KeystrokeEx(\"AA-9999\");"), Some(Format::Mask("AA-9999".into()))); + assert_eq!(parse_format("app.alert('hi')"), None); + assert_eq!(parse_validate("AFRange_Validate(true, 0, true, 100);"), Some(Validate::Range { min: Some(0.0), max: Some(100.0) })); + assert_eq!(parse_validate("AFRange_Validate(false, 0, true, 10);"), Some(Validate::Range { min: None, max: Some(10.0) })); + assert_eq!( + parse_calculate("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b.c\"));"), + Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b.c".into()] }) + ); + assert_eq!(parse_calculate("AFSimple_Calculate('AVG', ['x']);"), Some(Calculate::Simple { op: CalcOp::Average, fields: vec!["x".into()] })); + assert_eq!( + parse_calculate("/** BVCALC Price * Qty EVCALC **/ event.value = AFMakeNumber(getField(\"Price\").value) * …"), + Some(Calculate::Notation("Price * Qty".into())) + ); + // Round trips through what the tabs write. + for f in [ + Format::Number { decimals: 2, sep: 2, neg: 2, currency: "€".into(), prepend: false }, + Format::Percent { decimals: 0, sep: 0 }, + Format::Date("mmmm d, yyyy".into()), + Format::Special(3), + ] { + let (fj, _) = format_js(&f).unwrap(); + assert_eq!(parse_format(&fj), Some(f)); + } + let euro = format_js(&Format::Number { decimals: 2, sep: 2, neg: 0, currency: "€".into(), prepend: true }).unwrap().0; + assert!(euro.contains("\\u20AC"), "{euro}"); + assert!(!euro.contains('€'), "{euro}"); + let pound = format_js(&Format::Number { decimals: 2, sep: 0, neg: 0, currency: "£".into(), prepend: true }).unwrap().0; + assert!(pound.contains("\\u00A3"), "{pound}"); + assert!(!pound.contains('£'), "{pound}"); + let c = Calculate::Simple { op: CalcOp::Product, fields: vec!["q".into(), "p".into()] }; + assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); + let c = Calculate::Notation("a + b".into()); + assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c)); + // The JavaScript after the comment is Acrobat's translation, so viewers that run it + // instead of reading the comment compute the same value (not 0). + assert_eq!( + calculate_js(&Calculate::Notation("(Unit\\ Price * Qty) / 2".into())).unwrap(), + "/** BVCALC (Unit\\ Price * Qty) / 2 EVCALC **/ event.value = (AFMakeNumber(this.getField(\"Unit Price\").value) * AFMakeNumber(this.getField(\"Qty\").value)) / 2;" + ); +} + +#[test] +fn a_rename_rewrites_only_where_a_calculation_reads_the_field() { + let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); + // The field lists Acrobat and other tools write for AFSimple_Calculate. + assert_eq!( + renamed("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b\"));").as_deref(), + Some("AFSimple_Calculate(\"SUM\", new Array (\"qty\", \"b\"));") + ); + assert_eq!(renamed("AFSimple_Calculate('AVG', ['b', 'a']);").as_deref(), Some("AFSimple_Calculate('AVG', ['b', \"qty\"]);")); + assert_eq!(renamed("AFSimple_Calculate(\"SUM\", \"a, b,a\");").as_deref(), Some("AFSimple_Calculate(\"SUM\", \"qty, b,qty\");")); + assert_eq!(renamed("AFSimple_Calculate(\"SUM\", new Array(\"ab\", \"b.a\"));"), None, "other names that contain it"); + // Notation: the expression in the comment and Acrobat's translation after it. + let notation = |expr: &str| calculate_js(&Calculate::Notation(expr.into())).unwrap(); + assert_eq!(rename_in_calculation(¬ation("(a + ab) * a / 2"), "a", "Unit Price"), Some(notation("(Unit\\ Price + ab) * Unit\\ Price / 2"))); + assert_eq!(rename_in_calculation(¬ation("Unit\\ Price * 2"), "Unit Price", "price"), Some(notation("price * 2"))); + // Scripts: a getField whose argument is one string literal. Comments, strings, template + // literals, regular expressions and names built at run time are left as written. + let script = "// getField(\"a\")\nvar s = \"getField('a')\", t = `getField(\"a\")`; /* getField(\"a\") */\n\ + if (/\"/.test(s)) event.value = this.getField('a').value / 2 * getField(\"a\" + 1).value + getField ( \"ab\" ).value;"; + assert_eq!(renamed(script), Some(script.replacen("this.getField('a')", "this.getField(\"qty\")", 1))); + assert_eq!(renamed("event.value = getField(\"b\").value;"), None); + // A new name is escaped as the script needs it. + assert_eq!( + rename_in_calculation("event.value = getField(\"a\").value;", "a", "say \"é\"").as_deref(), + Some("event.value = getField(\"say \\\"\\u00E9\\\"\").value;") + ); + // A name that would end the notation early is not written in. + assert_eq!(rename_in_calculation(¬ation("a * 2"), "a", "qtyEVCALC"), None); + // Unfinished scripts end their tokens, not the program. + for js in ["getField(\"a", "/* getField(\"a\")", "`getField(\"a\")", "x = /[/", "getField('a\\u12"] { + assert_eq!(renamed(js), None, "{js}"); + } +} + +/// The notation is read only from a comment, where Acrobat writes it: a string or code that +/// spells the markers is left alone, by the Calculate tab and by a rename. +#[test] +fn notation_is_read_only_from_a_comment() { + let string = "var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"; + assert_eq!(parse_calculate(string), None); + assert_eq!(rename_in_calculation(string, "a", "qty"), None); + // A comment after such a string holds the notation. + let both = "var s = 'BVCALC x EVCALC'; /** BVCALC a + 1 EVCALC **/ event.value = 1;"; + assert_eq!(parse_calculate(both), Some(Calculate::Notation("a + 1".into()))); + assert_eq!(rename_in_calculation(both, "x", "y"), None); + assert_eq!(rename_in_calculation(both, "a", "qty"), Some(both.replace("a + 1", "qty + 1"))); + // A line comment is a comment too, and an unfinished one still holds it. + assert_eq!(parse_calculate("// BVCALC a EVCALC\nevent.value = 1;"), Some(Calculate::Notation("a".into()))); + assert_eq!(parse_calculate("/** BVCALC a EVCALC"), Some(Calculate::Notation("a".into()))); +} + +/// A line comment ends at any of JavaScript's line terminators, not only `\n`, so with `\r` line +/// endings the code after one is still code; and a `/` read as starting a regular expression +/// divides after all when the line ends first. +#[test] +fn a_line_comment_ends_at_any_line_terminator() { + for end in ["\n", "\r", "\r\n", "\u{2028}", "\u{2029}"] { + let string = format!("// header{end}var s = \"BVCALC a EVCALC\"; event.value = getField(\"b\").value;"); + assert_eq!(parse_calculate(&string), None, "{end:?}"); + assert_eq!(rename_in_calculation(&string, "a", "qty"), None, "{end:?}"); + let script = format!("// note{end}getField(\"a\").value;"); + assert_eq!(rename_in_calculation(&script, "a", "qty"), Some(script.replace("\"a\"", "\"qty\"")), "{end:?}"); + let divides = format!("var n = i++ / 2;{end}event.value = getField(\"a\").value / 4;"); + assert_eq!(rename_in_calculation(÷s, "a", "qty"), Some(divides.replace("\"a\"", "\"qty\"")), "{end:?}"); + } +} + +/// U+2028 and U+2029 end a word, a number or a regular expression's flags, though as bytes +/// past ASCII they could be part of one, so a `getField` right after one is still a call. +#[test] +fn a_token_ends_at_a_line_separator() { + for end in ["\u{2028}", "\u{2029}"] { + let scripts = ["x", "1", "/r/g"].map(|token| format!("var t = {token}{end}getField(\"a\").value;")); + let renamed = scripts.clone().map(|js| rename_in_calculation(&js, "a", "qty")); + assert_eq!(renamed, scripts.map(|js| Some(js.replace("\"a\"", "\"qty\""))), "{end:?}"); + } +} + +/// A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, +/// as after an operator; after a call's parentheses it still divides. +#[test] +fn a_regex_after_a_condition_is_not_read_as_code() { + let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); + assert_eq!(renamed("if (flag) /getField(\"a\")/.test(x); event.value = 1;"), None); + // A quote inside it doesn't start a string that hides the rest of the script. + let quote = "while (i--) /\"/.test(s); event.value = getField(\"a\").value;"; + assert_eq!(renamed(quote), Some(quote.replace("\"a\"", "\"qty\""))); + // The same after the `while` that ends a `do` loop. + let after_do = "do i++; while (i < n) /\"/.test(s); event.value = getField(\"a\").value;"; + assert_eq!(renamed(after_do), Some(after_do.replace("\"a\"", "\"qty\""))); + // After a call, or a method that happens to be called `if`, the `/` divides. + let divides = "event.value = Math.abs(getField(\"b\").value) / 2 + getField(\"a\").value / 3 + o.if(x) / 4 + getField(\"a\").value / 5;"; + assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\""))); +} + +/// A `getField` whose argument is more than one string, or an `AFSimple_Calculate` list that +/// isn't written out, reads a field whose name isn't known until the script runs. +#[test] +fn names_built_at_run_time_are_noticed() { + for js in [ + "event.value = getField(\"row\" + i).value;", + "event.value = this.getField(name).value;", + "AFSimple_Calculate(\"SUM\", list);", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", b));", + "AFSimple_Calculate(\"SUM\", [\"a\", prefix + \"b\"]);", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\" + c));", + "AFSimple_Calculate(op, new Array(\"a\"));", + "AFSimple_Calculate(\"SUM\", \"row\" + suffix);", + "AFSimple_Calculate(\"SUM\", [\"a\"].concat(more));", + "AFSimple_Calculate(\"SUM\", [\"row\" + i]);", + ] { + assert!(reads_built_names(js), "{js}"); + } + // A string that is only the start of a name or a list isn't read, or renamed, as one. + for (js, reads) in [ + ("AFSimple_Calculate(\"SUM\", \"row\" + suffix);", vec![]), + ("AFSimple_Calculate(\"SUM\", [\"row\" + i]);", vec![]), + ("AFSimple_Calculate(\"SUM\", [\"a\", \"row\" + i]);", vec!["a"]), + ("AFSimple_Calculate(\"SUM\", [\"a\", \"b\"].concat(more));", vec!["a", "b"]), + ] { + assert_eq!(parse_calculate(js), None, "{js} is a script"); + assert_eq!(calculation_reads(js), reads, "{js}"); + assert_eq!(rename_in_calculation(js, "row", "line"), None, "{js}"); + } + for js in [ + "event.value = getField(\"a\").value + getField('b').value;", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\"));", + "AFSimple_Calculate(\"SUM\", [\"a\", \"b\",]);", + "AFSimple_Calculate(\"SUM\", \"a, b\");", + "// getField(\"row\" + i)\nvar s = 'getField(name)'; event.value = 1;", + "/** BVCALC a + b EVCALC **/ event.value = getField(\"a\").value + getField(\"b\").value;", + ] { + assert!(!reads_built_names(js), "{js}"); + } + // The names it does know are still read and renamed. + let js = "event.value = getField(\"a\").value + getField(\"row\" + i).value + AFSimple_Calculate(\"SUM\", new Array(\"a\", b));"; + assert_eq!(calculation_reads(js), ["a"]); + assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a\"", "\"qty\""))); + // So are a list's, string or array, with another argument after it. + for js in ["AFSimple_Calculate(\"SUM\", \"a, b\", x);", "AFSimple_Calculate(\"SUM\", [\"a\", \"b\"], x);"] { + assert!(reads_built_names(js), "{js}"); + assert_eq!(calculation_reads(js), ["a", "b"], "{js}"); + assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a", "\"qty")), "{js}"); + } +} + +/// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in +/// one another's field lists, don't make a rename read the rest of the script again for each. +#[test] +fn hostile_scripts_are_read_in_one_pass() { + let regexes = format!("x = {} getField(\"a\")", "/[".repeat(500_000)); + assert!(rename_in_calculation(®exes, "a", "b").is_some_and(|js| js.ends_with("getField(\"b\")"))); + let nested = format!("{}\"a\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)); + assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)))); +} + +/// A notation is stored only when it reads back from the script's comment as itself. +#[test] +fn a_notation_is_stored_only_when_it_reads_back() { + assert!(notation_storable("Price * Qty")); + assert!(notation_storable(" a + b "), "the spaces around it are left out when it is read"); + // A last name that ends in a space keeps it, escaped, when the notation is read back. + let total = "Qty * Total\\ "; + assert!(notation_storable(total)); + let read = parse_calculate(&calculate_js(&Calculate::Notation(total.into())).unwrap()); + assert_eq!(read.as_ref().map(Calculate::reads), Some(vec!["Qty".to_owned(), "Total ".to_owned()])); + for expr in ["", " ", "a EVCALC b", "a */ x /*", "a */ b", "a\\"] { + assert!(!notation_storable(expr), "{expr:?}"); + } +} + +/// Notation is read through one lexer: field names with escapes, numbers and operators. +#[test] +fn notation_is_read_by_one_lexer() { + let tokens: Vec = notation_tokens("(Unit\\ Price+1.5)*qty_2\\").into_iter().map(|(_, t)| t).collect(); + assert_eq!( + tokens, + [ + Notation::Op('('), + Notation::Name("Unit Price".into()), + Notation::Op('+'), + Notation::Number, + Notation::Op(')'), + Notation::Op('*'), + Notation::Name("qty_2\\".into()) + ] + ); + assert_eq!(notation_js("2 * a\\ b"), "2 * AFMakeNumber(this.getField(\"a b\").value)"); + let values = |name: &str| if name == "Unit Price" { 4.0 } else { 0.5 }; + assert_eq!(eval_notation("(Unit\\ Price + 1.5) * qty", &values), Some(2.75)); + assert_eq!(eval_notation("1.2.3 + a", &values), None, "a number that isn't one"); + assert_eq!(notation_name("2nd + (x)\\"), "\\2nd\\ \\+\\ \\(x\\)\\\\"); +} + +proptest::proptest! { + /// Any field name written in notation reads back as that one name. + #[test] + fn any_name_written_in_notation_reads_back(name in "\\PC{1,12}") { + let written = notation_name(&name); + proptest::prop_assert_eq!(notation_tokens(&written), vec![(0..written.len(), Notation::Name(name))]); + } + + /// Renaming a field in a formula and back gives the formula as it was. + #[test] + fn a_rename_round_trips(old in "\\PC{1,8}", new in "\\PC{1,8}", others in proptest::collection::vec("\\PC{1,8}", 0..4), sum in proptest::bool::ANY) { + proptest::prop_assume!(old != new && !others.contains(&new) && ![&old, &new].into_iter().chain(&others).any(|n| n.contains("VCALC"))); + let names: Vec = std::iter::once(old.clone()).chain(others).collect(); + let js = if sum { + calculate_js(&Calculate::Simple { op: CalcOp::Sum, fields: names }).unwrap() + } else { + calculate_js(&Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" * "))).unwrap() + }; + let renamed = rename_in_calculation(&js, &old, &new); + proptest::prop_assert!(renamed.is_some(), "{js}"); + let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old)); + proptest::prop_assert_eq!(back, Some(js)); + } + + /// A notation of any names, written into a script, reads back from it as written. + #[test] + fn a_notation_reads_back_as_written(names in proptest::collection::vec("\\PC{1,8}", 1..5)) { + proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); + let expr = names.iter().map(|n| notation_name(n)).collect::>().join(" + "); + let js = calculate_js(&Calculate::Notation(expr.clone())).unwrap(); + proptest::prop_assert_eq!(parse_calculate(&js), Some(Calculate::Notation(expr.clone()))); + proptest::prop_assert!(notation_storable(&expr), "{expr:?}"); + } + + /// What a formula reads is the same whether it is read from the tab's settings or from + /// the script they are written as. + #[test] + fn a_formula_reads_the_same_fields_as_its_script(names in proptest::collection::vec("\\PC{1,8}", 1..5), sum in proptest::bool::ANY) { + proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); + let c = if sum { + Calculate::Simple { op: CalcOp::Product, fields: names.clone() } + } else { + Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" + ")) + }; + let js = calculate_js(&c).unwrap(); + proptest::prop_assert_eq!(calculation_reads(&js), c.reads()); + proptest::prop_assert_eq!(c.reads(), unique(names)); + } +} + +#[test] +fn a_calculation_reads_each_named_field_once() { + assert_eq!(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into(), "a".into()] }.reads(), ["a", "b"]); + assert_eq!(Calculate::Notation("(Unit\\ Price + 2) * qty / qty".into()).reads(), ["Unit Price", "qty"]); + assert_eq!(Calculate::None.reads(), Vec::::new()); + let script = "var t = getField(\"sub\").value; // getField(\"x\")\nevent.value = t + getField(\"tax\").value + getField(\"row\" + 1).value;"; + assert_eq!(calculation_reads(script), ["sub", "tax"], "names built at run time aren't known"); + assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]); +} + +#[test] +fn numbers_format_like_acrobat() { + let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; + assert_eq!(format_value(&n(2, 0, 0, "$", true), "1234.5"), "$1,234.50"); + assert_eq!(format_value(&n(0, 1, 0, "", true), "1234567"), "1234567"); + assert_eq!(format_value(&n(2, 2, 0, " €", false), "1234.5"), "1.234,50 €"); + assert_eq!(format_value(&n(2, 0, 2, "", true), "-12"), "(12.00)"); + assert_eq!(format_value(&n(1, 0, 0, "", true), "-0.25"), "-0.3"); + assert_eq!(format_value(&Format::Percent { decimals: 1, sep: 0 }, "0.125"), "12.5%"); + assert_eq!(make_number("$1,234.50"), Some(1234.5)); + assert_eq!(make_number("1.234,5"), Some(1234.5)); + assert_eq!(make_number("(7)"), Some(-7.0)); + assert_eq!(make_number("abc"), None); + assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "$1,000"), Ok("1000".into())); + assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", "ten"), Err("The value entered does not match the format of the field [ Total ]".into())); + // Blank input clears a formatted field (#1323); without a format it is text like any other. + assert_eq!(keystroke(&n(2, 0, 0, "$", true), "Total", " \t"), Ok(String::new())); + assert_eq!(keystroke(&Format::None, "Name", " \t"), Ok(" \t".into())); +} + +#[test] +fn dates_times_and_masks() { + let d = Format::Date("mm/dd/yyyy".into()); + assert_eq!(format_value(&d, "1/2/24"), "01/02/2024"); + assert_eq!(format_value(&Format::Date("mmmm d, yyyy".into()), "10/1/2026"), "October 1, 2026"); + assert_eq!(format_value(&Format::Date("dddd, mmm d, yyyy".into()), "10/1/2026"), "Thursday, Oct 1, 2026"); + assert_eq!(format_value(&Format::Date("dd-mmm-yy".into()), "5-Mar-21"), "05-Mar-21", "month names are read"); + assert!(keystroke(&d, "Due", "2/30/2024").is_err(), "no 30 February"); + assert!(keystroke(&d, "Due", "13/1/2024").is_err()); + // Literal digits in the format (a leave form fixing the year as "dd.mm.26"). + let fixed = Format::Date("dd.mm.26".into()); + assert_eq!(keystroke(&fixed, "Von", "15.05.26"), Ok("15.05.26".into())); + assert_eq!(format_value(&fixed, "15.05.26"), "15.05.26"); + assert_eq!(format_value(&fixed, "3.1"), "03.01.26", "the literal may be left out"); + assert!(keystroke(&fixed, "Von", "15.05.27").is_err(), "a different number there is not the literal"); + assert!(keystroke(&fixed, "Von", "32.05.26").is_err()); + assert_eq!(format_value(&Format::Time("h:MM tt".into()), "14:05"), "2:05 pm"); + assert_eq!(format_value(&Format::Time("HH:MM".into()), "2:05 pm"), "14:05"); + assert_eq!(format_value(&Format::Special(2), "5551234567"), "(555) 123-4567"); + assert_eq!(format_value(&Format::Special(3), "123-45-6789"), "123-45-6789"); + assert!(keystroke(&Format::Special(0), "Zip", "1234").is_err()); + assert_eq!(format_value(&Format::Mask("AA-9999".into()), "ab1234"), "ab-1234"); + assert!(keystroke(&Format::Mask("AA-9999".into()), "Code", "a11234").is_err()); +} + +#[test] +fn validation_and_calculation() { + let r = Validate::Range { min: Some(0.0), max: Some(100.0) }; + assert!(validate(&r, "50").is_ok() && validate(&r, "").is_ok()); + assert_eq!(validate(&r, "120"), Err("Invalid value: must be greater than or equal to 0 and less than or equal to 100.".into())); + let vals = |name: &str| -> Vec { + match name { + "a" => vec!["2".into()], + "b" => vec!["$3.50".into()], + "row" => vec!["1".into(), "2".into(), "3".into()], + "Unit Price" => vec!["4".into()], + _ => vec![], + } + }; + let simple = |op, f: &[&str]| calculate(&Calculate::Simple { op, fields: f.iter().map(|s| s.to_string()).collect() }, &vals); + assert_eq!(simple(CalcOp::Sum, &["a", "b"]), Some("5.5".into())); + assert_eq!(simple(CalcOp::Product, &["a", "b"]), Some("7".into())); + assert_eq!(simple(CalcOp::Average, &["row"]), Some("2".into()), "groups count every child"); + assert_eq!(simple(CalcOp::Maximum, &["a", "row"]), Some("3".into())); + assert_eq!(simple(CalcOp::Sum, &["missing"]), Some("0".into())); + assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); + assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); +} diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 4ec22f0114..c9ff86d17a 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -13,8 +13,9 @@ //! Acrobat keeps a value its field font can't show: the value itself is stored as typed, //! [`field_appearance_drawn`] returns the first such character, and the caller marks the widget //! (`pdfcraft_fonts::UNDRAWN_MARK`) and asks other viewers to redraw the field -//! (`/NeedAppearances`). Widths use the approximate metrics of `pdfcraft-fonts` (Helvetica, or one -//! em per full-width character in composite fonts). +//! (`/NeedAppearances`). Widths are the field font's own (its `/Widths`, or the standard-14 +//! metrics), Helvetica's when Helvetica stands in, and one em per full-width character in +//! composite fonts. //! //! `/MK /R` (0, 90, 180, 270, counterclockwise) lays the content out in the upright box and sets //! the appearance `/Matrix` so that box lands on the widget rectangle. Any other value is 0, and @@ -39,9 +40,11 @@ pub fn fmt(v: f64) -> String { } /// The font size an auto-sized (`0 Tf`) text field uses for `text` in a `width` × `height` box -/// (inside the padding), measured by `width_of(text, size)`: at most 12 pt and at least 4 pt. -/// Single-line text shrinks until it fits the width; multiline text takes the largest size whose -/// wrapped lines fit the height. The page appearance and the on-screen editor share this. +/// (inside the padding), measured by `width_of(text, size)`, never under 4 pt. Single-line text +/// takes the size the height gives, as Acrobat Reader draws it however tall the box (the size is +/// implementation-defined, ISO 32000-2 §12.7.4.3), and shrinks until it fits the width; multiline +/// text takes the largest size up to 12 pt whose wrapped lines fit the height. The page +/// appearance and the on-screen editor share this. pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, width_of: impl Fn(&str, f64) -> f64) -> f64 { let wrap_width = if width.is_finite() { width.max(1.0) } else { 1.0 }; auto_text_size_by( @@ -53,6 +56,17 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt ) } +/// The automatic font size of a comb field (`Field::is_comb`) whose cells are `height` tall inside +/// the padding: 4 to 12 pt, as only the height bounds a one-character cell. The page appearance +/// and the on-screen editor share this. +pub fn comb_auto_text_size(height: f64) -> f64 { + if height.is_finite() { (height / 1.15).clamp(4.0, 12.0) } else { 4.0 } +} + +/// The tallest box single-line auto-size follows, as a sanity bound: the largest page side +/// ISO 32000-1 Annex C recommends. +pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0; + /// [`auto_text_size`] with the text measured by the caller: `line_width(size)` is its width on /// one line, `wrapped_height(size)` its height wrapped at `width`. The screen measures in its /// own font, whose widths and line breaks differ from the page's. @@ -66,11 +80,139 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i } return size; } - let size = (height / 1.15).clamp(4.0, 12.0); + let size = (height.min(MAX_AUTO_HEIGHT) / 1.15).max(4.0); let tw = line_width(size); if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size } } +/// How a text field's widget lays its value out in [`field_appearance_drawn`], for asking +/// whether a value fits it ([`TextBox::fits`]): what a field that doesn't scroll (`DoNotScroll`, +/// ISO 32000-2 Table 228) still takes. Built from the same pieces the appearance uses: the `/DA` +/// font from the form's `/DR` (Helvetica where that font can't show the text), the room inside +/// the border and the 2 pt padding, the `/DA` size (or the automatic size), and wrapping for a +/// multi-line field; the two must change together. +#[derive(Clone, Debug, PartialEq)] +pub struct TextBox { + /// The `/DA` font from `/DR`, when it is one the appearance can draw with. + font: Option, + /// Width and height inside the border and padding. + inner_w: f64, + inner_h: f64, + /// The `/DA` size; `None` for an automatic (`0 Tf`) size, which shrinks to fit. + size: Option, + multiline: bool, + password: bool, + /// A comb field: its cells, not its width, bound it (`/MaxLen`). + comb: bool, +} + +/// The [`TextBox`] widget `w` of text field `f` lays its value out in. +pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + let pad = 2.0 + border_width(doc, &wd); + let multiline = f.has(flags::MULTILINE); + TextBox { + font: dr_font(doc, &da.font).map(|(_, enc)| enc), + inner_w: (place.layout_w - 2.0 * pad).max(1.0), + inner_h: place.layout_h - 2.0 * pad, + size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size), + multiline, + password: f.has(flags::PASSWORD), + comb: f.is_comb(), + } +} + +impl TextBox { + /// Whether `text` fits: on one line within the width, or, multi-line, wrapped within the + /// height, as the appearance draws it. An automatic size shrinks before text stops fitting, + /// so such a field is full only when the text no longer fits at its smallest size (4 pt). A + /// comb field always fits (its `/MaxLen` bounds it). + pub fn fits(&self, text: &str) -> bool { + if self.comb || text.is_empty() { + return true; + } + let shown: String = if self.password { "*".repeat(text.chars().count()) } else { text.to_string() }; + // The font the appearance would choose for this text. + let can_show = |enc: &FontText| if self.multiline { shown.split(['\n', '\r']).all(|l| enc.can_encode(l)) } else { enc.can_encode(&shown) }; + let font = self.font.as_ref().filter(|enc| can_show(enc)).unwrap_or(&FontText::WinAnsi); + let width_of = |t: &str, size: f64| font.width(t, size); + // The smallest an automatic size goes ([`auto_text_size_by`]). + let size = self.size.unwrap_or(4.0); + if self.multiline { + // As the appearance draws it: the first baseline is 0.85 em below the padded top and + // a line is drawn while its descent (0.207 em) stays inside the border, so the + // padding below the last line is not needed: `inner_h` plus 2 pt. + // Long words break by character, so a line is wider than the box only when it is a + // single character wider than the box, which the appearance still cuts at the sides. + let lines = wrap_with(&shown, size, self.inner_w, width_of); + let above = lines.len().saturating_sub(1) as f64; + above * size * 1.15 + 1.057 * size <= self.inner_h + 2.0 + 0.01 && lines.iter().all(|l| width_of(l, size) <= self.inner_w + 0.01) + } else { + // A line break in a single-line value is drawn as a space. + let line: String = shown.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect(); + width_of(&line, size) <= self.inner_w + 1e-6 + } + } + + /// An edit from `before` to `after` as a field that doesn't scroll takes it: all of it when + /// `after` fits, and otherwise as much of the text it inserted (typed or pasted) as still + /// fits, keeping what it removed removed. Returns the text and where the caret goes (in + /// characters, after what was kept of the insertion). + pub fn fitted_edit(&self, before: &str, after: &str) -> (String, usize) { + let (b, a): (Vec, Vec) = (before.chars().collect(), after.chars().collect()); + // The edit replaced b[p..b.len() - q] with a[p..a.len() - q]. + let p = b.iter().zip(&a).take_while(|(x, y)| x == y).count(); + let most = b.len().min(a.len()).saturating_sub(p); + let q = b.iter().rev().zip(a.iter().rev()).take(most).take_while(|(x, y)| x == y).count(); + // q <= a.len() - p, so these are in range; `get` keeps it so if that ever changes. + let end = a.len().saturating_sub(q); + let (prefix, inserted, suffix) = (a.get(..p).unwrap_or_default(), a.get(p..end).unwrap_or_default(), a.get(end..).unwrap_or_default()); + let with = |k: usize| prefix.iter().chain(inserted.get(..k).unwrap_or_default()).chain(suffix).collect::(); + if self.fits(after) { + return (after.to_string(), p + inserted.len()); + } + // The longest of the insertion's first `from..=to` characters that fits, halving: in one + // font a longer text is never narrower. `None` when even `from` doesn't fit. + let longest = |from: usize, to: usize| -> Option { + if from > to || !self.fits(&with(from)) { + return None; + } + let (mut lo, mut hi) = (from, to); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if self.fits(&with(mid)) { lo = mid } else { hi = mid - 1 } + } + Some(lo) + }; + // The font can change once, from the form's own to Helvetica, at the first inserted + // character the form's font can't show: each side of that point is searched on its own. + let n = inserted.len(); + let kept = match self.font_switch(prefix, inserted, suffix) { + Some(t) => longest(0, t.saturating_sub(1)).into_iter().chain(longest(t, n)).max(), + None => longest(0, n), + } + .unwrap_or(0); + (with(kept), p + kept) + } + + /// Where an insertion changes the font [`Self::fits`] measures in: how much of `inserted` + /// (between `prefix` and `suffix`) first includes a character the form's own font can't show, + /// when everything around it can be shown in that font. `None` when the font never changes. + fn font_switch(&self, prefix: &[char], inserted: &[char], suffix: &[char]) -> Option { + let enc = self.font.as_ref().filter(|_| !self.password)?; + // As `fits` decides: a multi-line value is checked line by line, its breaks aside. + let shows = |c: &char| (self.multiline && matches!(c, '\n' | '\r')) || enc.can_encode(&c.to_string()); + if !prefix.iter().chain(suffix).all(shows) { + return None; + } + inserted.iter().position(|c| !shows(c)).map(|i| i + 1) + } +} + /// `/MK /R` as a quadrant, or 0 when it is missing or not a multiple of 90. pub(crate) fn mk_rotation(doc: &Document, wd: &Dict) -> i64 { let Some(raw) = wd.get(b"MK") else { return 0 }; @@ -191,7 +333,12 @@ impl FontText { fn width(&self, s: &str, size: f64) -> f64 { match self { - Self::WinAnsi | Self::Simple(_) => helvetica_width(s, size), + Self::WinAnsi => helvetica_width(s, size), + // The font's `/Widths`, or its standard-14 metrics (Courier, Times) when it has none. + Self::Simple(metrics) => match metrics.encode(&s.replace(['\t', '\r', '\n'], " ")) { + Some(bytes) => metrics.codes(&bytes).iter().map(|&(code, _)| metrics.width(code)).sum::() * size, + None => helvetica_width(s, size), + }, Self::Unicode(_) => cjk_width(s, size), } } @@ -220,6 +367,28 @@ fn dr_font(doc: &Document, name: &str) -> Option<(Object, FontText)> { Some((entry, FontText::Simple(Box::new(Metrics::from_dict(doc, fd))))) } +/// A push button's caption, centered in a `width` × `height` box, in the font, size and color its +/// `/DA` asks for: the font from the form's `/DR`, else Helvetica, and the size `auto` when the +/// `/DA` leaves it to the viewer (0). Returns the content operators, the font resource (name and +/// object), and the first character drawn as `?` because the font can't show it. +pub(crate) fn button_caption(doc: &Document, da: &str, caption: &str, width: f64, height: f64, auto: f64) -> (Vec, String, Object, Option) { + let da = parse_da(da); + let (name, font, enc) = match dr_font(doc, &da.font).filter(|(_, enc)| enc.can_encode(caption)) { + Some((o, enc)) => (da.font.clone(), o, enc), + None => ("Helv".to_string(), helvetica(), FontText::WinAnsi), + }; + let missing = enc.first_unencodable(caption); + let size = if da.size > 0.0 { da.size } else { auto }; + let tw = enc.width(caption, size); + let mut out = + format!("BT /{name} {size:.2} Tf {} 1 0 0 1 {:.3} {:.3} Tm ", da.color, (width - tw) / 2.0, (height - size * 0.7) / 2.0).into_bytes(); + out.extend(literal(&enc.encode(caption))); + out.extend_from_slice(b" Tj ET\n"); + (out, name, font, missing) +} + +/// A non-embedded Helvetica font dictionary in WinAnsi encoding, the fallback when the form's +/// `/DR` has no font that can show the text. fn helvetica() -> Object { let mut f = Dict::new(); f.set(b"Type".to_vec(), Object::name("Font")); @@ -249,10 +418,14 @@ fn stroke_op(fill: &str) -> String { } } -/// Background and border from `/MK` and `/BS`; returns (content, border width). +/// Background and border from `/MK` and `/BS`; returns (content, border width). A round XFA +/// check button's ([`is_round`]) are a circle inside the widget instead of its rectangle. fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); let mut c = String::new(); + if is_round(doc, wd) { + return round_frame(doc, wd, mk.as_ref(), w, h); + } if let Some(bg) = color_array(doc, mk.as_ref(), b"BG") { c.push_str(&format!("{bg}\n0 0 {} {} re f\n", n(w), n(h))); } @@ -318,12 +491,89 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { (c, 0.0) } +/// Whether `wd` is a check box or radio button PdfCraft laid out for a round XFA check button +/// (`/PCXfaRound true`, written by the XFA layout). +pub(crate) fn is_round(doc: &Document, wd: &Dict) -> bool { + matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) +} + +/// [`frame`] for a round check button: the `/MK /BG` background as a disc and the `/MK /BC` +/// border as a circle, both centred in the `w` × `h` box and as wide as its shorter side. A +/// dashed border stays dashed and an underline stays an underline; any other style is drawn +/// solid, and a border as wide as the circle fills it. Returns the border width. +fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> (String, f64) { + let mut c = String::new(); + let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h).max(0.0) / 2.0); + if let Some(bg) = color_array(doc, mk, b"BG") { + c.push_str(&format!("{bg}\n{}f\n", circle_path(cx, cy, r))); + } + let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); + let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + let style = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default(); + match color_array(doc, mk, b"BC") { + // An underline stays one: only the bottom edge, as for any widget. + Some(bc) if bw > 0.0 && style == b"U" => { + c.push_str(&format!("{}\n{} w\n0 {} m {} {} l S\n", stroke_op(&bc), n(bw), n(bw / 2.0), n(w), n(bw / 2.0))); + (c, bw) + } + // A border as wide as the circle fills it: a disc in the border's colour. + Some(bc) if bw > 0.0 && bw >= 2.0 * r => { + c.push_str(&format!("{bc}\n{}f\n", circle_path(cx, cy, r))); + (c, 2.0 * r) + } + Some(bc) if bw > 0.0 => { + let dashed = style == b"D"; + // The stroke is centred on the path: inset by half its width, it stays inside. + let path = circle_path(cx, cy, r - bw / 2.0); + c.push_str(&format!("{}\n{} w\n{}{path}S\n[] 0 d\n", stroke_op(&bc), n(bw), if dashed { "[3] 0 d\n" } else { "" })); + (c, bw) + } + _ => (c, 0.0), + } +} + +/// A closed circle of radius `r` around (`cx`, `cy`) as four Bézier arcs, ending with a space +/// so a paint operator can follow. +fn circle_path(cx: f64, cy: f64, r: f64) -> String { + // The control-point distance for a quarter circle. + let k = 0.5523 * r; + format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c ", + n(cx + r), + n(cy), + n(cx + r), + n(cy + k), + n(cx + k), + n(cy + r), + n(cx), + n(cy + r), + n(cx - k), + n(cy + r), + n(cx - r), + n(cy + k), + n(cx - r), + n(cy), + n(cx - r), + n(cy - k), + n(cx - k), + n(cy - r), + n(cx), + n(cy - r), + n(cx + k), + n(cy - r), + n(cx + r), + n(cy - k), + n(cx + r), + n(cy) + ) +} + /// The width [`frame`] insets the content by: the `/BS` border width when the widget has a /// border colour, doubled for beveled and inset borders, and 0 without a border. fn border_width(doc: &Document, wd: &Dict) -> f64 { let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); - let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + let bw = line_width(doc, wd); if color_array(doc, mk.as_ref(), b"BC").is_none() || bw <= 0.0 { return 0.0; } @@ -331,6 +581,37 @@ fn border_width(doc: &Document, wd: &Dict) -> f64 { if style == b"B" || style == b"I" { 2.0 * bw } else { bw } } +/// The widget's border line width, `/BS /W` (default 1, Table 168), as [`frame`] strokes it. +fn line_width(doc: &Document, wd: &Dict) -> f64 { + let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); + // `f64::max` turns NaN into 0, as it does a negative width. + bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0) +} + +/// Comb fields stop drawing their cells past this many; they would be a solid block anyway. +const MAX_COMB_DIVIDERS: usize = 500; + +/// The lines between the `cells` positions of a comb field `w` × `h` (ISO 32000-2 Table 228, +/// Comb: the field is divided into `MaxLen` equally spaced positions), in the border's colour and +/// width, inside the border. None without a border, or past [`MAX_COMB_DIVIDERS`] cells. +fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> String { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let inset = border_width(doc, wd); + let lw = line_width(doc, wd); + let Some(bc) = color_array(doc, mk.as_ref(), b"BC") else { return String::new() }; + if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite() && (2..=MAX_COMB_DIVIDERS).contains(&cells)) { + return String::new(); + } + let cell = w / cells as f64; + let mut c = format!("{}\n{} w\n", stroke_op(&bc), n(lw)); + for i in 1..cells { + let x = cell * i as f64; + c.push_str(&format!("{} {} m {} {} l\n", n(x), n(inset), n(x), n(h - inset))); + } + c.push_str("S\n"); + c +} + /// Where a list box draws its options: one row per option from the top, starting with option /// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share /// it, so a click picks the option drawn under the pointer (#1524). @@ -421,6 +702,45 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri /// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't /// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF. pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option) { + let (stream, unencodable, _) = field_appearance_fit(doc, f, w, values, format); + (stream, unencodable) +} + +/// A text value that its box cuts off at the field's font size (#4018). +#[derive(Clone, Debug, PartialEq)] +pub enum Overflow { + /// One line `width` points wide, with `room` for it, at `size` points. + Wide { width: f64, room: f64, size: f64 }, + /// `lines` wrapped lines, of which the box shows `room`, at `size` points. + Tall { lines: usize, room: usize, size: f64 }, + /// One line `height` points high (ascent to descent), with `room` for it, at `size` points. + High { height: f64, room: f64, size: f64 }, +} + +impl std::fmt::Display for Overflow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Wide { width, room, size } => { + write!(f, "the value does not fit: {} pt wide at {} pt, room for {} pt", fmt(*width), fmt(*size), fmt(*room)) + } + Self::Tall { lines, room, size } => write!(f, "the value does not fit: {lines} lines at {} pt, room for {room}", fmt(*size)), + Self::High { height, room, size } => { + write!(f, "the value does not fit: {} pt high at {} pt, room for {} pt", fmt(*height), fmt(*size), fmt(*room)) + } + } + } +} + +/// Whether the text field's value, drawn in widget `w`, is cut off by its box. +pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option { + if f.kind != FieldKind::Text { + return None; + } + field_appearance_fit(doc, f, w, &f.value, true).2 +} + +/// [`field_appearance_drawn`], and whether the text drawn is cut off by the box. +fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option, Option) { // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -485,6 +805,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S } }; let mut size = da.size; + let mut overflow = None; match f.kind { FieldKind::List => { // Every option, one per line from the top (from `/TI`); selected ones highlighted. @@ -505,57 +826,78 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S } } _ => { - let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0); + let comb = f.is_comb(); if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { if size == 0.0 { size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); } let mut y = height - pad - size * 0.85; - for line in wrap_with(&text, size, inner_w, width_of) { + let lines = wrap_with(&text, size, inner_w, width_of); + // The lines whose descent stays inside the border; the rest are cut off. + let mut room = 0; + for line in &lines { if y < -size { break; } - show(&mut body, x_for(&line, size), y, &line); + if y - 0.207 * size >= bw - 0.01 { + room += 1; + } + show(&mut body, x_for(line, size), y, line); y -= size * 1.15; } + if room < lines.len() { + overflow = Some(Overflow::Tall { lines: lines.len(), room, size }); + } else if let Some(tw) = lines.iter().map(|l| width_of(l, size)).filter(|tw| *tw > inner_w + 0.01).reduce(f64::max) { + // A character wider than the box is a line of its own, still cut at the sides. + overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); + } } else { if size == 0.0 { - // Comb cells hold one character each, so only the height limits them. size = if comb { - ((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0) + comb_auto_text_size(height - 2.0 * pad) } else { auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of) }; } // Centre Helvetica's ascent (0.718) and descent (0.207) vertically. let y = (height - 0.925 * size) / 2.0 + 0.207 * size; + // The clip box is inside the border; a line taller than it is cut at the top and bottom. + let room_h = height - 2.0 * bw; + if 0.925 * size > room_h + 0.01 { + overflow = Some(Overflow::High { height: 0.925 * size, room: room_h, size }); + } if comb { let cells = f.max_len.unwrap_or(1).max(1); let cell = width / cells as f64; + c.push_str(&comb_dividers(doc, &wd, width, height, cells)); for (i, ch) in text.chars().take(cells).enumerate() { let s = ch.to_string(); show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); } } else { + let tw = width_of(&text.replace(['\n', '\r'], " "), size); + if overflow.is_none() && tw > inner_w + 0.01 { + overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); + } show(&mut body, x_for(&text, size), y, &text); } } } } - let mut content = c.into_bytes(); - content.extend( - format!( - "/Tx BMC\nq\n{} {} {} {} re W n\nBT\n/{} {} Tf\n{}\n", - n(bw), - n(bw), - n(width - 2.0 * bw), - n(height - 2.0 * bw), - font_name, - n(size), - da.color - ) - .bytes(), - ); + // Acrobat regenerates only what is inside /Tx BMC when it redraws a field (e.g. on focus + // changes), discarding what came before it (issues #1613, #633). The background and border + // therefore go inside the marked-content block too, so a redraw keeps them with the text. + let mut content = format!( + "/Tx BMC\nq\n{c}{} {} {} {} re W n\nBT\n/{} {} Tf\n{}\n", + n(bw), + n(bw), + n(width - 2.0 * bw), + n(height - 2.0 * bw), + font_name, + n(size), + da.color + ) + .into_bytes(); content.extend(body); content.extend_from_slice(b"ET\nQ\nEMC\n"); let mut fonts = Dict::new(); @@ -567,13 +909,17 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S d.set(b"Subtype".to_vec(), Object::name("Form")); set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(res)); - (Stream::flate(d, &content), unencodable) + // Nothing drawn is nothing cut, however small the box. + let overflow = overflow.filter(|_| !text.is_empty()); + (Stream::flate(d, &content), unencodable, overflow) } /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, /// drawn as paths, so no symbol font is needed). +/// The mark is drawn in the colour of the widget's `/DA`, else of `f_da` (the field's): the +/// Appearance tab's Text Color (#4261). /// The streams are added to `doc` as indirect objects (streams can't be direct objects). -pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict { +pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str, f_da: &str) -> Dict { let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); @@ -581,6 +927,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name let (width, height) = (place.layout_w, place.layout_h); let (frame_c, _) = frame(doc, &wd, width, height); let style = crate::author::CheckStyle::of_widget(doc, &wd, kind); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(f_da)); let mut form = |content: String| -> Object { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); @@ -589,7 +936,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes())))) }; let s = width.min(height); - let mark = check_mark(style, width / 2.0, height / 2.0, s); + let mark = check_mark(style, width / 2.0, height / 2.0, s, &da.color); let mut nd = Dict::new(); nd.set(crate::name_bytes(on_name), form(format!("{frame_c}{mark}"))); nd.set(b"Off".to_vec(), form(frame_c)); @@ -599,12 +946,13 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name } /// The on-state mark for `style` (#94), centred on (`cx`, `cy`) in a box whose smaller side is -/// `s`, drawn in black. -fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> String { +/// `s`, drawn in the fill colour operation `color` (`0 g`, `0 0 1 rg`). +fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color: &str) -> String { use crate::author::CheckStyle; + let (fill, stroke) = (color, stroke_op(color)); // A filled polygon through `pts`. let polygon = |pts: &[(f64, f64)]| { - let mut c = String::from("0 g\n"); + let mut c = format!("{fill}\n"); for (i, (x, y)) in pts.iter().enumerate() { c.push_str(&format!("{} {} {} ", n(*x), n(*y), if i == 0 { "m" } else { "l" })); } @@ -614,7 +962,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str let line = n((s * 0.1).max(1.0)); match style { CheckStyle::Check => format!( - "0 G\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + "{stroke}\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", n(cx - s * 0.28), n(cy), n(cx - s * 0.08), @@ -625,7 +973,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str CheckStyle::Cross => { let d = s * 0.25; format!( - "0 G\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", + "{stroke}\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", n(cx - d), n(cy - d), n(cx + d), @@ -638,7 +986,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str } CheckStyle::Square => { let d = s * 0.22; - format!("0 g\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) + format!("{fill}\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) } CheckStyle::Diamond => { let d = s * 0.3; @@ -660,7 +1008,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str let r = s * 0.25; let k = 0.5523 * r; format!( - "0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", + "{fill}\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", n(cx + r), n(cy), n(cx + r), @@ -692,6 +1040,95 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str } } +/// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when +/// the file has no appearance for that state, or leaves its appearances to the viewer +/// (`/NeedAppearances`): its `/MK` background and border ([`frame`]), and when on, its caption +/// `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes and radio buttons may +/// use too) as a ZapfDingbats character, as in the check box example of +/// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is +/// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most +/// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed. +pub(crate) fn button_state_shown(doc: &Document, f: &Field, w: &Widget, on: bool) -> Stream { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let (frame_c, bw) = frame(doc, &wd, width, height); + let mut content = frame_c.into_bytes(); + let mut res = Dict::new(); + if on { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let caption = mk.as_ref().and_then(|mk| mk.get(b"CA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + let dingbats = pdfcraft_fonts::Std14::ZapfDingbats; + // The caption's first character as a ZapfDingbats code, when the font has a glyph there. + let code = caption + .and_then(|s| s.chars().next()) + .and_then(|ch| u8::try_from(u32::from(ch)).ok()) + .filter(|&code| dingbats.width(code) > 0.0) + .unwrap_or(if f.kind == FieldKind::Radio { b'l' } else { b'4' }); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + // The glyph's advance and (about) its height, in ems. + let (advance, tall) = (dingbats.width(code) / 1000.0, 0.75); + let room = |side: f64| (side - 2.0 * bw - 2.0).max(1.0); + // An automatic size leaves a margin of a tenth of the room on each side. + let size = if da.size > 0.0 { da.size } else { (0.8 * room(width) / advance.max(0.1)).min(0.8 * room(height) / tall).clamp(1.0, 300.0) }; + let (x, y) = ((width - advance * size) / 2.0, (height - tall * size) / 2.0 + 0.05 * size); + content.extend(format!("q\nBT\n/ZaDb {} Tf\n{}\n{} {} Td\n", n(size), da.color, n(x), n(y)).bytes()); + content.extend(literal(&[code])); + content.extend_from_slice(b" Tj\nET\nQ\n"); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats")); + let mut fonts = Dict::new(); + fonts.set(b"ZaDb".to_vec(), Object::Dict(font)); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(res)); + Stream::flate(d, &content) +} + +/// How a push button widget `w` of field `f` with no normal appearance looks: its `/MK` +/// background and border, in the `/BS` width and style ([`frame`]: Table 168, `B` beveled, `I` +/// inset), with its caption `/MK /CA` (Table 192) centred in the `/DA` font, size and colour. +/// A button with an icon (`/MK /I`) is drawn as PdfCraft draws its own buttons, icon included. +pub(crate) fn push_button_shown(doc: &Document, f: &Field, w: &Widget) -> Stream { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + if mk.contains(b"I") { + return crate::author::button_appearance(doc, w, &f.da).0; + } + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let (frame_c, _) = frame(doc, &wd, width, height); + let mut content = frame_c.into_bytes(); + let caption = mk.get(b"CA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_default(); + let mut fonts = Dict::new(); + if !caption.is_empty() && mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) != Some(1) { + let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + // The automatic size PdfCraft's own buttons use. + let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0); + let (ops, name, font, _) = button_caption(doc, da.as_deref().unwrap_or(&f.da), &caption, width, height, auto); + content.extend(ops); + fonts.set(name.into_bytes(), font); + } + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(res)); + Stream::flate(d, &content) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs new file mode 100644 index 0000000000..64569fa3ca --- /dev/null +++ b/crates/forms/src/appearances.rs @@ -0,0 +1,237 @@ +use super::read::{acroform, fields}; +use super::values::merged_appearance; +use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes}; + +/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). +pub fn need_appearances(doc: &Document) -> bool { + acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) +} + +/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for +/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from +/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the +/// writer left the appearances to the viewer), and otherwise each widget without a normal +/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons +/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border +/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own +/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is +/// not changed: saving writes the file as it was. +pub fn with_field_appearances(doc: &Document) -> Option { + with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) +} + +/// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are +/// the same objects as then (see [`FieldAppearanceCache`]). +pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option { + let all = need_appearances(doc); + let mut copy: Option = None; + for f in fields(doc) { + for w in &f.widgets { + let widget = doc.get(w.obj); + let normal = widget + .as_dict() + .and_then(|d| d.get(b"AP")) + .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); + // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, + // else the value when it names this widget's on state (any check box value when the + // widget names none), else Off. An Off state may go without one (it shows nothing); + // an on state needs one. + let state = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => { + let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v); + let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine); + Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) + } + _ => None, + }; + // With /NeedAppearances a check box or radio button that describes its look in /MK is + // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. + let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); + let missing = match f.kind { + FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, + FieldKind::PushButton => normal.is_none(), + FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { + _ if all && described() => true, + (None, _) => true, + (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), + _ => false, + }, + FieldKind::Signature => false, + }; + if !missing { + continue; + } + let inputs = appearance_inputs(doc, w); + let stream = match cache.get(w.obj, &inputs) { + Some(stream) => stream, + None => { + let stream = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")), + FieldKind::PushButton => appearance::push_button_shown(doc, &f, w), + _ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0, + }; + cache.insert(w.obj, inputs, &stream); + stream + } + }; + let target = copy.get_or_insert_with(|| doc.clone()); + let ap = target.add(Object::Stream(stream)); + let mut fresh = Dict::new(); + match &state { + // The drawn look under its state's name, and that state as the widget's own. + Some(s) => { + let mut states = Dict::new(); + states.set(name_bytes(s), Object::Ref(ap)); + fresh.set(b"N".to_vec(), Object::Dict(states)); + } + None => fresh.set(b"N".to_vec(), Object::Ref(ap)), + } + let apd = merged_appearance(target, w.obj, &fresh); + // Fails only when the widget isn't a dictionary; that widget then keeps its own look. + target + .update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(s) = &state { + d.set(b"AS".to_vec(), Object::Name(name_bytes(s))); + } + }) + .ok(); + } + } + copy +} + +/// The objects a widget's drawn appearance is built from, as the document holds them: the widget +/// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the +/// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to +/// (an indirect `/MK` or `/V`). Not the page: a field's rotation is its own `/MK /R`. +fn appearance_inputs(doc: &Document, w: &Widget) -> Vec> { + // The objects a dictionary's entries refer to. The tree links and the drawn appearance itself + // are not inputs, and `/Kids` and `/Fields` can be long. + let referred = |d: &Dict, inputs: &mut Vec>| { + for (k, v) in d.iter() { + if !matches!(&k[..], b"Kids" | b"Fields" | b"P" | b"Parent" | b"AP") + && let Some(r) = v.as_ref() + { + inputs.push(doc.get(r)); + } + } + }; + let mut inputs = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut node = Some(w.obj); + // The /Parent chain; the seen-set and the cap end a cycle or an absurd depth. + while let Some(r) = node.filter(|r| seen.insert(*r) && seen.len() <= 64) { + let obj = doc.get(r); + if let Some(d) = obj.as_dict() { + node = d.reference(b"Parent"); + referred(d, &mut inputs); + } else { + node = None; + } + inputs.push(obj); + } + let Some(root) = doc.root() else { return inputs }; + let catalog = doc.get(root); + let form = match catalog.as_dict().and_then(|c| c.reference(b"AcroForm")) { + Some(r) => doc.get(r), + None => catalog, + }; + if let Some(f) = form.as_dict() { + referred(f, &mut inputs); + // The fonts of /DR. An indirect /DR or /Font is listed through the entries that refer to + // it; a direct one is part of the object that holds it. `held` only finds them: a copy + // of a direct object is a new one each time, never an input. + let held = |o: &Object| o.as_ref().map_or_else(|| std::sync::Arc::new(o.clone()), |r| doc.get(r)); + if let Some(dr) = f.get(b"DR").map(held) + && let Some(dr) = dr.as_dict() + { + referred(dr, &mut inputs); + if let Some(font) = dr.get(b"Font").map(held) + && let Some(fonts) = font.as_dict() + { + referred(fonts, &mut inputs); + } + } + } + inputs.push(form); + inputs +} + +/// Field appearances drawn for the display copy ([`with_field_appearances_cached`]), so a refresh +/// redraws only the widgets whose inputs changed: a form that leaves every appearance to the +/// viewer otherwise redrew all of its fields on each keystroke in any one of them (#3470). An +/// entry holds the input objects it was drawn from and is used only while each is still the same +/// object (`Arc::ptr_eq`); the document replaces an object it edits, so a changed input is a +/// miss, never a stale hit. +#[derive(Clone, Debug, Default)] +pub struct FieldAppearanceCache { + entries: std::collections::HashMap>, pdfcraft_cos::Stream)>, + bytes: usize, + hits: u64, + builds: u64, +} + +impl FieldAppearanceCache { + /// Cached appearance data restarts past this instead of growing without bound. + const MAX_BYTES: usize = 64 << 20; + + /// Forget every entry. + pub fn clear(&mut self) { + *self = Self::default(); + } + + fn get(&mut self, w: ObjRef, inputs: &[std::sync::Arc]) -> Option { + let (from, stream) = self.entries.get(&w)?; + if from.len() != inputs.len() || !from.iter().zip(inputs).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) { + return None; + } + self.hits += 1; + Some(stream.clone()) + } + + fn insert(&mut self, w: ObjRef, inputs: Vec>, stream: &pdfcraft_cos::Stream) { + self.builds += 1; + if self.bytes.saturating_add(stream.raw.len()) > Self::MAX_BYTES { + self.clear(); + } + self.bytes = self.bytes.saturating_add(stream.raw.len()); + if let Some((_, old)) = self.entries.insert(w, (inputs, stream.clone())) { + self.bytes = self.bytes.saturating_sub(old.raw.len()); + } + } + + /// Cache hits so far (reused appearances); for tests. + pub fn hits(&self) -> u64 { + self.hits + } + + /// Appearances drawn so far; for tests. + pub fn builds(&self) -> u64 { + self.builds + } +} + +/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). +pub fn declares_pdfa(doc: &Document) -> bool { + let Some(root) = doc.root() else { return false }; + let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false }; + let m = doc.resolve(&m); + match &*m { + Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), + _ => false, + } +} + +/// Every field with a widget whose appearance PdfCraft drew with `?` for a character its font +/// can't hold (`pdfcraft_fonts::UNDRAWN_MARK`): the field's name and that character, once per +/// field. +pub fn undrawn_fields(doc: &Document) -> Vec<(String, char)> { + fields(doc) + .into_iter() + .filter_map(|f| { + let c = f.widgets.iter().find_map(|w| doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark))?; + Some((f.name, c)) + }) + .collect() +} diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 521d1f5278..de597187d4 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -7,10 +7,26 @@ //! immediately so every viewer shows the empty field. use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; -use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs}; +mod edit; +mod join; +mod lifecycle; +mod look; +mod tree; + +pub(crate) use tree::ancestor_sets; + +pub use edit::set_props; +pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field}; +pub(crate) use look::button_appearance; +pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon}; +pub(crate) use tree::{ensure_form, form_fonts_as_objects}; + +/// The smallest width and height of a field's widget, in points: a smaller one is refused. +pub const MIN_FIELD_SIDE: f64 = 4.0; + /// The kind of field to add. #[derive(Clone, Debug, PartialEq)] pub enum NewField { @@ -72,6 +88,9 @@ pub struct FieldProps { pub max_len: Option>, /// Choice options (display texts; export values equal them). pub options: Option>, + /// Change one button widget's export value without changing its appearance state. + /// Widgets sharing that state are kept in sync. The index is zero-based. + pub export_value: Option<(usize, String)>, /// Font size (0 = auto). pub font_size: Option, /// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in @@ -111,1242 +130,3 @@ fn invalid(m: impl Into) -> Result { fn rgb(c: [f64; 3]) -> Object { Object::Array(c.iter().map(|v| Object::Real(*v)).collect()) } - -/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. -pub(crate) fn ensure_form(doc: &mut Document) -> Result { - let root = doc.root().ok_or(FormError::NoForm)?; - let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()); - let r = match existing { - Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => r, - other => { - let d = other.and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let r = doc.add(Object::Dict(d)); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; - r - } - }; - let mut af = doc.get(r).as_dict().cloned().unwrap_or_default(); - if !af.contains(b"Fields") { - af.set(b"Fields".to_vec(), Object::Array(Vec::new())); - } - if !af.contains(b"DA") { - af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - } - let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - // Acrobat's resource names for the standard fonts its Appearance tab offers. - let mut added = false; - for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { - if fonts.contains(name.as_bytes()) { - continue; - } - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(name.as_bytes().to_vec(), Object::Dict(f)); - added = true; - } - if added { - dr.set(b"Font".to_vec(), Object::Dict(fonts)); - af.set(b"DR".to_vec(), Object::Dict(dr)); - } - doc.set(r, Object::Dict(af)); - Ok(r) -} - -/// The Appearance tab: borders, colours and text. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Look { - pub border: Option<[f64; 3]>, - pub fill: Option<[f64; 3]>, - /// Line thickness in points (Acrobat: thin 1, medium 2, thick 3). - pub width: f64, - pub style: BorderStyle, - pub text: [f64; 3], - pub font: FieldFont, -} - -/// A partial Appearance-tab edit. Outer `None` keeps a property; `Some(None)` removes a -/// border or fill colour. The patch is applied to each widget's own dictionaries. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct LookPatch { - pub border: Option>, - pub fill: Option>, - pub width: Option, - pub style: Option, - pub text: Option<[f64; 3]>, - pub font: Option, -} - -impl LookPatch { - fn validate(&self) -> Result<(), FormError> { - for c in self.border.flatten().into_iter().chain(self.fill.flatten()).chain(self.text) { - if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) { - return invalid("appearance colours must be finite RGB values between 0 and 1"); - } - } - if self.width.is_some_and(|v| !v.is_finite() || !(0.0..=12.0).contains(&v)) { - return invalid("border width must be between 0 and 12 points"); - } - Ok(()) - } -} - -/// Rewrite only the selected operators in place: the last `Tf` (font and size) and the last -/// fill colour (`g`, `rg` or `k`), appending one when the string has none. Everything else (a -/// custom font resource, colour space or other graphics state) is kept, and repeated edits don't -/// grow the string. -fn patch_da(da: &str, size: Option, patch: &LookPatch) -> String { - if patch.font.is_none() && size.is_none() && patch.text.is_none() { - return da.to_string(); - } - let mut toks: Vec = da.split_whitespace().map(str::to_string).collect(); - let numeric = |s: &str| s.parse::().is_ok(); - if patch.font.is_some() || size.is_some() { - let old = appearance::parse_da(da); - let font = patch.font.map_or_else(|| old.font.clone(), |f| f.resource().to_string()); - // `parse_da` reads sizes up to 300 points; keep the same bound when writing. - let size = appearance::fmt(size.unwrap_or(old.size).clamp(0.0, 300.0)); - let at = toks.iter().enumerate().rev().find(|(i, t)| t.as_str() == "Tf" && *i >= 2).map(|(i, _)| i); - match at { - Some(i) => { - if let Some(slot) = toks.get_mut(i - 2) { - *slot = format!("/{font}"); - } - if let Some(slot) = toks.get_mut(i - 1) { - *slot = size; - } - } - None => toks.extend([format!("/{font}"), size, "Tf".into()]), - } - } - if let Some(c) = patch.text { - let rgb = [appearance::fmt(c[0]), appearance::fmt(c[1]), appearance::fmt(c[2]), "rg".into()]; - let at = toks.iter().enumerate().rev().find_map(|(i, t)| { - let n = match t.as_str() { - "g" => 1, - "rg" => 3, - "k" => 4, - _ => return None, - }; - let start = i.checked_sub(n)?; - toks.get(start..i).is_some_and(|ops| ops.iter().all(|o| numeric(o))).then_some((start, i)) - }); - match at { - Some((start, end)) => { - toks.splice(start..=end, rgb); - } - None => toks.extend(rgb), - } - } - toks.join(" ") -} - -fn patch_widget_look(doc: &mut Document, w: &Widget, patch: &LookPatch) -> Result<(), FormError> { - let touches_mk = patch.border.is_some() || patch.fill.is_some(); - let touches_bs = patch.width.is_some() || patch.style.is_some(); - // A size- or font-only edit changes /DA, not the widget: leave it out of the save. - if !touches_mk && !touches_bs { - return Ok(()); - } - let wd = doc.get(w.obj).as_dict().cloned().ok_or_else(|| FormError::Invalid("the widget is not a dictionary".into()))?; - if touches_mk { - let indirect = wd.get(b"MK").and_then(Object::as_ref); - let mut mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - for (key, change) in [(b"BC".as_slice(), patch.border), (b"BG".as_slice(), patch.fill)] { - if let Some(change) = change { - match change { - Some(c) => mk.set(key.to_vec(), rgb(c)), - None => { - mk.remove(key); - } - } - } - } - // A shared indirect /MK is updated where it lives, not copied into each widget. - match indirect { - Some(r) => doc.set(r, Object::Dict(mk)), - None => doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?, - } - } - if touches_bs { - let indirect = wd.get(b"BS").and_then(Object::as_ref); - let mut bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()).unwrap_or_default(); - if let Some(width) = patch.width { - bs.set(b"W".to_vec(), Object::Real(width)); - } - if let Some(style) = patch.style { - bs.set(b"S".to_vec(), Object::name(style.code())); - if style == BorderStyle::Dashed && !bs.contains(b"D") { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - } - match indirect { - Some(r) => doc.set(r, Object::Dict(bs)), - None => doc.update_dict(w.obj, |d| d.set(b"BS".to_vec(), Object::Dict(bs)))?, - } - } - Ok(()) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BorderStyle { - Solid, - Dashed, - Beveled, - Inset, - Underline, -} - -impl BorderStyle { - pub const ALL: [BorderStyle; 5] = [BorderStyle::Solid, BorderStyle::Dashed, BorderStyle::Beveled, BorderStyle::Inset, BorderStyle::Underline]; - - fn code(self) -> &'static str { - match self { - BorderStyle::Solid => "S", - BorderStyle::Dashed => "D", - BorderStyle::Beveled => "B", - BorderStyle::Inset => "I", - BorderStyle::Underline => "U", - } - } - - pub fn label(self) -> &'static str { - match self { - BorderStyle::Solid => "Solid", - BorderStyle::Dashed => "Dashed", - BorderStyle::Beveled => "Beveled", - BorderStyle::Inset => "Inset", - BorderStyle::Underline => "Underline", - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldFont { - Helvetica, - Times, - Courier, -} - -impl FieldFont { - pub const ALL: [FieldFont; 3] = [FieldFont::Helvetica, FieldFont::Times, FieldFont::Courier]; - - fn resource(self) -> &'static str { - match self { - FieldFont::Helvetica => "Helv", - FieldFont::Times => "TiRo", - FieldFont::Courier => "Cour", - } - } - - pub fn label(self) -> &'static str { - match self { - FieldFont::Helvetica => "Helvetica", - FieldFont::Times => "Times Roman", - FieldFont::Courier => "Courier", - } - } -} - -/// The mark a check box or radio button shows when on (Field Properties ▸ Options ▸ style). -/// Stored as `/MK /CA`, the ZapfDingbats character Acrobat uses for it; PdfCraft draws the -/// mark as paths, so no symbol font is needed. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CheckStyle { - Check, - Circle, - Cross, - Diamond, - Square, - Star, -} - -impl CheckStyle { - pub const ALL: [CheckStyle; 6] = - [CheckStyle::Check, CheckStyle::Circle, CheckStyle::Cross, CheckStyle::Diamond, CheckStyle::Square, CheckStyle::Star]; - - fn code(self) -> &'static str { - match self { - CheckStyle::Check => "4", - CheckStyle::Circle => "l", - CheckStyle::Cross => "8", - CheckStyle::Diamond => "u", - CheckStyle::Square => "n", - CheckStyle::Star => "H", - } - } - - pub fn label(self) -> &'static str { - match self { - CheckStyle::Check => "Check", - CheckStyle::Circle => "Circle", - CheckStyle::Cross => "Cross", - CheckStyle::Diamond => "Diamond", - CheckStyle::Square => "Square", - CheckStyle::Star => "Star", - } - } - - /// A widget's style: its `/MK /CA`, else a check for check boxes and a circle for radios. - pub(crate) fn of_widget(doc: &Document, wd: &Dict, kind: FieldKind) -> Self { - let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); - let ca = mk.get(b"CA").and_then(|c| doc.resolve(c).as_string().map(|s| s.to_text())); - CheckStyle::ALL.into_iter().find(|s| ca.as_deref() == Some(s.code())).unwrap_or(if kind == FieldKind::Radio { - CheckStyle::Circle - } else { - CheckStyle::Check - }) - } -} - -/// A check box's or radio button's style (from its first widget). -pub fn check_style(doc: &Document, f: &Field) -> CheckStyle { - let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); - CheckStyle::of_widget(doc, &wd, f.kind) -} - -fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> { - let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect(); - match v.len() { - 1 => Some([v[0]; 3]), - 3 => Some([v[0], v[1], v[2]]), - 4 => Some([(1.0 - v[0]) * (1.0 - v[3]), (1.0 - v[1]) * (1.0 - v[3]), (1.0 - v[2]) * (1.0 - v[3])]), - _ => None, - } -} - -/// A field's current look (from its first widget and its default appearance). -pub fn look(doc: &Document, f: &Field) -> Look { - let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); - let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); - let bs = wd.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()).unwrap_or_default(); - let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_else(|| f.da.clone()); - let parsed = appearance::parse_da(&da); - let nums: Vec = parsed.color.split_whitespace().filter_map(|t| t.parse().ok()).collect(); - let text = match nums.len() { - 1 => [nums[0]; 3], - 3 => [nums[0], nums[1], nums[2]], - 4 => [(1.0 - nums[0]) * (1.0 - nums[3]), (1.0 - nums[1]) * (1.0 - nums[3]), (1.0 - nums[2]) * (1.0 - nums[3])], - _ => [0.0; 3], - }; - Look { - border: rgb_of(doc, mk.get(b"BC")), - fill: rgb_of(doc, mk.get(b"BG")), - width: bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()).unwrap_or(1.0), - style: match bs.name(b"S") { - Some(b"D") => BorderStyle::Dashed, - Some(b"B") => BorderStyle::Beveled, - Some(b"I") => BorderStyle::Inset, - Some(b"U") => BorderStyle::Underline, - _ => BorderStyle::Solid, - }, - text, - font: match parsed.font.as_str() { - "TiRo" | "Times-Roman" => FieldFont::Times, - "Cour" | "Courier" => FieldFont::Courier, - _ => FieldFont::Helvetica, - }, - } -} - -/// A `/DA` string with the given font, size and colour. -fn da_string(font: FieldFont, size: f64, c: [f64; 3]) -> String { - let f = crate::appearance::fmt; - format!("/{} {} Tf {} {} {} rg", font.resource(), f(size.clamp(0.0, 100.0)), f(c[0]), f(c[1]), f(c[2])) -} - -fn add_to_page(doc: &mut Document, page: ObjRef, widget: ObjRef) -> Result<(), FormError> { - let existing = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()); - match existing.as_ref().and_then(|o| o.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { - Some(r) => { - let mut a = doc.get(r).as_array().cloned().unwrap_or_default(); - a.push(Object::Ref(widget)); - doc.set(r, Object::Array(a)); - } - None => { - let mut a = existing.and_then(|o| o.as_array().cloned()).unwrap_or_default(); - a.push(Object::Ref(widget)); - doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; - } - } - Ok(()) -} - -fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), FormError> { - let mut d = doc.get(af).as_dict().cloned().unwrap_or_default(); - let mut list = d.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - list.push(Object::Ref(field)); - d.set(b"Fields".to_vec(), Object::Array(list)); - doc.set(af, Object::Dict(d)); - Ok(()) -} - -/// The first free "N" name. -fn free_name(existing: &[Field], base: &str) -> String { - (1..=u64::MAX) - .map(|n| format!("{base}{n}")) - .find(|c| !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) - .unwrap_or_else(|| base.to_string()) -} - -fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name("Widget")); - d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"P".to_vec(), Object::Ref(page)); - d.set(b"F".to_vec(), Object::Int(4)); - let mut mk = Dict::new(); - mk.set(b"BC".to_vec(), rgb([0.55, 0.55, 0.55])); - mk.set(b"BG".to_vec(), rgb([1.0, 1.0, 1.0])); - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Int(1)); - bs.set(b"S".to_vec(), Object::name("S")); - d.set(b"BS".to_vec(), Object::Dict(bs)); - d -} - -/// Add a field on `page` (0-based) in `rect` (user space). Returns its full name. -pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewField, name: Option<&str>) -> Result { - let pages = page_refs(doc); - let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?; - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 4.0 || rect[3] - rect[1] < 4.0 { - return invalid("the field is too small"); - } - let existing = fields(doc); - // A radio button names its group: a new group takes that name. - let name = match (name, kind) { - (None, NewField::Radio { group: Some(g), .. }) => Some(g.as_str()), - _ => name, - }; - let name = match name.map(str::trim) { - Some(n) if !n.is_empty() => { - if n.contains('.') { - return invalid("field names can't contain a period"); - } - n.to_string() - } - _ => free_name(&existing, kind.base_name()), - }; - let radio_group = match kind { - NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), - _ => None, - }; - if radio_group.is_none() && existing.iter().any(|f| f.name == name) { - return invalid(format!("a field named {name:?} already exists")); - } - let af = ensure_form(doc)?; - let mut w = widget_dict(page_ref, rect); - let field_name = match kind { - NewField::Radio { group: Some(g), .. } if radio_group.is_some() => g.clone(), - _ => name.clone(), - }; - match kind { - NewField::Text { multiline } => { - w.set(b"FT".to_vec(), Object::name("Tx")); - // Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888). - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - if *multiline { - w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); - } - } - NewField::Date => { - w.set(b"FT".to_vec(), Object::name("Tx")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). - let js = |s: &str| { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(s.as_bytes().to_vec())); - Object::Dict(a) - }; - let mut aa = Dict::new(); - aa.set(b"F".to_vec(), js("AFDate_FormatEx(\"mm/dd/yyyy\");")); - aa.set(b"K".to_vec(), js("AFDate_KeystrokeEx(\"mm/dd/yyyy\");")); - w.set(b"AA".to_vec(), Object::Dict(aa)); - } - NewField::CheckBox => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"V".to_vec(), Object::name("Off")); - w.set(b"AS".to_vec(), Object::name("Off")); - w.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); - } - NewField::Radio { export, .. } => { - w.set(b"AS".to_vec(), Object::name("Off")); - if export.trim().is_empty() || export == "Off" { - return invalid("a radio button needs an export value other than Off"); - } - } - NewField::Combo { options, editable } | NewField::List { options, multi: editable } => { - w.set(b"FT".to_vec(), Object::name("Ch")); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); - let combo = matches!(kind, NewField::Combo { .. }); - let mut ff = 0; - if combo { - ff |= flags::COMBO; - if *editable { - ff |= flags::EDIT; - } - } else if *editable { - ff |= flags::MULTI_SELECT; - } - w.set(b"Ff".to_vec(), Object::Int(ff as i64)); - w.set(b"Opt".to_vec(), Object::Array(options.iter().map(|o| Object::String(PdfString::text(o))).collect())); - } - NewField::Button { caption } => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - mk.set(b"BG".to_vec(), rgb([0.86, 0.86, 0.86])); - mk.set(b"CA".to_vec(), PdfString::text(caption)); - w.set(b"MK".to_vec(), Object::Dict(mk)); - } - NewField::Image => { - w.set(b"FT".to_vec(), Object::name("Btn")); - w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); - w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); - let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - // Icon only, no border or fill: the picture is the field. - mk.remove(b"BC"); - mk.remove(b"BG"); - mk.set(b"TP".to_vec(), Object::Int(1)); - w.set(b"MK".to_vec(), Object::Dict(mk)); - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(b"event.target.buttonImportIcon();".to_vec())); - w.set(b"A".to_vec(), Object::Dict(a)); - } - NewField::Signature => { - w.set(b"FT".to_vec(), Object::name("Sig")); - } - } - let widget = doc.add(Object::Dict(w)); - match kind { - NewField::Radio { export, .. } => { - let group = match &radio_group { - Some(g) => g.obj, - None => { - let mut g = Dict::new(); - g.set(b"FT".to_vec(), Object::name("Btn")); - g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); - g.set(b"T".to_vec(), PdfString::text(&field_name)); - g.set(b"V".to_vec(), Object::name("Off")); - g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); - g.set(b"Kids".to_vec(), Object::Array(Vec::new())); - let g = doc.add(Object::Dict(g)); - add_to_fields(doc, af, g)?; - g - } - }; - doc.update_dict(widget, |d| d.set(b"Parent".to_vec(), Object::Ref(group)))?; - doc.update_dict(group, |d| { - let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.push(Object::Ref(widget)); - d.set(b"Kids".to_vec(), Object::Array(kids)); - })?; - let w = Widget { - obj: widget, - page: Some(page), - rect, - on_state: Some(export.clone()), - state: Some("Off".into()), - tab: usize::MAX, - locked: false, - hidden: false, - rotation: 0, - caption: None, - list: None, - }; - let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); - doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; - } - _ => { - doc.update_dict(widget, |d| d.set(b"T".to_vec(), PdfString::text(&name)))?; - add_to_fields(doc, af, widget)?; - } - } - add_to_page(doc, page_ref, widget)?; - redraw_field(doc, &field_name)?; - Ok(field_name) -} - -/// Regenerate every widget appearance of a field from its current value and settings. A value or -/// button caption its font can't hold is drawn with `?` for those characters, the widget is marked -/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see -/// `ask_viewers_to_redraw`), as when a value is set. -pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { - redraw_widgets(doc, name, None) -} - -fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let mut undrawn = false; - for (index, w) in f.widgets.iter().enumerate() { - if only.is_some_and(|only| only != index) { - continue; - } - let mut missing = None; - let ap = match f.kind { - FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); - missing = m; - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - FieldKind::CheckBox | FieldKind::Radio => { - let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); - appearance::check_box_states(doc, w, f.kind, &on) - } - FieldKind::PushButton => { - let (s, m) = button_appearance(doc, w); - missing = m; - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - FieldKind::Signature => { - let s = empty_box(doc, w); - let r = doc.add(Object::Stream(s)); - let mut d = Dict::new(); - d.set(b"N".to_vec(), Object::Ref(r)); - d - } - }; - undrawn |= missing.is_some(); - let existing = crate::merged_appearance(doc, w.obj, &ap); - doc.update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(existing)); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - } - if undrawn { - crate::ask_viewers_to_redraw(doc)?; - } - Ok(()) -} - -fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) { - let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); - let wobj = doc.get(w.obj); - let wd = wobj.as_dict().cloned().unwrap_or_default(); - let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h); - let (width, height) = (place.layout_w, place.layout_h); - let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); - let col = |k: &[u8]| -> Option { - let v: Vec = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); - (v.len() == 3).then(|| format!("{} {} {}", crate::appearance::fmt(v[0]), crate::appearance::fmt(v[1]), crate::appearance::fmt(v[2]))) - }; - let mut c = String::new(); - if let Some(bg) = col(b"BG") { - c.push_str(&format!("{bg} rg 0 0 {width:.3} {height:.3} re f\n")); - } - if let Some(bc) = col(b"BC") { - c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0)); - } - (c, place) -} - -fn form_stream(place: crate::appearance::Placement, content: Vec, resources: Dict) -> Stream { - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - crate::appearance::set_form_box(&mut d, place); - d.set(b"Resources".to_vec(), Object::Dict(resources)); - Stream::flate(d, &content) -} - -/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its -/// centred caption (unless the layout is icon only, `/TP 1`). The caption is drawn in Helvetica, -/// with the first character it can't draw (drawn as `?`), for the caller to mark and report. -fn button_appearance(doc: &Document, w: &Widget) -> (Stream, Option) { - let (mut c, place) = frame_only(doc, w); - let (width, height) = (place.layout_w, place.layout_h); - let wobj = doc.get(w.obj); - let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default(); - let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1); - let mut content = std::mem::take(&mut c).into_bytes(); - let mut xobjects = Dict::new(); - if let Some(icon) = mk.get(b"I").and_then(Object::as_ref) - && let Object::Stream(s) = &*doc.get(icon) - { - let bb: Vec = s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if let [x0, y0, x1, y1] = bb[..] - && x1 > x0 - && y1 > y0 - { - // Proportional, as large as fits (Acrobat's default icon placement), centred. - let k = ((width - 2.0) / (x1 - x0)).min((height - 2.0) / (y1 - y0)).max(0.0); - let (dx, dy) = ((width - (x1 - x0) * k) / 2.0 - x0 * k, (height - (y1 - y0) * k) / 2.0 - y0 * k); - content.extend(format!("q {k:.6} 0 0 {k:.6} {dx:.3} {dy:.3} cm /Icon Do Q\n").bytes()); - xobjects.set(b"Icon".to_vec(), Object::Ref(icon)); - } - } - let mut missing = None; - if !icon_only && !caption.is_empty() { - missing = pdfcraft_fonts::first_undrawable(&caption); - let size = ((height - 4.0) * 0.6).clamp(4.0, 14.0); - let tw = helvetica_width(&caption, size); - content.extend(format!("BT /Helv {size:.2} Tf 0 g 1 0 0 1 {:.3} {:.3} Tm ", (width - tw) / 2.0, (height - size * 0.7) / 2.0).bytes()); - content.extend(literal(&win_ansi(&caption))); - content.extend_from_slice(b" Tj ET\n"); - } - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); - let mut res = Dict::new(); - res.set(b"Font".to_vec(), Object::Dict(fonts)); - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } - (form_stream(place, content, res), missing) -} - -/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): -/// it becomes the button's icon (`/MK /I`) and its appearance. -pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - if f.kind != FieldKind::PushButton { - return invalid(format!("{name} is not a button or image field")); - } - // The icon form draws the image in a box of its pixel size (keeping its aspect ratio). - let (w, h) = (px.0.max(1) as f64, px.1.max(1) as f64); - let mut xo = Dict::new(); - xo.set(b"Im0".to_vec(), Object::Ref(image)); - let mut res = Dict::new(); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let icon = - doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); - for wd in &f.widgets { - let mut mk = - doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); - mk.set(b"I".to_vec(), Object::Ref(icon)); - if !mk.contains(b"TP") { - mk.set(b"TP".to_vec(), Object::Int(1)); - } - doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - redraw_field(doc, name) -} - -fn empty_box(doc: &Document, w: &Widget) -> Stream { - let (c, place) = frame_only(doc, w); - form_stream(place, c.into_bytes(), Dict::new()) -} - -/// Swap width and height, keeping the center. `None` when that is not a usable widget. -fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { - if !r.iter().all(|v| v.is_finite()) { - return None; - } - let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); - let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); - let (nw, nh) = (y1 - y0, x1 - x0); - let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; - (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out) -} - -/// Change a field's properties (General and Options tabs) and redraw it. -pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { - if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { - return invalid("font size must be a finite, non-negative number"); - } - if let Some(patch) = &props.appearance { - if props.look.is_some() { - return invalid("give an appearance patch or a complete look, not both"); - } - patch.validate()?; - } - let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); - // Refuse a wrong type/index before any other requested property is written. - if let Some((wi, _)) = &props.caption { - if f.kind != FieldKind::PushButton { - return invalid("only push buttons have a caption"); - } - if f.widgets.get(*wi).is_none() { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - } - } - // Validated before anything is written, so a refused rotation leaves the document unchanged. - // `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle. - let mut rotated_rect: Option> = None; - if let Some((wi, rot)) = props.rotation { - if !matches!(rot, 0 | 90 | 180 | 270) { - return invalid("rotation must be 0, 90, 180 or 270 degrees"); - } - let Some(w) = f.widgets.get(wi) else { - return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); - }; - let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); - rotated_rect = Some(None); - if axis_change && props.rect.is_none() { - let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; - rotated_rect = Some(Some(rect)); - } - } - // A locked field only takes unlocking (Acrobat greys its properties out). - if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { - return invalid(format!("{name} is locked: unlock it first")); - } - if let Some(lock) = props.locked { - for w in &f.widgets { - let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0); - let v = if lock { flags | 128 } else { flags & !128 }; - doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(v)))?; - } - if *props == (FieldProps { locked: props.locked, ..FieldProps::default() }) { - return Ok(name.to_string()); - } - } - if let Some(acts) = &props.actions { - crate::set_field_actions(doc, name, acts)?; - } - let mut new_name = name.to_string(); - if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { - if n.is_empty() || n.contains('.') { - return invalid("field names can't be empty or contain a period"); - } - if all.iter().any(|x| x.name == n) { - return invalid(format!("a field named {n:?} already exists")); - } - // Rename the terminal part (the field's own /T). - let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - new_name = format!("{prefix}{n}"); - doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; - } - // The new default appearance when the size or the look changes. - let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); - let new_da = match &props.look { - Some(l) => da_string(l.font, size, l.text), - None => patch_da(&f.da, props.font_size, &props.appearance.unwrap_or_default()), - }; - if props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some()) { - ensure_form(doc)?; - } - let mut ff = f.flags; - let mut set_flag = |flag: u32, on: Option| { - if let Some(on) = on { - if on { - ff |= flag; - } else { - ff &= !flag; - } - } - }; - set_flag(flags::READ_ONLY, props.read_only); - set_flag(flags::REQUIRED, props.required); - if f.kind == FieldKind::Text { - set_flag(flags::MULTILINE, props.multiline); - } - for (flag, on) in &props.flags { - set_flag(*flag, Some(*on)); - } - // A comb spreads a fixed number of characters across the field: it needs a limit and - // excludes multi-line, password, file selection and scrolling (ISO 32000-2 Table 229). - if f.kind == FieldKind::Text && ff & flags::COMB != 0 { - let limit = match props.max_len { - Some(m) => m, - None => f.max_len, - }; - if limit.is_none_or(|m| m == 0) { - return invalid("a comb of characters needs a character limit"); - } - ff &= !(flags::MULTILINE | flags::PASSWORD | flags::FILE_SELECT); - } - if let Some(q) = props.quadding - && !(0..=2).contains(&q) - { - return invalid("alignment is 0 (left), 1 (centre) or 2 (right)"); - } - if props.options.as_ref().is_some_and(|o| o.is_empty()) && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - return invalid("a list needs at least one option"); - } - if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { - return invalid("only check boxes and radio buttons have a check style"); - } - doc.update_dict(f.obj, |d| { - d.set(b"Ff".to_vec(), Object::Int(ff as i64)); - if let Some(t) = &props.tooltip { - if t.is_empty() { - d.remove(b"TU"); - } else { - d.set(b"TU".to_vec(), PdfString::text(t)); - } - } - if let Some(m) = props.max_len { - match m { - Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), - None => { - d.remove(b"MaxLen"); - } - } - } - if let Some(opts) = &props.options { - d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); - } - // "Sort items": the list itself is kept in order (export values stay with their items). - if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - let mut items: Vec<(String, String)> = match &props.options { - Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(), - None => f.options.clone(), - }; - items.sort_by_key(|(_, display)| display.to_lowercase()); - let opt = items - .iter() - .map(|(e, disp)| { - if e == disp { - Object::String(PdfString::text(disp)) - } else { - Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) - } - }) - .collect(); - d.set(b"Opt".to_vec(), Object::Array(opt)); - } - if let Some(q) = props.quadding { - d.set(b"Q".to_vec(), Object::Int(q)); - } - match &props.default_value { - // Buttons name their default state; text and choices hold a string. - Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), - Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), - Some(None) => { - d.remove(b"DV"); - } - None => {} - } - if props.font_size.is_some() || props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some() || p.text.is_some()) { - d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); - } - })?; - if props.appearance.is_some() || (props.font_size.is_some() && props.look.is_none()) { - let patch = &props.appearance.unwrap_or_default(); - for w in &f.widgets { - patch_widget_look(doc, w, patch)?; - // A widget's own /DA overrides its field's /DA. Patch that override separately - // rather than replacing its font or colour with the first widget's defaults. - if w.obj != f.obj && (props.font_size.is_some() || patch.font.is_some() || patch.text.is_some()) { - let da = doc - .get(w.obj) - .as_dict() - .and_then(|d| d.get(b"DA").cloned()) - .map(|v| doc.resolve(&v)) - .and_then(|v| v.as_string().map(|s| s.to_text())); - if let Some(da) = da { - let da = patch_da(&da, props.font_size, patch); - doc.update_dict(w.obj, |d| d.set(b"DA".to_vec(), PdfString::literal(da.into_bytes())))?; - } - } - } - } - if let Some(l) = &props.look { - let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its mark, rotation and other entries. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - doc.update_dict(w.obj, |d| { - match l.border { - Some(c) => mk.set(b"BC".to_vec(), arr(c)), - None => { - mk.remove(b"BC"); - } - } - match l.fill { - Some(c) => mk.set(b"BG".to_vec(), arr(c)), - None => { - mk.remove(b"BG"); - } - } - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); - bs.set(b"S".to_vec(), Object::name(l.style.code())); - if l.style == BorderStyle::Dashed { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - d.set(b"BS".to_vec(), Object::Dict(bs)); - })?; - } - } - if let Some(style) = props.check_style { - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its colours. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(style.code())); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - } - if let Some((wi, r)) = props.rect { - let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; - let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; - if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < 4.0 || r[3] - r[1] < 4.0 { - return invalid("the field is too small"); - } - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; - } - if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { - if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { - return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); - } - let mut aa = doc - .get(f.obj) - .as_dict() - .and_then(|d| d.get(b"AA").cloned()) - .map(|a| doc.resolve(&a)) - .and_then(|a| a.as_dict().cloned()) - .unwrap_or_default(); - let action = |js: &str| -> Object { - let mut a = Dict::new(); - a.set(b"S".to_vec(), Object::name("JavaScript")); - a.set(b"JS".to_vec(), PdfString::literal(js.as_bytes().to_vec())); - Object::Dict(a) - }; - if let Some(fm) = &props.format { - aa.remove(b"F"); - aa.remove(b"K"); - if let Some((fjs, kjs)) = crate::af::format_js(fm) { - if !fjs.is_empty() { - aa.set(b"F".to_vec(), action(&fjs)); - } - aa.set(b"K".to_vec(), action(&kjs)); - } - } - if let Some(v) = &props.validate { - aa.remove(b"V"); - if let Some(js) = crate::af::validate_js(v) { - aa.set(b"V".to_vec(), action(&js)); - } - } - if let Some(c) = &props.calculate { - aa.remove(b"C"); - if let Some(js) = crate::af::calculate_js(c) { - aa.set(b"C".to_vec(), action(&js)); - } - // The calculation order lists calculated fields. - let af_ref = ensure_form(doc)?; - let mut co: Vec = doc - .get(af_ref) - .as_dict() - .and_then(|d| d.get(b"CO").cloned()) - .map(|c| doc.resolve(&c)) - .and_then(|c| c.as_array().cloned()) - .unwrap_or_default(); - co.retain(|o| o.as_ref() != Some(f.obj)); - if *c != crate::af::Calculate::None { - co.push(Object::Ref(f.obj)); - } - doc.update_dict(af_ref, |d| { - if co.is_empty() { - d.remove(b"CO"); - } else { - d.set(b"CO".to_vec(), Object::Array(co)); - } - })?; - } - doc.update_dict(f.obj, |d| { - if aa.is_empty() { - d.remove(b"AA"); - } else { - d.set(b"AA".to_vec(), Object::Dict(aa)); - } - })?; - } - // A complete look replaces widget /DA overrides. Partial changes were patched above - // against each override, including size-only changes that keep its font and colour. - if props.look.is_some() { - for w in &f.widgets { - if w.obj != f.obj { - doc.update_dict(w.obj, |d| { - if d.contains(b"DA") { - d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); - } - })?; - } - } - } - if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) - && let Some(w) = f.widgets.get(wi) - { - if let Some(rect) = swapped { - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; - } - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - if rot == 0 { - mk.remove(b"R"); - } else { - mk.set(b"R".to_vec(), Object::Int(rot)); - } - doc.update_dict(w.obj, |d| { - if mk.is_empty() { - d.remove(b"MK"); - } else { - d.set(b"MK".to_vec(), Object::Dict(mk)); - } - })?; - } - if let Some((wi, caption)) = &props.caption - && let Some(w) = f.widgets.get(*wi) - { - // Copy an indirect/shared MK before patching it; colours, icons, rotation and - // unknown entries belong to this widget, and other owners keep their old label. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(caption)); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - // A label-only edit must not replace another widget's custom appearances. - let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi); - redraw_widgets(doc, &new_name, only)?; - if props.calculate.is_some() { - crate::recalculate(doc)?; - } - Ok(new_name) -} - -/// Delete a field: its widgets leave their pages and the field leaves the form. -pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { - let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); - let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); - for p in page_refs(doc) { - let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; - let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); - let kept: Vec = list.iter().filter(|o| !o.as_ref().is_some_and(|r| widgets.contains(&r))).cloned().collect(); - if kept.len() != list.len() { - match a.as_ref() { - Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), - _ => doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?, - } - } - } - // Out of the field tree: from its parent's /Kids, or from /Fields. - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { - let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); - d.set(key.to_vec(), Object::Array(kept)); - doc.set(holder, Object::Dict(d)); - Ok(()) - }; - match parent { - Some(p) => remove_from(doc, p, b"Kids")?, - None => { - let root = doc.root().ok_or(FormError::NoForm)?; - match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { - Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, - // A form dictionary held directly in the catalog. - Some(Object::Dict(mut af)) => { - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); - af.set(b"Fields".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - } - } - Ok(()) -} - -/// Widget-only keys: they stay with each widget when a merged field is split. -const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"]; - -/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page -/// gets a widget at the same place, belonging to the same field, so they share one value. -/// A field that is its own widget is first split into a field and a widget kid. Returns how -/// many widgets were added (pages that already have one are skipped). -pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Result { - let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let model = f.widgets.first().cloned().ok_or_else(|| FormError::Invalid(format!("{name} has no widget")))?; - let refs = page_refs(doc); - for p in pages { - if *p >= refs.len() { - return invalid(format!("page {} does not exist", p + 1)); - } - } - let have: std::collections::HashSet = f.widgets.iter().filter_map(|w| w.page).collect(); - let targets: Vec = pages.iter().copied().filter(|p| !have.contains(p)).collect(); - if targets.is_empty() { - return Ok(0); - } - // A merged field/widget becomes a field with one widget kid. - let mut model_ref = model.obj; - if model.obj == f.obj { - let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); - let mut kid = Dict::new(); - for k in WIDGET_KEYS { - if let Some(v) = d.get(k) { - kid.set(k.to_vec(), v.clone()); - } - } - kid.set(b"Parent".to_vec(), Object::Ref(f.obj)); - let kr = doc.add(Object::Dict(kid)); - doc.update_dict(f.obj, |d| { - for k in WIDGET_KEYS { - d.remove(k); - } - d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); - })?; - // The page lists the widget now, not the field. - if let Some(p) = model.page { - let pr = refs[p]; - let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); - let swap = - |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; - match annots { - Some(Object::Ref(ar)) => { - let a = doc.get(ar).as_array().cloned().unwrap_or_default(); - doc.set(ar, Object::Array(swap(a))); - } - Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, - _ => {} - } - } - model_ref = kr; - } - let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default(); - let mut added = Vec::new(); - for p in &targets { - let pr = refs[*p]; - let mut w = template.clone(); - w.set(b"P".to_vec(), Object::Ref(pr)); - w.set(b"Parent".to_vec(), Object::Ref(f.obj)); - w.remove(b"StructParent"); - let wr = doc.add(Object::Dict(w)); - let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); - match annots { - Some(Object::Ref(ar)) => { - let mut a = doc.get(ar).as_array().cloned().unwrap_or_default(); - a.push(Object::Ref(wr)); - doc.set(ar, Object::Array(a)); - } - other => { - let mut a = other.and_then(|o| o.as_array().cloned()).unwrap_or_default(); - a.push(Object::Ref(wr)); - doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; - } - } - added.push(wr); - } - doc.update_dict(f.obj, |d| { - let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.extend(added.iter().map(|r| Object::Ref(*r))); - d.set(b"Kids".to_vec(), Object::Array(kids)); - })?; - redraw_field(doc, name)?; - Ok(added.len()) -} diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs new file mode 100644 index 0000000000..2d05600ae2 --- /dev/null +++ b/crates/forms/src/author/edit.rs @@ -0,0 +1,615 @@ +use super::join::{check_join, join_radio_group}; +use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets}; +use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; +use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; + +/// Swap width and height, keeping the center. `None` when that is not a usable widget. +fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { + if !r.iter().all(|v| v.is_finite()) { + return None; + } + let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let (nw, nh) = (y1 - y0, x1 - x0); + let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; + (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) +} + +/// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was +/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept` +/// are the old `/I` positions carried into the new list, which tell apart options sharing an +/// export value; a selected value none of them covers takes its first position, by `set_value`'s +/// rule. Returns them. +fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String], kept: &[usize]) -> Vec { + let mut indices: Vec = kept.iter().copied().filter(|&i| exports.get(i).is_some_and(|e| values.contains(e))).collect(); + for v in values { + if !indices.iter().any(|&i| exports.get(i) == Some(v)) { + indices.extend(exports.iter().position(|e| e == v)); + } + } + indices.sort_unstable(); + indices.dedup(); + if indices.is_empty() { + d.remove(b"I"); + } else { + d.set(b"I".to_vec(), Object::Array(indices.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect())); + } + indices +} + +/// The positions in `/Opt` that a choice field's `/I` holds, those below `count`. +fn selected_indices(doc: &Document, field: ObjRef, count: usize) -> Vec { + let Some(i) = doc.get(field).as_dict().and_then(|d| d.get(b"I").cloned()) else { + return Vec::new(); + }; + let positions = doc.resolve(&i); + positions.as_array().map(|a| a.iter().filter_map(|o| usize::try_from(o.as_int()?).ok()).filter(|&n| n < count).collect()).unwrap_or_default() +} + +/// The items (export value, display text) "Sort items" orders: the new `props.options`, or else +/// the field's own, whose selection, `/I` included, moves with them. +fn items_to_sort(doc: &Document, f: &Field, props: &FieldProps) -> (Vec<(String, String)>, Vec) { + match &props.options { + Some(o) => (o.iter().map(|x| (x.clone(), x.clone())).collect(), Vec::new()), + None => (f.options.clone(), selected_indices(doc, f.obj, f.options.len())), + } +} + +/// Sort a choice field's items (export value, display text) by display text, ignoring case, and +/// write them as `/Opt`; export values stay with their items. Returns the export values in their +/// new order and the `old` positions carried through the sort. +fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) -> (Vec, Vec) { + let mut order: Vec = (0..items.len()).collect(); + order.sort_by_key(|&i| items.get(i).map(|(_, display)| display.to_lowercase())); + let sorted: Vec<&(String, String)> = order.iter().filter_map(|&i| items.get(i)).collect(); + let opt = sorted + .iter() + .map(|(e, disp)| { + if e == disp { + Object::String(PdfString::text(disp)) + } else { + Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) + } + }) + .collect(); + d.set(b"Opt".to_vec(), Object::Array(opt)); + let carried = old.iter().filter_map(|o| order.iter().position(|i| i == o)).collect(); + (sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried) +} + +/// Build /Opt before modifying the document, preserving appearance states and selection. +fn button_export_options(f: &Field, index: usize, value: &str) -> Result, FormError> { + if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { + return invalid("only check boxes and radio buttons have an export value"); + } + if value.is_empty() || value == "Off" || value.len() > 4096 { + return invalid("export value must be non-empty, at most 4096 bytes, and not Off"); + } + let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?; + let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + if !f.options.is_empty() && f.options.len() != f.widgets.len() { + return invalid("button export options do not match its widgets"); + } + let previous = f.export_of(index); + let mut out = Vec::with_capacity(f.widgets.len()); + for (i, w) in f.widgets.iter().enumerate() { + let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + let same_state = w.on_state.as_deref() == Some(state); + if !same_state && (export == value || w.on_state.as_deref() == Some(value)) { + return invalid("export value conflicts with another button choice"); + } + if same_state && Some(export) != previous { + return invalid("widgets with the same appearance state have conflicting export values"); + } + out.push(Object::String(PdfString::text(if same_state { value } else { export }))); + } + Ok(out) +} + +/// Change a field's properties (General and Options tabs) and redraw it. +pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { + if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { + return invalid("font size must be a finite, non-negative number"); + } + if let Some(patch) = &props.appearance { + if props.look.is_some() { + return invalid("give an appearance patch or a complete look, not both"); + } + patch.validate()?; + } + // Refused here, before anything is written, rather than stored as a different formula. + if let Some(crate::af::Calculate::Notation(n)) = &props.calculate + && !crate::af::notation_storable(n) + { + return invalid(crate::af::NOTATION_NOT_STORABLE); + } + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); + let export_options = props.export_value.as_ref().map(|(index, value)| button_export_options(&f, *index, value)).transpose()?; + if props.export_value.is_some() && props.options.is_some() { + return invalid("give export_value or options, not both"); + } + // Refuse a wrong type/index before any other requested property is written. + if let Some((wi, _)) = &props.caption { + if f.kind != FieldKind::PushButton { + return invalid("only push buttons have a caption"); + } + if f.widgets.get(*wi).is_none() { + return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); + } + } + // Validated before anything is written, so a refused rotation leaves the document unchanged. + // `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle. + let mut rotated_rect: Option> = None; + if let Some((wi, rot)) = props.rotation { + if !matches!(rot, 0 | 90 | 180 | 270) { + return invalid("rotation must be 0, 90, 180 or 270 degrees"); + } + let Some(w) = f.widgets.get(wi) else { + return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); + }; + let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); + rotated_rect = Some(None); + if axis_change && props.rect.is_none() { + let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; + rotated_rect = Some(Some(rect)); + } + } + // A locked field only takes unlocking (Acrobat greys its properties out). + if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { + return invalid(format!("{name} is locked: unlock it first")); + } + if let Some(options) = export_options.as_ref() + && *props == (FieldProps { export_value: props.export_value.clone(), ..FieldProps::default() }) + { + doc.update_dict(f.obj, |d| d.set(b"Opt".to_vec(), Object::Array(options.clone())))?; + return Ok(name.to_string()); + } + if let Some(lock) = props.locked { + for w in &f.widgets { + let flags = doc.get(w.obj).as_dict().and_then(|d| d.get(b"F").and_then(|v| doc.resolve(v).as_int())).unwrap_or(0); + let v = if lock { flags | 128 } else { flags & !128 }; + doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(v)))?; + } + if *props == (FieldProps { locked: props.locked, ..FieldProps::default() }) { + return Ok(name.to_string()); + } + } + if let Some(acts) = &props.actions { + crate::set_field_actions(doc, name, acts)?; + } + // A radio button renamed to another radio group's name joins that group (#4949). The other + // properties are written first, to this field, and its buttons then move. + let (new_name, join) = rename_field(doc, &all, &f, name, props)?; + // The new default appearance when the size or the look changes. + let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); + let new_da = match &props.look { + Some(l) => da_string(l.font, size, l.text), + None => patch_da(&f.da, props.font_size, &props.appearance.unwrap_or_default()), + }; + if props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some()) { + ensure_form(doc)?; + } + let mut ff = f.flags; + let mut set_flag = |flag: u32, on: Option| { + if let Some(on) = on { + if on { + ff |= flag; + } else { + ff &= !flag; + } + } + }; + set_flag(flags::READ_ONLY, props.read_only); + set_flag(flags::REQUIRED, props.required); + if f.kind == FieldKind::Text { + set_flag(flags::MULTILINE, props.multiline); + } + for (flag, on) in &props.flags { + set_flag(*flag, Some(*on)); + } + // A comb spreads a fixed number of characters across the field: it needs a limit and + // excludes multi-line, password, file selection and scrolling (ISO 32000-2 Table 229). + if f.kind == FieldKind::Text && ff & flags::COMB != 0 { + let limit = match props.max_len { + Some(m) => m, + None => f.max_len, + }; + if limit.is_none_or(|m| m == 0) { + return invalid("a comb of characters needs a character limit"); + } + ff &= !(flags::MULTILINE | flags::PASSWORD | flags::FILE_SELECT); + } + if let Some(q) = props.quadding + && !(0..=2).contains(&q) + { + return invalid("alignment is 0 (left), 1 (centre) or 2 (right)"); + } + if props.options.as_ref().is_some_and(Vec::is_empty) && matches!(f.kind, FieldKind::Combo | FieldKind::List) { + return invalid("a list needs at least one option"); + } + if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { + return invalid("only check boxes and radio buttons have a check style"); + } + let mut list_picked = None; + let (sort_items, old_selection) = items_to_sort(doc, &f, props); + let parent_default = ancestor_sets(doc, f.obj, b"DV"); + doc.update_dict(f.obj, |d| { + d.set(b"Ff".to_vec(), Object::Int(ff as i64)); + if let Some(t) = &props.tooltip { + if t.is_empty() { + d.remove(b"TU"); + } else { + d.set(b"TU".to_vec(), PdfString::text(t)); + } + } + if let Some(m) = props.max_len { + match m { + Some(m) => d.set(b"MaxLen".to_vec(), Object::Int(m as i64)), + None => { + d.remove(b"MaxLen"); + } + } + } + // The export values in `/Opt` order once it is rewritten, for re-pointing `/I`. + let mut new_exports: Option> = None; + let mut kept = Vec::new(); + if let Some(options) = &export_options { + d.set(b"Opt".to_vec(), Object::Array(options.clone())); + } + if let Some(opts) = &props.options { + d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); + new_exports = Some(opts.clone()); + } + // "Sort items": the list itself is kept in order (export values stay with their items). + if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { + let (exports, carried) = write_sorted_opt(d, &sort_items, &old_selection); + new_exports = Some(exports); + kept = carried; + } + if let Some(exports) = &new_exports + && matches!(f.kind, FieldKind::Combo | FieldKind::List) + { + list_picked = Some((repoint_selection(d, &f.value, exports, &kept), exports.len())); + } + if let Some(q) = props.quadding { + d.set(b"Q".to_vec(), Object::Int(q)); + } + match &props.default_value { + // Buttons name their default state; text and choices hold a string. + Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), + Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), + // `/DV` is inheritable: with an ancestor's in place, removing the field's own would + // bring that one back, so the field keeps an empty default of its own instead. + Some(None) if parent_default && matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => { + d.set(b"DV".to_vec(), Object::Name(b"Off".to_vec())) + } + Some(None) if parent_default => d.set(b"DV".to_vec(), PdfString::text("")), + Some(None) => { + d.remove(b"DV"); + } + None => {} + } + if props.font_size.is_some() || props.look.is_some() || props.appearance.is_some_and(|p| p.font.is_some() || p.text.is_some()) { + d.set(b"DA".to_vec(), PdfString::literal(new_da.clone().into_bytes())); + } + })?; + if props.appearance.is_some() || (props.font_size.is_some() && props.look.is_none()) { + let patch = &props.appearance.unwrap_or_default(); + for w in &f.widgets { + patch_widget_look(doc, w, patch)?; + // A widget's own /DA overrides its field's /DA. Patch that override separately + // rather than replacing its font or colour with the first widget's defaults. + if w.obj != f.obj && (props.font_size.is_some() || patch.font.is_some() || patch.text.is_some()) { + let da = doc + .get(w.obj) + .as_dict() + .and_then(|d| d.get(b"DA").cloned()) + .map(|v| doc.resolve(&v)) + .and_then(|v| v.as_string().map(|s| s.to_text())); + if let Some(da) = da { + let da = patch_da(&da, props.font_size, patch); + doc.update_dict(w.obj, |d| d.set(b"DA".to_vec(), PdfString::literal(da.into_bytes())))?; + } + } + } + } + apply_widget_style_props(doc, &f, name, props)?; + if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { + if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { + return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); + } + let mut aa = doc + .get(f.obj) + .as_dict() + .and_then(|d| d.get(b"AA").cloned()) + .map(|a| doc.resolve(&a)) + .and_then(|a| a.as_dict().cloned()) + .unwrap_or_default(); + let action = |js: &str| -> Object { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(js.as_bytes().to_vec())); + Object::Dict(a) + }; + if let Some(fm) = &props.format { + aa.remove(b"F"); + aa.remove(b"K"); + if let Some((fjs, kjs)) = crate::af::format_js(fm) { + if !fjs.is_empty() { + aa.set(b"F".to_vec(), action(&fjs)); + } + aa.set(b"K".to_vec(), action(&kjs)); + } + } + if let Some(v) = &props.validate { + aa.remove(b"V"); + if let Some(js) = crate::af::validate_js(v) { + aa.set(b"V".to_vec(), action(&js)); + } + } + if let Some(c) = &props.calculate { + aa.remove(b"C"); + if let Some(js) = crate::af::calculate_js(c) { + aa.set(b"C".to_vec(), action(&js)); + } + crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?; + } + doc.update_dict(f.obj, |d| { + if aa.is_empty() { + d.remove(b"AA"); + } else { + d.set(b"AA".to_vec(), Object::Dict(aa)); + } + })?; + } + let only = apply_late_widget_props(doc, &f, props, &new_da, rotated_rect)?; + scroll_list(doc, f.obj, list_picked)?; + redraw_or_join(doc, &f, name, &new_name, join.as_ref(), only)?; + if props.calculate.is_some() { + crate::recalculate(doc)?; + } + Ok(new_name) +} + +/// Apply a new name from `props`: rename the field, move it under the parents a full name +/// names, or, for a radio button named after another radio group, pick that group to join. +/// Returns the field's new full name and the group it joins. +fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result<(String, Option), FormError> { + let mut new_name = name.to_string(); + let mut join = None; + if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { + // A full name under the same parents only renames the field's own part (the editor + // shows nested fields by their full name): it isn't moved, so it keeps its place and + // goes on inheriting from its parents. + let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) { + (Some((parents, own)), Some((current, _))) if parents == current => own, + _ => n, + }; + let full = match f.name.rsplit_once('.') { + Some((parents, _)) if !n.contains('.') => format!("{parents}.{n}"), + _ => n.to_string(), + }; + let group = all.iter().find(|x| x.obj != f.obj && x.kind == FieldKind::Radio && x.name == full); + if let Some(group) = group.filter(|_| f.kind == FieldKind::Radio) { + check_join(doc, f, group)?; + join = Some(group.clone()); + new_name = full; + } else if n.contains('.') { + // A full name: the field moves under the parent fields it names (#2696). + let parts = name_parts(n)?; + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { + return invalid(why); + } + let af = ensure_form(doc)?; + // Checked before anything changes, so a refused move leaves the field as it was. + let add = inherited(doc, f.obj); + check_place(doc, af, f.obj, &parts, &add)?; + // From here a failure (a write that `check_place` couldn't foresee) would leave the + // field half moved in `doc`. The engine runs every edit on a copy of the document and + // keeps it only when the edit succeeds (`Session::apply`), so the open document never + // sees that. + own_inherited(doc, f.obj, &add)?; + detach(doc, f.obj)?; + attach(doc, af, f.obj, &parts)?; + new_name = n.to_string(); + } else { + if n.is_empty() { + return invalid("field names can't be empty"); + } + // Rename the terminal part (the field's own /T). The parents' part stays, so the name + // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO + // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. + let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + new_name = format!("{prefix}{n}"); + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { + return invalid(why); + } + // A node of that name beside it that isn't a terminal field, such as a parent + // field left empty by a move. + let af = ensure_form(doc)?; + let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); + if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { + return invalid(format!("a field named {new_name:?} already exists")); + } + doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; + } + // Calculations read the field by its new name, unless another field keeps the old one. + if all.iter().filter(|x| x.name == f.name).count() == 1 { + rename_in_calculations(doc, all, &f.name, &new_name)?; + } + } + Ok((new_name, join)) +} + +fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> { + if let Some(l) = &props.look { + let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its mark, rotation and other entries. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + doc.update_dict(w.obj, |d| { + match l.border { + Some(c) => mk.set(b"BC".to_vec(), arr(c)), + None => { + mk.remove(b"BC"); + } + } + match l.fill { + Some(c) => mk.set(b"BG".to_vec(), arr(c)), + None => { + mk.remove(b"BG"); + } + } + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); + bs.set(b"S".to_vec(), Object::name(l.style.code())); + if l.style == BorderStyle::Dashed { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + d.set(b"BS".to_vec(), Object::Dict(bs)); + })?; + } + } + if let Some(style) = props.check_style { + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its colours. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + mk.set(b"CA".to_vec(), PdfString::text(style.code())); + doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + } + if let Some((wi, r)) = props.rect { + let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; + let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { + return invalid("the field is too small"); + } + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; + } + Ok(()) +} + +/// Redraw the edited widgets. A radio button joining another group (`join`) is redrawn under its +/// old name `name` first; then its buttons move, and only they are redrawn as `new_name`, so the +/// group's own buttons keep their appearances. +fn redraw_or_join(doc: &mut Document, f: &Field, name: &str, new_name: &str, join: Option<&Field>, only: Option) -> Result<(), FormError> { + if let Some(group) = join { + redraw_widgets(doc, name, only)?; + join_radio_group(doc, f, group)?; + let moved: Vec = fields(doc) + .into_iter() + .find(|g| g.name == new_name) + .map(|g| g.widgets.iter().enumerate().filter(|(_, w)| f.widgets.iter().any(|m| m.obj == w.obj)).map(|(i, _)| i).collect()) + .unwrap_or_default(); + for index in moved { + redraw_widgets(doc, new_name, Some(index))?; + } + Ok(()) + } else { + redraw_widgets(doc, new_name, only) + } +} + +/// A list box scrolls, as for a new value, so its first row is still an option and the +/// selection is in view. Its rows are measured with the edited font size and rectangle. +fn scroll_list(doc: &mut Document, field: ObjRef, list_picked: Option<(Vec, usize)>) -> Result<(), FormError> { + if let Some((picked, count)) = list_picked + && let Some(g) = fields(doc).into_iter().find(|g| g.obj == field) + { + if let Some((old, new)) = crate::values::list_scroll(&g, &picked, count) + && old != new + { + doc.update_dict(field, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; + } + crate::values::scroll_widgets(doc, &g, &picked, count)?; + } + Ok(()) +} + +fn apply_late_widget_props( + doc: &mut Document, + f: &Field, + props: &FieldProps, + new_da: &str, + rotated_rect: Option>, +) -> Result, FormError> { + // A complete look replaces widget /DA overrides. Partial changes were patched above + // against each override, including size-only changes that keep its font and colour. + if props.look.is_some() { + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + if d.contains(b"DA") { + d.set(b"DA".to_vec(), PdfString::literal(new_da.to_owned().into_bytes())); + } + })?; + } + } + } + if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect) + && let Some(w) = f.widgets.get(wi) + { + if let Some(rect) = swapped { + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?; + } + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + if rot == 0 { + mk.remove(b"R"); + } else { + mk.set(b"R".to_vec(), Object::Int(rot)); + } + doc.update_dict(w.obj, |d| { + if mk.is_empty() { + d.remove(b"MK"); + } else { + d.set(b"MK".to_vec(), Object::Dict(mk)); + } + })?; + } + if let Some((wi, caption)) = &props.caption + && let Some(w) = f.widgets.get(*wi) + { + // Copy an indirect/shared MK before patching it; colours, icons, rotation and + // unknown entries belong to this widget, and other owners keep their old label. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + mk.set(b"CA".to_vec(), PdfString::text(caption)); + doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + // A label-only edit must not replace another widget's custom appearances. + let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi); + Ok(only) +} + +/// Name field `old` `new` in each field's Calculate script that reads it +/// ([`crate::af::rename_in_calculation`]). +fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &str) -> Result<(), FormError> { + let aa_of = + |doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()); + for f in all { + // The script is the field's, or its only widget's when the field has none, as + // `actions_of` reads it. + let only_widget = || match f.widgets.as_slice() { + [w] => aa_of(doc, w.obj).map(|aa| (w.obj, aa)), + _ => None, + }; + let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue }; + let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue }; + let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() { + Some(Object::String(s)) => s.to_text(), + // A stream that isn't UTF-8 reads with replacement characters, which must not be + // written back in place of what it holds. + Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) { + Some(js) => js, + None => continue, + }, + _ => continue, + }; + let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue }; + action.set(b"JS".to_vec(), PdfString::text(&js)); + aa.set(b"C".to_vec(), Object::Dict(action)); + doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; + } + Ok(()) +} diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs new file mode 100644 index 0000000000..e9fedbd655 --- /dev/null +++ b/crates/forms/src/author/join.rs @@ -0,0 +1,129 @@ +//! Renaming a radio button to an existing radio group's name makes it a button of that group +//! (#4949): radio buttons are one group when they are widgets of one field (ISO 32000-2 +//! §12.7.5.2.4), and a button's export value is the name of its "on" appearance state. + +use std::collections::{HashMap, HashSet}; + +use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers}; +use super::tree::{detach, list_of}; +use super::{Dict, Document, Field, FormError, ObjRef, Object, invalid}; + +/// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA` +/// keeps only the widget's own triggers. +const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"]; + +/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). +fn has_opt(doc: &Document, field: ObjRef) -> bool { + doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt")) +} + +/// Refuse what [`join_radio_group`] can't do, before anything is written. +pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<(), FormError> { + for x in [f, target] { + if has_opt(doc, x.obj) { + return invalid(format!("{} lists its export values in /Opt, so its buttons can't move between groups", x.name)); + } + if x.widgets.is_empty() { + return invalid(format!("{} has no button", x.name)); + } + } + if target.locked() { + return invalid(format!("{} is locked: unlock it first", target.name)); + } + Ok(()) +} + +/// Move every button of radio field `f` into radio field `target`. Each keeps its export value +/// unless a button of `target` already has it; then it takes the first free "ChoiceN", as a +/// button added to a group does, and buttons of `f` that shared a value still share the new one. A moved button shows selected only if it is `target`'s +/// selected value. `f` leaves the form; its own value and field-level properties go with it, +/// and `target`'s stay. Call [`check_join`] first. +pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> Result<(), FormError> { + let theirs: HashSet = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); + let mut taken = theirs.clone(); + taken.extend(f.widgets.iter().filter_map(|w| w.on_state.clone())); + // The group's value counts only when one of its buttons has it. + let selected = target.value.first().filter(|v| theirs.contains(*v)).cloned(); + // Each clashing value of `f` gets one fresh name, shared by every button that had it. Of + // `taken.len() + 1` names at least one is free, so the search always ends in one. + let mut renamed: HashMap = HashMap::new(); + for old in f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| theirs.contains(*s)) { + if renamed.contains_key(old) { + continue; + } + let Some(fresh) = (1..=taken.len().saturating_add(1)).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)) else { + return invalid(format!("no free export value for {old}")); + }; + taken.insert(fresh.clone()); + renamed.insert(old.to_owned(), fresh); + } + // A group that is its own one button gets a field with a separate widget first, so it can + // hold more than one. + if let [only] = target.widgets.as_slice() + && only.obj == target.obj + { + split_merged(doc, target, only)?; + } + detach(doc, f.obj)?; + // `f` is no longer a field, even when it stays on as a widget. + drop_from_calculation_order(doc, |r| r == f.obj)?; + let mut kids = Vec::with_capacity(f.widgets.len()); + for w in &f.widgets { + let mut d = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + if w.obj == f.obj { + for k in FIELD_ONLY_KEYS { + d.remove(k); + } + let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned()); + match aa.as_ref().map(split_triggers) { + Some((Some(widget_aa), _)) => d.set(b"AA".to_vec(), Object::Dict(widget_aa)), + Some((None, _)) => { + d.remove(b"AA"); + } + None => {} + } + } + let mut state = w.on_state.clone(); + if let Some(old) = w.on_state.as_deref() + && let Some(fresh) = renamed.get(old) + && rename_state(doc, &mut d, old, fresh) + { + state = Some(fresh.clone()); + } + let on = state.is_some() && state == selected; + let shown = match (&state, on) { + (Some(s), true) => Object::Name(crate::name_bytes(s)), + _ => Object::name("Off"), + }; + d.set(b"AS".to_vec(), shown); + d.set(b"Parent".to_vec(), Object::Ref(target.obj)); + doc.set(w.obj, Object::Dict(d)); + kids.push(Object::Ref(w.obj)); + } + // `/Kids` may be an indirect array: read through it so the group's own buttons stay. + let mut list = list_of(doc, target.obj, b"Kids"); + list.extend(kids); + doc.update_dict(target.obj, |d| d.set(b"Kids".to_vec(), Object::Array(list)))?; + Ok(()) +} + +/// Rename the appearance state `old` to `new` in the widget dictionary `d` (`/AP` `/N`, `/D`, +/// `/R`). A shared `/AP` or state dictionary is copied into `d` rather than renamed for its other +/// users. Whether `/N` had the state: when it didn't, nothing is changed. +fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool { + let (old, new) = (crate::name_bytes(old), crate::name_bytes(new)); + let Some(mut ap) = d.get(b"AP").and_then(|a| doc.resolve(a).as_dict().cloned()) else { return false }; + let states = |ap: &Dict, key: &[u8]| ap.get(key).and_then(|s| doc.resolve(s).as_dict().cloned()); + if !states(&ap, b"N").is_some_and(|n| n.contains(&old)) { + return false; + } + for key in [&b"N"[..], b"D", b"R"] { + let Some(mut sub) = states(&ap, key) else { continue }; + if let Some(v) = sub.remove(&old) { + sub.set(new.clone(), v); + ap.set(key.to_vec(), Object::Dict(sub)); + } + } + d.set(b"AP".to_vec(), Object::Dict(ap)); + true +} diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs new file mode 100644 index 0000000000..f719292a80 --- /dev/null +++ b/crates/forms/src/author/lifecycle.rs @@ -0,0 +1,428 @@ +use super::look::redraw_field; +use super::tree::{add_to_page, attach, clash, detach, ensure_form, free_name, name_parts, top_names}; +use super::{ + Dict, Document, Field, FieldKind, FormError, MIN_FIELD_SIDE, NewField, ObjRef, Object, PdfString, Widget, appearance, fields, flags, invalid, + page_refs, rgb, +}; + +fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"F".to_vec(), Object::Int(4)); + let mut mk = Dict::new(); + mk.set(b"BC".to_vec(), rgb([0.55, 0.55, 0.55])); + mk.set(b"BG".to_vec(), rgb([1.0, 1.0, 1.0])); + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Int(1)); + bs.set(b"S".to_vec(), Object::name("S")); + d.set(b"BS".to_vec(), Object::Dict(bs)); + d +} + +/// Add a field on `page` (0-based) in `rect` (user space). Returns its full name. +pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewField, name: Option<&str>) -> Result { + let pages = page_refs(doc); + let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?; + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < MIN_FIELD_SIDE || rect[3] - rect[1] < MIN_FIELD_SIDE { + return invalid("the field is too small"); + } + let existing = fields(doc); + // A radio button names its group: a new group takes that name. + let name = match (name, kind) { + (None, NewField::Radio { group: Some(g), .. }) => Some(g.as_str()), + _ => name, + }; + let name = match name.map(str::trim) { + Some(n) if !n.is_empty() => n.to_string(), + _ => free_name(&existing, &top_names(doc), kind.base_name()), + }; + let parts = name_parts(&name)?; + let radio_group = match kind { + NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), + _ => None, + }; + if radio_group.is_none() + && let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name)) + { + return invalid(why); + } + let af = ensure_form(doc)?; + let mut w = widget_dict(page_ref, rect); + let field_name = match kind { + NewField::Radio { group: Some(g), .. } if radio_group.is_some() => g.clone(), + _ => name.clone(), + }; + match kind { + NewField::Text { multiline } => { + w.set(b"FT".to_vec(), Object::name("Tx")); + // Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888). + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + if *multiline { + w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64)); + } + } + NewField::Date => { + w.set(b"FT".to_vec(), Object::name("Tx")); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + // Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine). + let js = |s: &str| { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(s.as_bytes().to_vec())); + Object::Dict(a) + }; + let mut aa = Dict::new(); + aa.set(b"F".to_vec(), js("AFDate_FormatEx(\"mm/dd/yyyy\");")); + aa.set(b"K".to_vec(), js("AFDate_KeystrokeEx(\"mm/dd/yyyy\");")); + w.set(b"AA".to_vec(), Object::Dict(aa)); + } + NewField::CheckBox => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"V".to_vec(), Object::name("Off")); + w.set(b"AS".to_vec(), Object::name("Off")); + w.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); + } + NewField::Radio { export, .. } => { + w.set(b"AS".to_vec(), Object::name("Off")); + if export.trim().is_empty() || export == "Off" { + return invalid("a radio button needs an export value other than Off"); + } + } + NewField::Combo { options, editable } | NewField::List { options, multi: editable } => { + w.set(b"FT".to_vec(), Object::name("Ch")); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec())); + let combo = matches!(kind, NewField::Combo { .. }); + let mut ff = 0; + if combo { + ff |= flags::COMBO; + if *editable { + ff |= flags::EDIT; + } + } else if *editable { + ff |= flags::MULTI_SELECT; + } + w.set(b"Ff".to_vec(), Object::Int(ff as i64)); + w.set(b"Opt".to_vec(), Object::Array(options.iter().map(|o| Object::String(PdfString::text(o))).collect())); + } + NewField::Button { caption } => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + mk.set(b"BG".to_vec(), rgb([0.86, 0.86, 0.86])); + mk.set(b"CA".to_vec(), PdfString::text(caption)); + w.set(b"MK".to_vec(), Object::Dict(mk)); + } + NewField::Image => { + w.set(b"FT".to_vec(), Object::name("Btn")); + w.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + let mut mk = w.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + // Icon only, no border or fill: the picture is the field. + mk.remove(b"BC"); + mk.remove(b"BG"); + mk.set(b"TP".to_vec(), Object::Int(1)); + w.set(b"MK".to_vec(), Object::Dict(mk)); + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::literal(b"event.target.buttonImportIcon();".to_vec())); + w.set(b"A".to_vec(), Object::Dict(a)); + } + NewField::Signature => { + w.set(b"FT".to_vec(), Object::name("Sig")); + } + } + let widget = doc.add(Object::Dict(w)); + match kind { + NewField::Radio { export, .. } => { + let group = match &radio_group { + Some(g) => g.obj, + None => { + let mut g = Dict::new(); + g.set(b"FT".to_vec(), Object::name("Btn")); + g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); + g.set(b"V".to_vec(), Object::name("Off")); + g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); + g.set(b"Kids".to_vec(), Object::Array(Vec::new())); + let g = doc.add(Object::Dict(g)); + attach(doc, af, g, &parts)?; + g + } + }; + doc.update_dict(widget, |d| d.set(b"Parent".to_vec(), Object::Ref(group)))?; + doc.update_dict(group, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.push(Object::Ref(widget)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + })?; + let w = Widget { + obj: widget, + page: Some(page), + rect, + on_state: Some(export.clone()), + state: Some("Off".into()), + tab: usize::MAX, + locked: false, + hidden: false, + read_only: false, + rotation: 0, + caption: None, + list: None, + round: true, + }; + let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); + doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; + } + _ => attach(doc, af, widget, &parts)?, + } + add_to_page(doc, page_ref, widget)?; + redraw_field(doc, &field_name)?; + Ok(field_name) +} + +/// Delete a field: its widgets leave their pages and the field leaves the form. +pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + delete_found_field(doc, f) +} + +/// [`delete_field`] for the field object `obj`, when separate fields share one name. +pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.obj == obj).ok_or_else(|| FormError::NoSuchField(format!("{obj:?}")))?; + delete_found_field(doc, f) +} + +/// Delete widget `widget` (an index into the field's widgets) of the field `name`, keeping the +/// field and its other widgets: one radio button of a group, or one copy of a duplicated field, +/// as Acrobat's Prepare Form deletes the selected one (#4609). A field with one widget is deleted +/// whole. A per-widget `/Opt` entry goes with its widget, and a check box or radio group that was +/// on through the deleted widget alone turns off. +pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let Some(w) = f.widgets.get(widget).cloned() else { + return invalid(format!("{name} has no widget {}", widget.saturating_add(1))); + }; + // A field merged with its widget is that widget's only one. + if f.widgets.len() < 2 || w.obj == f.obj { + return delete_found_field(doc, f); + } + let per_widget_opt = matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.options.len() == f.widgets.len(); + // Turn the group off when no button left *shows* the deleted one's state: a twin with the same + // on-state name that is currently off does not keep the choice visible. + let on_only_here = w.on_state.as_deref().is_some_and(|on| { + f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.state.as_deref() == Some(on)) + }); + drop_from_pages(doc, &std::iter::once(w.obj).collect())?; + detach(doc, w.obj)?; + drop_from_calculation_order(doc, |r| r == w.obj)?; + let mut d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); + let mut changed = false; + // The field's own `/Kids` is what made the widget its: `detach` follows the widget's + // `/Parent`, which a damaged file can leave out or point elsewhere, so the field would keep + // a widget no page shows. + if let Some(kids) = d.get(b"Kids").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) + && kids.iter().any(|k| k.as_ref() == Some(w.obj)) + { + d.set(b"Kids".to_vec(), Object::Array(kids.into_iter().filter(|k| k.as_ref() != Some(w.obj)).collect())); + changed = true; + } + if per_widget_opt + && let Some(opt) = d.get(b"Opt").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) + && opt.len() == f.widgets.len() + { + let kept: Vec = opt.into_iter().enumerate().filter(|(i, _)| *i != widget).map(|(_, o)| o).collect(); + d.set(b"Opt".to_vec(), Object::Array(kept)); + changed = true; + } + if on_only_here && d.get(b"V").is_some() { + d.set(b"V".to_vec(), Object::name("Off")); + changed = true; + } + // The default (`/DV`, what Reset puts back) can't name a state no button has any more. + let dv_only_here = w.on_state.as_deref().is_some_and(|on| { + d.get(b"DV").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)).is_some_and(|dv| dv == on.as_bytes()) + && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on)) + }); + if dv_only_here { + d.set(b"DV".to_vec(), Object::name("Off")); + changed = true; + } + if changed { + doc.set(f.obj, Object::Dict(d)); + } + Ok(()) +} + +fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { + let widgets: std::collections::HashSet = f.widgets.iter().map(|w| w.obj).collect(); + drop_from_pages(doc, &widgets)?; + detach(doc, f.obj)?; + // Out of the calculation order too: a `/CO` entry would keep the field, its value and its + // widgets reachable for a full save. + drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) +} + +/// Take the annotations `widgets` off every page's `/Annots`, held directly or by reference. +fn drop_from_pages(doc: &mut Document, widgets: &std::collections::HashSet) -> Result<(), FormError> { + for p in page_refs(doc) { + let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; + let list = doc.resolve(&a).as_array().cloned().unwrap_or_default(); + let kept: Vec = list.iter().filter(|o| !o.as_ref().is_some_and(|r| widgets.contains(&r))).cloned().collect(); + if kept.len() != list.len() { + match a.as_ref() { + Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + _ => doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?, + } + } + } + Ok(()) +} + +/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or +/// by reference. +pub(super) fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { + let Some(root) = doc.root() else { return Ok(()) }; + let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; + let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; + let Some(co_obj) = af.get(b"CO").cloned() else { return Ok(()) }; + let co = doc.resolve(&co_obj).as_array().cloned().unwrap_or_default(); + let kept: Vec = co.iter().filter(|o| !o.as_ref().is_some_and(&gone)).cloned().collect(); + if kept.len() == co.len() { + return Ok(()); + } + match (co_obj.as_ref(), af_obj.as_ref()) { + (Some(r), _) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(kept)), + (_, Some(af_ref)) => doc.update_dict(af_ref, |d| d.set(b"CO".to_vec(), Object::Array(kept)))?, + (_, None) => { + af.set(b"CO".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } + Ok(()) +} + +/// Widget-only keys: they stay with each widget when a merged field is split. +const WIDGET_KEYS: [&[u8]; 13] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent", b"A"]; + +/// The `/AA` triggers of a widget annotation (ISO 32000-2 §12.6.3, Table 197). The rest (`K`, +/// `F`, `V`, `C`, Table 199) belong to the field. +const WIDGET_TRIGGERS: [&[u8]; 10] = [b"E", b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"]; + +/// Split additional-actions dictionary `aa` into the widget's triggers and the field's. +/// An empty half is `None`. +pub(super) fn split_triggers(aa: &Dict) -> (Option, Option) { + let (mut widget, mut field) = (Dict::new(), Dict::new()); + for (k, v) in aa.iter() { + let to = if WIDGET_TRIGGERS.contains(&k.as_slice()) { &mut widget } else { &mut field }; + to.set(k.clone(), v.clone()); + } + let some = |d: Dict| (!d.is_empty()).then_some(d); + (some(widget), some(field)) +} + +/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page +/// gets a widget at the same place, belonging to the same field, so they share one value. +/// A field that is its own widget is first split into a field and a widget kid. Returns how +/// many widgets were added (pages that already have one are skipped). +pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Result { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let model = f.widgets.first().cloned().ok_or_else(|| FormError::Invalid(format!("{name} has no widget")))?; + let refs = page_refs(doc); + for p in pages { + if *p >= refs.len() { + return invalid(format!("page {} does not exist", p + 1)); + } + } + let have: std::collections::HashSet = f.widgets.iter().filter_map(|w| w.page).collect(); + let targets: Vec = pages.iter().copied().filter(|p| !have.contains(p)).collect(); + if targets.is_empty() { + return Ok(0); + } + // A merged field/widget becomes a field with one widget kid. + let model_ref = if model.obj == f.obj { split_merged(doc, &f, &model)? } else { model.obj }; + let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default(); + let mut added = Vec::new(); + for p in &targets { + let pr = refs[*p]; + let mut w = template.clone(); + w.set(b"P".to_vec(), Object::Ref(pr)); + w.set(b"Parent".to_vec(), Object::Ref(f.obj)); + w.remove(b"StructParent"); + let wr = doc.add(Object::Dict(w)); + let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); + match annots { + Some(Object::Ref(ar)) => { + let mut a = doc.get(ar).as_array().cloned().unwrap_or_default(); + a.push(Object::Ref(wr)); + doc.set(ar, Object::Array(a)); + } + other => { + let mut a = other.and_then(|o| o.as_array().cloned()).unwrap_or_default(); + a.push(Object::Ref(wr)); + doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; + } + } + added.push(wr); + } + doc.update_dict(f.obj, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + kids.extend(added.iter().map(|r| Object::Ref(*r))); + d.set(b"Kids".to_vec(), Object::Array(kids)); + })?; + redraw_field(doc, name)?; + Ok(added.len()) +} + +/// Split field `f`, which is also its only widget `model`, into a field and one widget kid that +/// takes the widget's keys and its place in the page's `/Annots`. Returns the kid. +pub(super) fn split_merged(doc: &mut Document, f: &Field, model: &Widget) -> Result { + let refs = page_refs(doc); + let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); + let mut kid = Dict::new(); + for k in WIDGET_KEYS { + if let Some(v) = d.get(k) { + kid.set(k.to_vec(), v.clone()); + } + } + // The widget's own triggers go with it; the field's stay. + let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned()); + let (widget_aa, field_aa) = aa.as_ref().map(split_triggers).unwrap_or_default(); + if let Some(w) = widget_aa { + kid.set(b"AA".to_vec(), Object::Dict(w)); + } + kid.set(b"Parent".to_vec(), Object::Ref(f.obj)); + let kr = doc.add(Object::Dict(kid)); + doc.update_dict(f.obj, |d| { + for k in WIDGET_KEYS { + d.remove(k); + } + if aa.is_some() { + match field_aa { + Some(fa) => d.set(b"AA".to_vec(), Object::Dict(fa)), + None => { + d.remove(b"AA"); + } + } + } + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); + })?; + // The page lists the widget now, not the field. + if let Some(&pr) = model.page.and_then(|p| refs.get(p)) { + let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); + let swap = |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; + match annots { + Some(Object::Ref(ar)) => { + let a = doc.get(ar).as_array().cloned().unwrap_or_default(); + doc.set(ar, Object::Array(swap(a))); + } + Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, + _ => {} + } + } + Ok(kr) +} diff --git a/crates/forms/src/author/look.rs b/crates/forms/src/author/look.rs new file mode 100644 index 0000000000..23661ed70a --- /dev/null +++ b/crates/forms/src/author/look.rs @@ -0,0 +1,473 @@ +use super::{Dict, Document, Field, FieldKind, FormError, ObjRef, Object, Stream, Widget, appearance, fields, invalid, rgb}; + +/// The Appearance tab: borders, colours and text. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + pub border: Option<[f64; 3]>, + pub fill: Option<[f64; 3]>, + /// Line thickness in points (Acrobat: thin 1, medium 2, thick 3). + pub width: f64, + pub style: BorderStyle, + pub text: [f64; 3], + pub font: FieldFont, +} + +/// A partial Appearance-tab edit. Outer `None` keeps a property; `Some(None)` removes a +/// border or fill colour. The patch is applied to each widget's own dictionaries. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct LookPatch { + pub border: Option>, + pub fill: Option>, + pub width: Option, + pub style: Option, + pub text: Option<[f64; 3]>, + pub font: Option, +} + +impl LookPatch { + pub(super) fn validate(&self) -> Result<(), FormError> { + for c in self.border.flatten().into_iter().chain(self.fill.flatten()).chain(self.text) { + if !c.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) { + return invalid("appearance colours must be finite RGB values between 0 and 1"); + } + } + if self.width.is_some_and(|v| !v.is_finite() || !(0.0..=12.0).contains(&v)) { + return invalid("border width must be between 0 and 12 points"); + } + Ok(()) + } +} + +/// Rewrite only the selected operators in place: the last `Tf` (font and size) and the last +/// fill colour (`g`, `rg` or `k`), appending one when the string has none. Everything else (a +/// custom font resource, colour space or other graphics state) is kept, and repeated edits don't +/// grow the string. +pub(super) fn patch_da(da: &str, size: Option, patch: &LookPatch) -> String { + if patch.font.is_none() && size.is_none() && patch.text.is_none() { + return da.to_string(); + } + let mut toks: Vec = da.split_whitespace().map(str::to_string).collect(); + let numeric = |s: &str| s.parse::().is_ok(); + if patch.font.is_some() || size.is_some() { + let old = appearance::parse_da(da); + let font = patch.font.map_or_else(|| old.font.clone(), |f| f.resource().to_string()); + // `parse_da` reads sizes up to 300 points; keep the same bound when writing. + let size = appearance::fmt(size.unwrap_or(old.size).clamp(0.0, 300.0)); + let at = toks.iter().enumerate().rev().find(|(i, t)| t.as_str() == "Tf" && *i >= 2).map(|(i, _)| i); + match at { + Some(i) => { + if let Some(slot) = toks.get_mut(i - 2) { + *slot = format!("/{font}"); + } + if let Some(slot) = toks.get_mut(i - 1) { + *slot = size; + } + } + None => toks.extend([format!("/{font}"), size, "Tf".into()]), + } + } + if let Some(c) = patch.text { + let rgb = [appearance::fmt(c[0]), appearance::fmt(c[1]), appearance::fmt(c[2]), "rg".into()]; + let at = toks.iter().enumerate().rev().find_map(|(i, t)| { + let n = match t.as_str() { + "g" => 1, + "rg" => 3, + "k" => 4, + _ => return None, + }; + let start = i.checked_sub(n)?; + toks.get(start..i).is_some_and(|ops| ops.iter().all(|o| numeric(o))).then_some((start, i)) + }); + match at { + Some((start, end)) => { + toks.splice(start..=end, rgb); + } + None => toks.extend(rgb), + } + } + toks.join(" ") +} + +pub(super) fn patch_widget_look(doc: &mut Document, w: &Widget, patch: &LookPatch) -> Result<(), FormError> { + let touches_mk = patch.border.is_some() || patch.fill.is_some(); + let touches_bs = patch.width.is_some() || patch.style.is_some(); + // A size- or font-only edit changes /DA, not the widget: leave it out of the save. + if !touches_mk && !touches_bs { + return Ok(()); + } + let wd = doc.get(w.obj).as_dict().cloned().ok_or_else(|| FormError::Invalid("the widget is not a dictionary".into()))?; + if touches_mk { + let indirect = wd.get(b"MK").and_then(Object::as_ref); + let mut mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + for (key, change) in [(b"BC".as_slice(), patch.border), (b"BG".as_slice(), patch.fill)] { + if let Some(change) = change { + match change { + Some(c) => mk.set(key.to_vec(), rgb(c)), + None => { + mk.remove(key); + } + } + } + } + // A shared indirect /MK is updated where it lives, not copied into each widget. + match indirect { + Some(r) => doc.set(r, Object::Dict(mk)), + None => doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?, + } + } + if touches_bs { + let indirect = wd.get(b"BS").and_then(Object::as_ref); + let mut bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()).unwrap_or_default(); + if let Some(width) = patch.width { + bs.set(b"W".to_vec(), Object::Real(width)); + } + if let Some(style) = patch.style { + bs.set(b"S".to_vec(), Object::name(style.code())); + if style == BorderStyle::Dashed && !bs.contains(b"D") { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + } + match indirect { + Some(r) => doc.set(r, Object::Dict(bs)), + None => doc.update_dict(w.obj, |d| d.set(b"BS".to_vec(), Object::Dict(bs)))?, + } + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BorderStyle { + Solid, + Dashed, + Beveled, + Inset, + Underline, +} + +impl BorderStyle { + pub const ALL: [BorderStyle; 5] = [BorderStyle::Solid, BorderStyle::Dashed, BorderStyle::Beveled, BorderStyle::Inset, BorderStyle::Underline]; + + pub(super) fn code(self) -> &'static str { + match self { + BorderStyle::Solid => "S", + BorderStyle::Dashed => "D", + BorderStyle::Beveled => "B", + BorderStyle::Inset => "I", + BorderStyle::Underline => "U", + } + } + + pub fn label(self) -> &'static str { + match self { + BorderStyle::Solid => "Solid", + BorderStyle::Dashed => "Dashed", + BorderStyle::Beveled => "Beveled", + BorderStyle::Inset => "Inset", + BorderStyle::Underline => "Underline", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldFont { + Helvetica, + Times, + Courier, +} + +impl FieldFont { + pub const ALL: [FieldFont; 3] = [FieldFont::Helvetica, FieldFont::Times, FieldFont::Courier]; + + fn resource(self) -> &'static str { + match self { + FieldFont::Helvetica => "Helv", + FieldFont::Times => "TiRo", + FieldFont::Courier => "Cour", + } + } + + pub fn label(self) -> &'static str { + match self { + FieldFont::Helvetica => "Helvetica", + FieldFont::Times => "Times Roman", + FieldFont::Courier => "Courier", + } + } +} + +/// The mark a check box or radio button shows when on (Field Properties ▸ Options ▸ style). +/// Stored as `/MK /CA`, the ZapfDingbats character Acrobat uses for it; PdfCraft draws the +/// mark as paths, so no symbol font is needed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CheckStyle { + Check, + Circle, + Cross, + Diamond, + Square, + Star, +} + +impl CheckStyle { + pub const ALL: [CheckStyle; 6] = + [CheckStyle::Check, CheckStyle::Circle, CheckStyle::Cross, CheckStyle::Diamond, CheckStyle::Square, CheckStyle::Star]; + + pub(super) fn code(self) -> &'static str { + match self { + CheckStyle::Check => "4", + CheckStyle::Circle => "l", + CheckStyle::Cross => "8", + CheckStyle::Diamond => "u", + CheckStyle::Square => "n", + CheckStyle::Star => "H", + } + } + + pub fn label(self) -> &'static str { + match self { + CheckStyle::Check => "Check", + CheckStyle::Circle => "Circle", + CheckStyle::Cross => "Cross", + CheckStyle::Diamond => "Diamond", + CheckStyle::Square => "Square", + CheckStyle::Star => "Star", + } + } + + /// A widget's style: its `/MK /CA`, else a check for check boxes and a circle for radios. + pub(crate) fn of_widget(doc: &Document, wd: &Dict, kind: FieldKind) -> Self { + let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); + let ca = mk.get(b"CA").and_then(|c| doc.resolve(c).as_string().map(|s| s.to_text())); + CheckStyle::ALL.into_iter().find(|s| ca.as_deref() == Some(s.code())).unwrap_or(if kind == FieldKind::Radio { + CheckStyle::Circle + } else { + CheckStyle::Check + }) + } +} + +/// A check box's or radio button's style (from its first widget). +pub fn check_style(doc: &Document, f: &Field) -> CheckStyle { + let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); + CheckStyle::of_widget(doc, &wd, f.kind) +} + +fn rgb_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 3]> { + let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(Object::as_f64).collect(); + match v.len() { + 1 => Some([v[0]; 3]), + 3 => Some([v[0], v[1], v[2]]), + 4 => Some([(1.0 - v[0]) * (1.0 - v[3]), (1.0 - v[1]) * (1.0 - v[3]), (1.0 - v[2]) * (1.0 - v[3])]), + _ => None, + } +} + +/// A field's current look (from its first widget and its default appearance). +pub fn look(doc: &Document, f: &Field) -> Look { + let wd = f.widgets.first().and_then(|w| doc.get(w.obj).as_dict().cloned()).unwrap_or_default(); + let mk = wd.get(b"MK").and_then(|m| doc.resolve(m).as_dict().cloned()).unwrap_or_default(); + let bs = wd.get(b"BS").and_then(|b| doc.resolve(b).as_dict().cloned()).unwrap_or_default(); + let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_else(|| f.da.clone()); + let parsed = appearance::parse_da(&da); + let nums: Vec = parsed.color.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + let text = match nums.len() { + 1 => [nums[0]; 3], + 3 => [nums[0], nums[1], nums[2]], + 4 => [(1.0 - nums[0]) * (1.0 - nums[3]), (1.0 - nums[1]) * (1.0 - nums[3]), (1.0 - nums[2]) * (1.0 - nums[3])], + _ => [0.0; 3], + }; + Look { + border: rgb_of(doc, mk.get(b"BC")), + fill: rgb_of(doc, mk.get(b"BG")), + width: bs.get(b"W").and_then(|w| doc.resolve(w).as_f64()).unwrap_or(1.0), + style: match bs.name(b"S") { + Some(b"D") => BorderStyle::Dashed, + Some(b"B") => BorderStyle::Beveled, + Some(b"I") => BorderStyle::Inset, + Some(b"U") => BorderStyle::Underline, + _ => BorderStyle::Solid, + }, + text, + font: match parsed.font.as_str() { + "TiRo" | "Times-Roman" => FieldFont::Times, + "Cour" | "Courier" => FieldFont::Courier, + _ => FieldFont::Helvetica, + }, + } +} + +/// A `/DA` string with the given font, size and colour. +pub(super) fn da_string(font: FieldFont, size: f64, c: [f64; 3]) -> String { + let f = crate::appearance::fmt; + format!("/{} {} Tf {} {} {} rg", font.resource(), f(size.clamp(0.0, 100.0)), f(c[0]), f(c[1]), f(c[2])) +} + +/// Regenerate every widget appearance of a field from its current value and settings. A value or +/// button caption its font can't hold is drawn with `?` for those characters, the widget is marked +/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see +/// `ask_viewers_to_redraw`), as when a value is set. +pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { + redraw_widgets(doc, name, None) +} + +/// `redraw_field` for the widget at index `only` alone (so a caption-only edit leaves the field's +/// other widgets' appearances as they are), or for every widget when `only` is `None`. Fails with +/// `FormError::NoSuchField` when no field is named `name`. +pub(super) fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let mut undrawn = false; + for (index, w) in f.widgets.iter().enumerate() { + if only.is_some_and(|only| only != index) { + continue; + } + let mut missing = None; + let ap = match f.kind { + FieldKind::Text | FieldKind::Combo | FieldKind::List => { + let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true); + missing = m; + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + FieldKind::CheckBox | FieldKind::Radio => { + let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); + appearance::check_box_states(doc, w, f.kind, &on, &f.da) + } + FieldKind::PushButton => { + let (s, m) = button_appearance(doc, w, &f.da); + missing = m; + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + FieldKind::Signature => { + let s = empty_box(doc, w); + let r = doc.add(Object::Stream(s)); + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Ref(r)); + d + } + }; + undrawn |= missing.is_some(); + let existing = crate::merged_appearance(doc, w.obj, &ap); + doc.update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(existing)); + pdfcraft_fonts::set_undrawn_mark(d, missing); + })?; + } + if undrawn { + crate::ask_viewers_to_redraw(doc)?; + } + Ok(()) +} + +fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) { + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); + let col = |k: &[u8]| -> Option { + let v: Vec = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); + (v.len() == 3).then(|| format!("{} {} {}", crate::appearance::fmt(v[0]), crate::appearance::fmt(v[1]), crate::appearance::fmt(v[2]))) + }; + let mut c = String::new(); + if let Some(bg) = col(b"BG") { + c.push_str(&format!("{bg} rg 0 0 {width:.3} {height:.3} re f\n")); + } + if let Some(bc) = col(b"BC") { + c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0)); + } + (c, place) +} + +fn form_stream(place: crate::appearance::Placement, content: Vec, resources: Dict) -> Stream { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + crate::appearance::set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + Stream::flate(d, &content) +} + +/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its +/// centred caption (unless the layout is icon only, `/TP 1`). The caption uses the font, size and +/// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one). +/// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to +/// mark and report. +pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { + let (mut c, place) = frame_only(doc, w); + let (width, height) = (place.layout_w, place.layout_h); + let wobj = doc.get(w.obj); + let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default(); + let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1); + let mut content = std::mem::take(&mut c).into_bytes(); + let mut xobjects = Dict::new(); + if let Some(icon) = mk.get(b"I").and_then(Object::as_ref) + && let Object::Stream(s) = &*doc.get(icon) + { + let bb: Vec = s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + if let [x0, y0, x1, y1] = bb[..] + && x1 > x0 + && y1 > y0 + { + // Proportional, as large as fits (Acrobat's default icon placement), centred. + let k = ((width - 2.0) / (x1 - x0)).min((height - 2.0) / (y1 - y0)).max(0.0); + let (dx, dy) = ((width - (x1 - x0) * k) / 2.0 - x0 * k, (height - (y1 - y0) * k) / 2.0 - y0 * k); + content.extend(format!("q {k:.6} 0 0 {k:.6} {dx:.3} {dy:.3} cm /Icon Do Q\n").bytes()); + xobjects.set(b"Icon".to_vec(), Object::Ref(icon)); + } + } + let mut missing = None; + let mut fonts = Dict::new(); + if !icon_only && !caption.is_empty() { + let da = wobj.as_dict().and_then(|d| d.get(b"DA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0); + let (ops, name, font, m) = appearance::button_caption(doc, da.as_deref().unwrap_or(field_da), &caption, width, height, auto); + content.extend(ops); + fonts.set(name.into_bytes(), font); + missing = m; + } + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + (form_stream(place, content, res), missing) +} + +/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): +/// it becomes the button's icon (`/MK /I`) and its appearance. +pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, u32)) -> Result<(), FormError> { + let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + if f.kind != FieldKind::PushButton { + return invalid(format!("{name} is not a button or image field")); + } + // The icon form draws the image in a box of its pixel size (keeping its aspect ratio). + let (w, h) = (px.0.max(1) as f64, px.1.max(1) as f64); + let mut xo = Dict::new(); + xo.set(b"Im0".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let icon = + doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); + for wd in &f.widgets { + let mut mk = + doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + mk.set(b"I".to_vec(), Object::Ref(icon)); + if !mk.contains(b"TP") { + mk.set(b"TP".to_vec(), Object::Int(1)); + } + doc.update_dict(wd.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + redraw_field(doc, name) +} + +fn empty_box(doc: &Document, w: &Widget) -> Stream { + let (c, place) = frame_only(doc, w); + form_stream(place, c.into_bytes(), Dict::new()) +} diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs new file mode 100644 index 0000000000..ce4ee687bb --- /dev/null +++ b/crates/forms/src/author/tree.rs @@ -0,0 +1,398 @@ +use super::{Dict, Document, Field, FormError, ObjRef, Object, PdfString, invalid}; + +/// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. +pub(crate) fn ensure_form(doc: &mut Document) -> Result { + let root = doc.root().ok_or(FormError::NoForm)?; + let existing = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()); + let r = match existing { + Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => r, + other => { + let d = other.and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let r = doc.add(Object::Dict(d)); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; + r + } + }; + let mut af = doc.get(r).as_dict().cloned().unwrap_or_default(); + if !af.contains(b"Fields") { + af.set(b"Fields".to_vec(), Object::Array(Vec::new())); + } + if !af.contains(b"DA") { + af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); + } + let (dr, mut fonts) = form_fonts(doc, &af); + let mut added = inline_fonts_as_objects(doc, &mut fonts); + // Acrobat's resource names for the standard fonts its Appearance tab offers. + for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { + if fonts.contains(name.as_bytes()) { + continue; + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let r = doc.add(Object::Dict(f)); + fonts.set(name.as_bytes().to_vec(), Object::Ref(r)); + added = true; + } + if added { + store_form_fonts(doc, &mut af, dr, fonts); + } + doc.set(r, Object::Dict(af)); + Ok(r) +} + +/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing). +fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) { + let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + (dr, fonts) +} + +/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows +/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline +/// (by an earlier save, or another writer) become objects saying the same; a font that is an +/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed. +fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool { + let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + let changed = !inline.is_empty(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + fonts.set(name, Object::Ref(r)); + } + changed +} + +/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or +/// `/Font` stays the object it is. +fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) { + match dr.get(b"Font").and_then(Object::as_ref) { + Some(fr) => doc.set(fr, Object::Dict(fonts)), + None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), + } + match af.get(b"DR").and_then(Object::as_ref) { + Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), + None => af.set(b"DR".to_vec(), Object::Dict(dr)), + } +} + +/// Filling a field: the form's inline `/DR` fonts become objects (see +/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat +/// once it has been filled here too (#3441). Only the font storage changes; nothing is added. +/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the +/// value itself. +pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { + let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && holds_value(doc, f.obj)); + if signed { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + let Some(entry) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) else { return Ok(()) }; + match entry { + Object::Ref(r) => { + let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.set(r, Object::Dict(af)); + } + } + // A form dictionary written directly in the catalog (the catalog's /AcroForm need not be + // an indirect reference) is written back there. + Object::Dict(mut af) => { + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } + _ => {} + } + Ok(()) +} + +/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is +/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep +/// as the field walk goes, so a cyclic chain ends. +fn holds_value(doc: &Document, r: ObjRef) -> bool { + let mut at = r; + for _ in 0..=64 { + let obj = doc.get(at); + let Some(d) = obj.as_dict() else { return false }; + if d.contains(b"V") { + return true; + } + match d.reference(b"Parent") { + Some(p) => at = p, + None => return false, + } + } + false +} + +pub(super) fn add_to_page(doc: &mut Document, page: ObjRef, widget: ObjRef) -> Result<(), FormError> { + let existing = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()); + match existing.as_ref().and_then(|o| o.as_ref()).filter(|r| doc.get(*r).as_array().is_some()) { + Some(r) => { + let mut a = doc.get(r).as_array().cloned().unwrap_or_default(); + a.push(Object::Ref(widget)); + doc.set(r, Object::Array(a)); + } + None => { + let mut a = existing.and_then(|o| o.as_array().cloned()).unwrap_or_default(); + a.push(Object::Ref(widget)); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?; + } + } + Ok(()) +} + +fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), FormError> { + let mut d = doc.get(af).as_dict().cloned().unwrap_or_default(); + let mut list = d.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.push(Object::Ref(field)); + d.set(b"Fields".to_vec(), Object::Array(list)); + doc.set(af, Object::Dict(d)); + Ok(()) +} + +/// The most parts a dotted field name may have ("a.b.c" has three). +const MAX_NAME_PARTS: usize = 32; + +/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). +const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; + +/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a +/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). +pub(super) fn name_parts(name: &str) -> Result, FormError> { + let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect(); + if parts.len() > MAX_NAME_PARTS { + return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts")); + } + if parts.iter().any(|p| p.trim().is_empty()) { + return invalid("field names can't be empty, or have an empty part between periods"); + } + Ok(parts) +} + +/// Why the full name `name` can't be used beside the field named `other`, if it can't: the +/// same name, or one inside the other (a field that has a value can't also hold fields). +pub(super) fn clash(name: &str, other: &str) -> Option { + if name == other { + return Some(format!("a field named {name:?} already exists")); + } + let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.')); + (inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}")) +} + +/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). +pub(super) fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { + let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() +} + +/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top. +fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) -> Result<(), FormError> { + let Some(p) = parent else { return add_to_fields(doc, af, field) }; + let mut kids = list_of(doc, p, b"Kids"); + kids.push(Object::Ref(field)); + doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?; + Ok(()) +} + +/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form. +pub(super) fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) -> Option { + let list = match parent { + Some(p) => list_of(doc, p, b"Kids"), + None => list_of(doc, af, b"Fields"), + }; + list.iter() + .filter_map(|o| o.as_ref()) + .find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name)) +} + +/// The values a parent field passes to the fields under it that can't be turned off by giving +/// the field a neutral one of its own, as `/Ff` and `/Q` can. +const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; + +/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The +/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node +/// of the same name there is found here. +/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]). +pub(super) fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + for (k, v) in inherited.iter() { + owned.set(k.to_vec(), v.clone()); + } + let mut at: Option = None; + let mut whole_path = true; + for part in parents { + let Some(r) = child_named(doc, af, at, part) else { + whole_path = false; + break; + }; + // A value, reset value or length limit held by a parent would pass to this field. + let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); + if held { + return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); + } + at = Some(r); + } + // A field being moved is still in the tree here, but under another full name (a move to its + // own name changes nothing and doesn't get here), so a node found is another. + if whole_path && child_named(doc, af, at, own).is_some() { + return invalid(format!("a field named {:?} already exists", parts.join("."))); + } + Ok(()) +} + +/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, +/// and the parts before it name parent fields, reused where the tree has them and made where +/// not (Acrobat makes `a.b` a field `b` under a field `a`). +pub(super) fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + check_place(doc, af, field, parts, &Dict::new())?; + let mut parent: Option = None; + let mut inherits = false; + for part in parents { + let next = match child_named(doc, af, parent, part) { + Some(r) => { + inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); + r + } + None => { + let mut d = Dict::new(); + d.set(b"T".to_vec(), PdfString::text(part)); + d.set(b"Kids".to_vec(), Object::Array(Vec::new())); + if let Some(p) = parent { + d.set(b"Parent".to_vec(), Object::Ref(p)); + } + let r = doc.add(Object::Dict(d)); + link(doc, af, parent, r)?; + r + } + }; + parent = Some(next); + } + doc.update_dict(field, |d| { + d.set(b"T".to_vec(), PdfString::text(own)); + match parent { + Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)), + None => { + d.remove(b"Parent"); + } + } + // An existing parent's flags and alignment would otherwise pass to this field. + if inherits { + for k in [&b"Ff"[..], b"Q"] { + if !d.contains(k) { + d.set(k.to_vec(), Object::Int(0)); + } + } + } + })?; + link(doc, af, parent, field) +} + +/// What `field` inherits from its parents and doesn't hold itself, changing nothing. +pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict { + let own = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut add = Dict::new(); + let mut seen = std::collections::HashSet::new(); + let mut parent = own.reference(b"Parent"); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + break; + } + let Some(d) = doc.get(p).as_dict().cloned() else { break }; + for k in INHERITED_KEYS { + if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { + add.set(k.to_vec(), v.clone()); + } + } + parent = d.reference(b"Parent"); + } + add +} + +/// Whether an ancestor of `field` sets the inheritable `key`, which `field` would take on without +/// its own entry. +pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool { + let mut seen = std::collections::HashSet::new(); + let mut parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + return false; + } + let Some(d) = doc.get(p).as_dict().cloned() else { return false }; + if d.contains(key) { + return true; + } + parent = d.reference(b"Parent"); + } + false +} + +/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. +pub(super) fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { + if add.iter().next().is_some() { + doc.update_dict(field, |d| { + for (k, v) in add.iter() { + d.set(k.to_vec(), v.clone()); + } + })?; + } + Ok(()) +} + +/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`. +pub(super) fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { + let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { + let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + d.set(key.to_vec(), Object::Array(kept)); + doc.set(holder, Object::Dict(d)); + Ok(()) + }; + match parent { + Some(p) => remove_from(doc, p, b"Kids")?, + None => { + let root = doc.root().ok_or(FormError::NoForm)?; + match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { + Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, + // A form dictionary held directly in the catalog. + Some(Object::Dict(mut af)) => { + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + af.set(b"Fields".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + } + } + Ok(()) +} + +/// The first free "N" name: not a field's, and not held by a node at the top of the field +/// tree (`held`), such as a parent field left empty by a move, which isn't in `existing`. +pub(super) fn free_name(existing: &[Field], held: &[String], base: &str) -> String { + (1..=u64::MAX) + .map(|n| format!("{base}{n}")) + .find(|c| !held.contains(c) && !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) + .unwrap_or_else(|| base.to_string()) +} + +/// The own names (`/T`) of the nodes in the form's `/Fields`, changing nothing. +pub(super) fn top_names(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()) else { + return Vec::new(); + }; + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() +} diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs new file mode 100644 index 0000000000..62c16ce67d --- /dev/null +++ b/crates/forms/src/calc_order.rs @@ -0,0 +1,480 @@ +//! The calculation order (`/CO`, ISO 32000-2 §12.7.3): the calculated fields in the order they +//! recalculate, the fields each one's formula reads, and what is wrong with that order. + +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}; + +use pdfcraft_cos::{Document, ObjRef, Object}; + +use crate::{Field, FormError, acroform, fields}; + +/// At most this many reads between calculated fields are checked. A group name stands for every +/// field under it, so a form could otherwise make a check build billions of them. +const MAX_LINKS: usize = 1 << 20; + +/// A calculated field, at its place in the calculation order. +#[derive(Clone, Debug, PartialEq)] +pub struct CalculatedField { + /// The field's full name. + pub name: String, + /// The field object. Two calculated fields can share a name in a malformed form; this tells + /// them apart. + pub obj: ObjRef, + /// The fields its formula reads, each once, as the formula names them: a name also stands for + /// the fields under it. A script's names built at run time (`getField("row" + i)`) aren't here. + pub reads: Vec, + /// `/CO` lists it. A field it leaves out calculates after the listed ones. + pub listed: bool, + /// What is wrong with its formula or its place in the order. + pub issues: Vec, +} + +/// Something wrong with a calculated field's formula or its place in the calculation order. +#[derive(Clone, Debug, PartialEq)] +pub enum CalculationIssue { + /// It reads `field`, which the form doesn't have (a field deleted, renamed outside PdfCraft, + /// or mistyped), so the formula gets no value there. + MissingField { field: String }, + /// It reads `field`, which calculates after it, so it uses that field's previous value. + RunsTooEarly { field: String }, + /// Its formula reads its own result: it reads `fields`, which read it back (itself, when it + /// reads itself directly). No order is right for a loop: one of the formulas has to change. + Cycle { fields: Vec }, + /// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers + /// may not calculate it at all. + NotInOrder, + /// Its script also reads fields by names it builds at run time (`getField("row" + i)`). Those + /// aren't in `reads`, so the order isn't checked against them. + BuiltNames, +} + +/// A form's calculation order and what is wrong with it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CalculationOrder { + /// The calculated fields in the order [`crate::recalculate`] runs them: as `/CO` lists them, + /// then the ones it leaves out, in field order. + pub fields: Vec, + /// `/CO` entries that calculate nothing: a field without a calculation, a second entry for a + /// field, or an object that isn't a field (written as `12 0 R`). + pub extra: Vec, + /// Fix order's order, as indexes into `fields`. + fixed: Vec, +} + +impl CalculationOrder { + /// The names in the order Fix order writes: each field after the calculated fields it reads, + /// keeping the current order wherever that allows. Reads within a loop are left as they are. + pub fn fixed(&self) -> Vec { + self.fixed.iter().filter_map(|&i| self.fields.get(i)).map(|f| f.name.clone()).collect() + } + + /// Whether Fix order changes anything: a field moves, joins `/CO`, or an extra entry goes. + pub fn fixable(&self) -> bool { + !self.extra.is_empty() || self.fields.iter().any(|f| !f.listed) || self.fixed.iter().enumerate().any(|(k, &i)| k != i) + } +} + +/// Whether a field has a calculation: a Calculate tab formula or a custom Calculate script. +pub(crate) fn is_calculated(f: &Field) -> bool { + f.actions.calculate != crate::af::Calculate::None || f.actions.scripts.calculate.is_some() +} + +/// The entries of `/CO`, as written. +pub(crate) fn co_entries(doc: &Document) -> Vec { + acroform(doc).and_then(|af| af.get(b"CO").map(|c| doc.resolve(c)).and_then(|c| c.as_array().cloned())).unwrap_or_default() +} + +/// Keep `/CO` in step with whether field `obj` calculates: a field that gets a calculation joins +/// the end of the order, one that loses it leaves, and one whose calculation changes keeps its +/// place, so editing a formula doesn't undo an order set by hand. +pub(crate) fn keep_listed(doc: &mut Document, obj: ObjRef, calculates: bool) -> Result<(), FormError> { + let mut co = co_entries(doc); + match (co.iter().any(|o| o.as_ref() == Some(obj)), calculates) { + (false, true) => co.push(Object::Ref(obj)), + (true, false) => co.retain(|o| o.as_ref() != Some(obj)), + _ => return Ok(()), + } + write_co(doc, co) +} + +/// Write `/CO` (none when `co` is empty), creating the form if needed. +fn write_co(doc: &mut Document, co: Vec) -> Result<(), FormError> { + let af_ref = crate::author::ensure_form(doc)?; + doc.update_dict(af_ref, |d| { + if co.is_empty() { + d.remove(b"CO"); + } else { + d.set(b"CO".to_vec(), Object::Array(co)); + } + })?; + Ok(()) +} + +/// The calculated fields in the order they recalculate, as indexes into `fields` paired with +/// whether `/CO` lists them, and the `/CO` entries that calculate nothing. +pub(crate) fn run_order(doc: &Document, fields: &[Field]) -> (Vec<(usize, bool)>, Vec) { + let mut by_obj: HashMap = HashMap::with_capacity(fields.len()); + for (i, f) in fields.iter().enumerate() { + by_obj.entry(f.obj).or_insert(i); + } + let mut placed = HashSet::new(); + let (mut run, mut extra) = (Vec::new(), Vec::new()); + for entry in co_entries(doc) { + let field = entry.as_ref().and_then(|r| by_obj.get(&r).copied()).and_then(|i| Some((i, fields.get(i)?))); + match field { + Some((i, f)) if is_calculated(f) && placed.insert(i) => run.push((i, true)), + Some((_, f)) => extra.push(f.name.clone()), + None => extra.push(entry.as_ref().map_or_else(|| "not a reference".to_owned(), |r| r.to_string())), + } + } + run.extend(fields.iter().enumerate().filter(|(i, f)| is_calculated(f) && !placed.contains(i)).map(|(i, _)| (i, false))); + (run, extra) +} + +#[cfg(test)] +thread_local! { + /// Read names compared while merging a field's reads. + pub(crate) static NAMES_COMPARED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The form's calculation order (`fields` as [`fields`] reads them), with each calculated field's +/// reads and issues, and Fix order's order. +pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { + let (run, extra) = run_order(doc, fields); + let calculated: Vec<(&Field, bool)> = run.iter().filter_map(|&(i, listed)| Some((fields.get(i)?, listed))).collect(); + let reads: Vec> = calculated + .iter() + .map(|(f, _)| { + let mut names = f.actions.calculate.reads(); + if let Some(js) = &f.actions.scripts.calculate { + let mut known: HashSet = names.iter().cloned().collect(); + for name in crate::af::calculation_reads(js) { + #[cfg(test)] + NAMES_COMPARED.with(|n| n.set(n.get() + 1)); + if known.insert(name.clone()) { + names.push(name); + } + } + } + names + }) + .collect(); + let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads); + let component = components(&links); + let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + all.sort_unstable(); + let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default(); + let mut out = Vec::with_capacity(calculated.len()); + for (k, ((f, listed), reads)) in calculated.iter().zip(reads).enumerate() { + let own = component.get(k).copied(); + let mut issues: Vec = + reads.iter().filter(|name| !found(&all, name)).map(|name| CalculationIssue::MissingField { field: name.clone() }).collect(); + let read = links.get(k).map(Vec::as_slice).unwrap_or_default(); + for &j in read { + if j > k && component.get(j).copied() != own { + issues.push(CalculationIssue::RunsTooEarly { field: name(j) }); + } + } + // Only the loop's fields it reads itself are named, so a loop through thousands of fields + // isn't listed again for each of them. + let looped: Vec = read.iter().filter(|&&j| component.get(j).copied() == own).map(|&j| name(j)).collect(); + if !looped.is_empty() { + issues.push(CalculationIssue::Cycle { fields: looped }); + } + if !listed { + issues.push(CalculationIssue::NotInOrder); + } + if f.actions.scripts.calculate.as_deref().is_some_and(crate::af::reads_built_names) { + issues.push(CalculationIssue::BuiltNames); + } + out.push(CalculatedField { name: f.name.clone(), obj: f.obj, reads, listed: *listed, issues }); + } + let fixed = fixed_order(&links, &component); + CalculationOrder { fields: out, extra, fixed } +} + +/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name or a +/// field under it (`row` for `row.1`), as [`links`], [`crate::recalculate`] and the scripts' +/// `getField` look names up. Acrobat's widget names (`price.0`) aren't fields to any of them, so +/// a read of one gets no value and is missing too. +fn found(names: &[&str], name: &str) -> bool { + let prefix = format!("{name}."); + let under = names.partition_point(|n| *n < prefix.as_str()); + names.binary_search(&name).is_ok() || names.get(under).is_some_and(|n| n.starts_with(&prefix)) +} + +/// For each calculated field (by its place in `names`), the places of the calculated fields it +/// reads, sorted. A read name finds the field of that name and the fields under it, as +/// [`crate::recalculate`] looks values up. +fn links<'a>(names: impl Iterator, reads: &[Vec]) -> Vec> { + let mut sorted: Vec<(&str, usize)> = names.enumerate().map(|(k, n)| (n, k)).collect(); + sorted.sort_unstable(); + let mut budget = MAX_LINKS; + reads + .iter() + .map(|names| { + let mut out = Vec::new(); + for name in names { + let prefix = format!("{name}."); + let exact = sorted.partition_point(|(n, _)| *n < name.as_str()); + let under = sorted.partition_point(|(n, _)| *n < prefix.as_str()); + let same = sorted.get(exact..).unwrap_or_default().iter().take_while(|(n, _)| n == name); + let below = sorted.get(under..).unwrap_or_default().iter().take_while(|(n, _)| n.starts_with(&prefix)); + for &(_, k) in same.chain(below) { + if budget == 0 { + break; + } + budget -= 1; + out.push(k); + } + } + out.sort_unstable(); + out.dedup(); + out + }) + .collect() +} + +/// The strongly connected components of `links` (Tarjan's algorithm, without recursion): one +/// component number per node. Fields in one component read each other in a loop. +fn components(links: &[Vec]) -> Vec { + const UNSEEN: usize = usize::MAX; + let n = links.len(); + let (mut index, mut low, mut component) = (vec![UNSEEN; n], vec![0; n], vec![UNSEEN; n]); + let mut on_stack = vec![false; n]; + let (mut stack, mut work): (Vec, Vec<(usize, usize)>) = (Vec::new(), Vec::new()); + let (mut next, mut count) = (0, 0); + // Every index below is a node: `links` only holds places below `n` (see `links`). + for root in 0..n { + if index[root] != UNSEEN { + continue; + } + work.push((root, 0)); + while let Some((v, e)) = work.pop() { + if e == 0 { + index[v] = next; + low[v] = next; + next += 1; + stack.push(v); + on_stack[v] = true; + } + if let Some(&w) = links[v].get(e) { + work.push((v, e + 1)); + if index[w] == UNSEEN { + work.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(index[w]); + } + continue; + } + if low[v] == index[v] { + pop_component(&mut stack, &mut on_stack, &mut component, v, count); + count += 1; + } + if let Some(&(parent, _)) = work.last() { + low[parent] = low[parent].min(low[v]); + } + } + } + component +} + +fn pop_component(stack: &mut Vec, on_stack: &mut [bool], component: &mut [usize], v: usize, count: usize) { + while let Some(w) = stack.pop() { + on_stack[w] = false; + component[w] = count; + if w == v { + break; + } + } +} + +/// Fix order: every node after the nodes it reads in other components, the earliest ready node +/// first, so an order that is already right stays as it is and otherwise only what must move +/// moves. Reads within a component (a loop) are left out: no order satisfies them. +fn fixed_order(links: &[Vec], component: &[usize]) -> Vec { + let n = links.len(); + let mut waits = vec![0usize; n]; + let mut readers: Vec> = vec![Vec::new(); n]; + for (k, read) in links.iter().enumerate() { + for &j in read { + if component.get(j) != component.get(k) + && let (Some(w), Some(r)) = (waits.get_mut(k), readers.get_mut(j)) + { + *w += 1; + r.push(k); + } + } + } + let mut ready: BinaryHeap> = waits.iter().enumerate().filter(|(_, w)| **w == 0).map(|(k, _)| Reverse(k)).collect(); + let mut out = Vec::with_capacity(n); + while let Some(Reverse(k)) = ready.pop() { + out.push(k); + for &r in readers.get(k).map(Vec::as_slice).unwrap_or_default() { + if let Some(w) = waits.get_mut(r) { + *w = w.saturating_sub(1); + if *w == 0 { + ready.push(Reverse(r)); + } + } + } + } + // Without the reads inside loops no loop is left, so every node is placed; kept total anyway. + if out.len() < n { + let placed: HashSet = out.iter().copied().collect(); + out.extend((0..n).filter(|k| !placed.contains(k))); + } + out +} + +/// Set the calculation order (Acrobat: Set Field Calculation Order): `names` calculate first, in +/// that order, then the calculated fields left out, in their current order. `/CO` is written +/// afresh, so it lists every calculated field once and nothing else. +/// +/// Two calculated fields can share a full name in a malformed form, and the order then names +/// both: a repeated name stands for the next of them in the current order, so the current order +/// sets back unchanged. +pub fn set_calculation_order(doc: &mut Document, names: &[String]) -> Result<(), FormError> { + let all = fields(doc); + let (run, _) = run_order(doc, &all); + let mut by_name: HashMap<&str, VecDeque> = HashMap::with_capacity(run.len()); + for &(i, _) in &run { + if let Some(f) = all.get(i) { + by_name.entry(f.name.as_str()).or_default().push_back(i); + } + } + let mut chosen = HashSet::new(); + let mut co = Vec::with_capacity(names.len()); + for name in names { + let Some(next) = by_name.get_mut(name.as_str()) else { + return Err(if all.iter().any(|f| f.name == *name) { + FormError::Invalid(format!("{name} has no calculation")) + } else { + FormError::NoSuchField(name.clone()) + }); + }; + let i = next.pop_front().ok_or_else(|| FormError::Invalid(format!("{name} is in the order twice")))?; + if let Some(f) = all.get(i) { + chosen.insert(i); + co.push(Object::Ref(f.obj)); + } + } + co.extend(run.iter().filter(|(i, _)| !chosen.contains(i)).filter_map(|&(i, _)| all.get(i)).map(|f| Object::Ref(f.obj))); + write_co(doc, co) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fix order on `links` (node `k` reads `links[k]`), as places. + fn fix(links: &[Vec]) -> Vec { + fixed_order(links, &components(links)) + } + + #[test] + fn loops_are_found_without_recursion() { + // 0 ↔ 1, 2 reads itself, 3 reads 0, 4 alone. + let c = components(&[vec![1], vec![0], vec![2], vec![0], vec![]]); + assert_eq!(c[0], c[1]); + assert!(c[2] != c[0] && c[3] != c[0] && c[4] != c[0] && c[3] != c[4]); + // A chain a million fields long is one pass, not a million nested calls. + let n = 1_000_000; + let chain: Vec> = (0..n).map(|k| if k + 1 < n { vec![k + 1] } else { vec![0] }).collect(); + let c = components(&chain); + assert!(c.iter().all(|x| *x == c[0]), "one loop through every field"); + } + + #[test] + fn fix_order_moves_only_what_must_move() { + // Already right: unchanged. + assert_eq!(fix(&[vec![], vec![0], vec![1], vec![]]), [0, 1, 2, 3]); + // 0 reads 2: it moves after 2, the rest keep their order. + assert_eq!(fix(&[vec![2], vec![], vec![], vec![]]), [1, 2, 0, 3]); + // A chain written backwards comes out forwards. + assert_eq!(fix(&[vec![1], vec![2], vec![]]), [2, 1, 0]); + // A loop (0 ↔ 1) keeps its order; 2, which reads it, goes after it. + assert_eq!(fix(&[vec![1], vec![0], vec![]]), [0, 1, 2]); + assert_eq!(fix(&[vec![], vec![2], vec![1]]), [0, 1, 2]); + assert_eq!(fix(&[vec![1, 2], vec![0], vec![]]), [1, 2, 0], "a loop member still waits for what it reads outside the loop"); + // Reading itself is a loop, not a reason to move. + assert_eq!(fix(&[vec![0], vec![]]), [0, 1]); + } + + #[test] + fn a_name_finds_the_fields_under_it() { + let names = ["row.1", "row", "row.10", "rows", "row-1", "total"]; + let reads = vec![vec![], vec![], vec![], vec![], vec![], vec!["row".to_owned(), "missing".to_owned(), "row".to_owned()]]; + assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1"); + } + + #[test] + fn a_read_name_finds_a_field_or_a_group() { + let mut names = vec!["price", "row.1", "row.2", "rows", "total"]; + names.sort_unstable(); + for name in ["price", "row", "row.1", "rows"] { + assert!(found(&names, name), "{name}"); + } + // A widget name finds nothing: recalculation and getField don't resolve it either. + for name in ["pric", "ro", "row.3", "price.", "price.0", "price.12", "total.0.1", ""] { + assert!(!found(&names, name), "{name}"); + } + } + + /// Two thousand formulas each reading a group of two thousand fields would be four million + /// reads; the checks stop at their cap. + #[test] + fn reads_of_a_large_group_are_capped() { + let n = 2000; + let names: Vec = (0..n).map(|i| format!("g.{i}")).chain((0..n).map(|i| format!("r{i}"))).collect(); + let reads: Vec> = (0..2 * n).map(|i| if i < n { Vec::new() } else { vec!["g".to_owned()] }).collect(); + let total: usize = links(names.iter().map(String::as_str), &reads).iter().map(Vec::len).sum(); + assert_eq!(total, MAX_LINKS); + } + + proptest::proptest! { + /// Fix order is a reordering of the fields after which none reads a field that calculates + /// later, except within a loop; an order that is already right comes back unchanged. + #[test] + fn fix_order_leaves_nothing_too_early(links in proptest::collection::vec(proptest::collection::vec(0usize..12, 0..4), 1..12)) { + let n = links.len(); + let links: Vec> = links.into_iter().map(|mut l| { l.retain(|j| *j < n); l.sort_unstable(); l.dedup(); l }).collect(); + let component = components(&links); + // Components are exactly the nodes that reach each other. + let reach = |from: usize| { + let mut seen = vec![false; n]; + let mut todo = vec![from]; + while let Some(v) = todo.pop() { + for &w in &links[v] { + if !seen[w] { seen[w] = true; todo.push(w); } + } + } + seen + }; + let reaches: Vec> = (0..n).map(reach).collect(); + for a in 0..n { + for b in 0..n { + if a != b { + proptest::prop_assert_eq!(component[a] == component[b], reaches[a][b] && reaches[b][a]); + } + } + } + let fixed = fixed_order(&links, &component); + let mut sorted = fixed.clone(); + sorted.sort_unstable(); + proptest::prop_assert_eq!(sorted, (0..n).collect::>(), "a reordering"); + let mut at = vec![0; n]; + for (place, &k) in fixed.iter().enumerate() { + at[k] = place; + } + for (k, read) in links.iter().enumerate() { + for &j in read { + proptest::prop_assert!(component[j] == component[k] || at[j] < at[k], "{k} reads {j}: {fixed:?}"); + } + } + // Fixed once, the order needs no more moves. + let renumbered: Vec> = fixed.iter().map(|&k| { let mut l: Vec = links[k].iter().map(|&j| at[j]).collect(); l.sort_unstable(); l }).collect(); + proptest::prop_assert_eq!(fix(&renumbered), (0..n).collect::>()); + } + } +} diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index d5bf073b1e..386671f433 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -5,7 +5,7 @@ //! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads //! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan). -use pdfcraft_content::{Matrix, parse}; +use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{Document, Object}; pub mod raster; @@ -136,11 +136,10 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { }, None => Vec::new(), }; - let mut data = Vec::new(); + let mut pieces = Vec::new(); for o in list { if let Object::Stream(s) = &*doc.resolve(&o) { - data.extend(s.decoded().unwrap_or_default()); - data.push(b'\n'); + pieces.push(s.decoded().unwrap_or_default()); } } let mut ctm = Matrix::IDENTITY; @@ -149,7 +148,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { let mut subpaths: Vec = Vec::new(); let mut path = DetectionPath::default(); let mut cur: Option<[f64; 2]> = None; - for op in parse(&data).ops { + for op in Pieces::join(&pieces).parse().ops { match op.op.as_slice() { b"q" => stack.push(ctm), b"Q" => ctm = stack.pop().unwrap_or(ctm), @@ -308,7 +307,7 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String { .iter() .filter(|w| w.rect[1] >= rect[3] - 1.0 && w.rect[1] - rect[3] < 24.0 && w.rect[0] < rect[2] && w.rect[2] > rect[0] - 4.0) .collect(); - let Some(line) = above.iter().map(|w| mid(w.rect)).min_by(|a, b| a.total_cmp(b)) else { return String::new() }; + let Some(line) = above.iter().map(|w| mid(w.rect)).min_by(f64::total_cmp) else { return String::new() }; let mut on: Vec<&&Word> = above.iter().filter(|w| (mid(w.rect) - line).abs() < 3.0).collect(); on.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0])); clean(&on.iter().map(|w| w.text.as_str()).collect::>().join(" ")) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 1dfa767262..775306d865 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -7,30 +7,55 @@ //! ([`appearance`]); check boxes and radio buttons switch `/V` and each widget's `/AS` between //! the states their appearances already define. //! - [`reset`]: Acrobat's Clear form (back to `/DV`). +//! - [`table`]: a table of text fields, laid out and named before any of it is added. //! //! Not yet: JavaScript actions (format, keystroke, validate, calculate — M6.4/M6.5) and rich text //! values (`/RV`, which is removed when a value is set so it can't contradict `/V`). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString}; mod actions; pub mod af; pub mod appearance; mod author; +mod calc_order; pub mod detect; mod scripting; -pub use actions::{FieldAction, Trigger, field_actions, set_field_actions}; +pub mod table; +pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; pub use author::{ - BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, NewField, add_field, check_style, delete_field, duplicate_field, look, - redraw_field, set_button_icon, set_props, + BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, + delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props, }; +pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script, set_field_script, }; +mod appearances; +mod read; +mod tab_order; +mod values; + +pub use appearances::{FieldAppearanceCache, declares_pdfa, need_appearances, undrawn_fields, with_field_appearances, with_field_appearances_cached}; +pub(crate) use read::page_refs; +pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted}; +#[cfg(test)] +use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; +use read::{script, script_budget}; +pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; +#[cfg(test)] +use values::has_password_flag; +use values::write_value; +pub(crate) use values::{ask_viewers_to_redraw, merged_appearance}; +pub use values::{ + calculations_changed, clear_password_values, has_password_values, password_values, recalculate, recalculate_with, reset, restore_password_values, + set_value, set_value_with, set_values, +}; + #[cfg(test)] mod tests; @@ -44,6 +69,10 @@ pub enum FormError { ReadOnly(String), #[error("{0}")] Invalid(String), + /// The value doesn't fit the field's format (its keystroke check refused it), which is + /// checked before any of the field's scripts run. + #[error("{0}")] + Format(String), #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -101,12 +130,18 @@ pub struct Widget { pub locked: bool, /// The widget's Hidden or NoView flag (`/F` bit 2 or 6): it isn't shown and takes no input. pub hidden: bool, + /// Read-only: the field is, or the widget's own `/Ff` says so (a radio button of a group can + /// be locked on its own, as an XFA exclusion group's layout writes it). + pub read_only: bool, /// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0. pub rotation: i64, /// Push buttons: the normal label (`/MK /CA`), separate from the field's name. pub caption: Option, /// List boxes: where the options are drawn, so a click can pick the one under the pointer. pub list: Option, + /// Drawn round: a radio button, or a check box laid out for a round XFA check button + /// (`/PCXfaRound true`). + pub round: bool, } /// A terminal form field. @@ -141,6 +176,12 @@ impl Field { self.flags & flag != 0 } + /// A comb field: the Comb flag set with a `/MaxLen`, neither multiline nor password (ISO + /// 32000-2 Table 228). Each cell holds one character. + pub fn is_comb(&self) -> bool { + self.has(flags::COMB) && !self.has(flags::MULTILINE) && !self.has(flags::PASSWORD) && self.max_len.is_some_and(|m| m > 0) + } + pub fn read_only(&self) -> bool { self.has(flags::READ_ONLY) } @@ -179,6 +220,15 @@ impl Field { self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state) } + /// Values for data export, resolving button appearance names through /Opt. + pub fn export_values(&self) -> Vec { + if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) { + self.value.iter().map(|v| self.export_for_state(v).to_string()).collect() + } else { + self.value.clone() + } + } + /// The value as one string: text, the state name, or the selected display texts. pub fn display_value(&self) -> String { match self.kind { @@ -253,1086 +303,6 @@ fn name_obj(text: &str) -> Object { Object::Name(name_bytes(text)) } -/// At most this many `/State` entries of a set-layer-visibility action are read. -const MAX_LAYER_STATE: usize = 1024; - -/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). -fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option { - // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): - // clicking it picks a picture, as `buttonImportIcon()` does. - if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { - return Some(af::ButtonAction::ImportIcon); - } - let pick = |dict: &Dict| -> Option { - dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned()))) - }; - let action = pick(d).or_else(|| widgets.iter().find_map(|w| doc.get(*w).as_dict().and_then(pick)))?; - let a = doc.resolve(&action); - let a = a.as_dict()?; - let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); - Some(match a.name(b"S")? { - b"ResetForm" => { - let fields = a - .get(b"Fields") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match o { - Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), - other => text_of(&doc.resolve(other)), - }) - .collect(); - let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; - af::ButtonAction::Reset { fields, exclude } - } - b"Named" => af::ButtonAction::Named(String::from_utf8_lossy(a.name(b"N")?).into_owned()), - b"URI" => af::ButtonAction::Uri(s(b"URI")?), - b"SubmitForm" => af::ButtonAction::Submit( - a.get(b"F") - .map(|f| doc.resolve(f)) - .and_then(|f| f.as_dict().and_then(|fd| fd.get(b"F").and_then(|x| text_of(&doc.resolve(x)))).or_else(|| text_of(&f))) - .unwrap_or_default(), - ), - b"GoTo" => { - let dest = doc.resolve(a.get(b"D")?); - let target = dest.as_array()?.first()?.as_ref()?; - af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) - } - b"Hide" => { - // /T: a field name, an annotation (or field) reference, or an array of them. - let target = |o: &Object| match o { - Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))), - other => text_of(other), - }; - let fields = match a.get(b"T") { - Some(t) => match &*doc.resolve(t) { - Object::Array(items) => items.iter().filter_map(target).collect(), - _ => target(t).into_iter().collect(), - }, - None => Vec::new(), - }; - let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))); - af::ButtonAction::ShowHide { fields, hide } - } - b"SetOCGState" => { - // /State: ON, OFF or Toggle, each followed by the groups it applies to. - let mut changes = Vec::new(); - if let Some(state) = a.get(b"State") { - let mut op = None; - for item in doc.resolve(state).as_array().into_iter().flatten().take(MAX_LAYER_STATE) { - match item { - Object::Name(n) => { - op = match n.as_slice() { - b"ON" => Some(af::LayerOp::On), - b"OFF" => Some(af::LayerOp::Off), - b"Toggle" => Some(af::LayerOp::Toggle), - _ => None, - } - } - Object::Ref(r) => changes.extend(op.map(|op| (op, (r.num, r.generation)))), - _ => {} - } - } - } - let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false))); - af::ButtonAction::SetLayers { changes, preserve_rb } - } - b"JavaScript" => af::button_script(&script(doc, &action)?), - _ => return None, - }) -} - -/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, -/// from the partial names (`/T`) up its `/Parent` chain. -fn qualified_name(doc: &Document, r: ObjRef) -> Option { - let mut parts = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut cur = Some(r); - while let Some(c) = cur { - if seen.len() > 64 || !seen.insert(c) { - break; - } - let obj = doc.get(c); - let d = obj.as_dict()?; - if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { - parts.push(t); - } - cur = d.get(b"Parent").and_then(|p| p.as_ref()); - } - parts.reverse(); - (!parts.is_empty()).then(|| parts.join(".")) -} - -/// The JavaScript of an action dictionary (`/JS` string or stream). -fn script(doc: &Document, action: &Object) -> Option { - let a = doc.resolve(action); - let js = a.as_dict()?.get(b"JS")?.clone(); - match &*doc.resolve(&js) { - Object::String(s) => Some(s.to_text()), - Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), - _ => None, - } -} - -/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only -/// widget's when the field has none). -fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions { - let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { - (widgets.len() == 1) - .then(|| doc.get(widgets[0])) - .and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a)))) - .and_then(|a| a.as_dict().cloned()) - }); - let mut out = af::Actions::default(); - let Some(aa) = aa else { return out }; - let js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a)); - if let Some(f) = js(b"F") { - match af::parse_format(&f) { - Some(fm) => out.format = fm, - None if !f.trim().is_empty() => { - out.unsupported.push("format"); - out.scripts.format = Some(f); - } - None => {} - } - } - if let Some(k) = js(b"K") - && out.format == af::Format::None - { - // A mask has only a keystroke script. - match af::parse_format(&k) { - Some(fm @ af::Format::Mask(_)) => out.format = fm, - Some(_) => {} - None if !k.trim().is_empty() => { - out.unsupported.push("keystroke"); - out.scripts.keystroke = Some(k); - } - None => {} - } - } - if let Some(v) = js(b"V") { - match af::parse_validate(&v) { - Some(vv) => out.validate = vv, - None if !v.trim().is_empty() => { - out.unsupported.push("validate"); - out.scripts.validate = Some(v); - } - None => {} - } - } - if let Some(c) = js(b"C") { - match af::parse_calculate(&c) { - Some(cc) => out.calculate = cc, - None if !c.trim().is_empty() => { - out.unsupported.push("calculate"); - out.scripts.calculate = Some(c); - } - None => {} - } - } - out -} - -fn values_of(doc: &Document, o: Option<&Object>) -> Vec { - let Some(o) = o else { return Vec::new() }; - match &*doc.resolve(o) { - Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(), - Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(), - other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(), - } -} - -fn nums(doc: &Document, o: Option<&Object>) -> Option> { - let o = doc.resolve(o?); - o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() -} - -/// Leaf page objects in order (a small walker; `organize` and `annot` have their own). -pub(crate) fn page_refs(doc: &Document) -> Vec { - let Some(root) = doc.root() else { return Vec::new() }; - let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; - let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let mut stack = vec![pages]; - while let Some(node) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - match d.get(b"Kids").map(|k| doc.resolve(k)) { - Some(kids) if d.name(b"Type") != Some(b"Page") => { - if let Some(a) = kids.as_array() { - stack.extend(a.iter().rev().filter_map(|k| k.as_ref())); - } - } - _ => out.push(node), - } - } - out -} - -/// The AcroForm dictionary. -pub fn acroform(doc: &Document) -> Option { - let root = doc.root()?; - let cat = doc.get(root); - let af = cat.as_dict()?.get(b"AcroForm")?.clone(); - doc.resolve(&af).as_dict().cloned() -} - -#[derive(Clone, Default)] -struct Inherited { - name: String, - ft: Option>, - ff: Option, - v: Option, - dv: Option, - da: Option, - q: Option, - max_len: Option, -} - -/// Every terminal field: the `/Fields` tree in order, then the fields reachable only through the -/// page annotations (see [`adopt_page_fields`]). -pub fn fields(doc: &Document) -> Vec { - fields_with_adopted(doc).items -} - -/// How many of [`fields`] were reachable only through the page annotations: the form's `/Fields` -/// list names none or only some of them, so the leniency that adopts them is worth recording. -pub fn adopted_page_fields(doc: &Document) -> usize { - fields_with_adopted(doc).adopted_page_fields -} - -/// The terminal fields and page-only fields adopted while reading a form. -#[derive(Clone, Debug, PartialEq)] -pub struct Fields { - /// Terminal fields in tree order, followed by adopted page-only fields. - pub items: Vec, - /// How many fields were reachable only through page annotations. - pub adopted_page_fields: usize, -} - -/// Read all terminal fields and the page-only adoption count in one enumeration. -pub fn fields_with_adopted(doc: &Document) -> Fields { - let (items, adopted_page_fields) = enumerate(doc); - Fields { items, adopted_page_fields } -} - -fn enumerate(doc: &Document) -> (Vec, usize) { - let af = acroform(doc); - let mut page_of = std::collections::HashMap::new(); - let mut annot_index = std::collections::HashMap::new(); - let pages = page_refs(doc); - for (i, p) in pages.iter().enumerate() { - if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { - for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { - if let Some(r) = e.as_ref() { - page_of.entry(r).or_insert(i); - annot_index.entry(r).or_insert(k); - } - } - } - } - let base = match &af { - Some(af) => Inherited { - da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))), - q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()), - ..Default::default() - }, - None => Inherited::default(), - }; - let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); - if let Some(af) = &af { - for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { - if let Some(r) = f.as_ref() { - walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); - } - } - } - let listed = out.len(); - adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out); - let adopted = out.len() - listed; - rank_tabs(doc, &pages, &annot_index, &mut out); - (out, adopted) -} - -/// Widget annotations no `/Fields` entry reaches are adopted as fields, as Acrobat and the -/// browsers do: some writers list only some of the fields, or none at all. Each widget is adopted -/// through its topmost unlisted `/Parent`, so a field split across several widgets stays one -/// field. Tree-listed fields keep their order first; the rest follow in page-annotation order. -fn adopt_page_fields( - doc: &Document, - pages: &[ObjRef], - base: &Inherited, - page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, - out: &mut Vec, -) { - for p in pages { - let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; - let annots = doc.resolve(&a); - let Some(annots) = annots.as_array() else { continue }; - for e in annots { - let Some(r) = e.as_ref() else { continue }; - if seen.contains(&r) { - continue; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - if d.name(b"Subtype") != Some(b"Widget") { - continue; - } - if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { - continue; - } - if let Some(root) = unlisted_root(doc, r, seen) { - walk(doc, root, base, page_of, seen, out, 0); - } - } - } -} - -/// The topmost field above `r` (itself without a `/Parent`), or `None` when `r` or an ancestor is -/// already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. -fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet) -> Option { - let mut top = r; - let mut visited = std::collections::HashSet::from([r]); - loop { - let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent")); - let Some(p) = parent else { - return if seen.contains(&top) { None } else { Some(top) }; - }; - if !visited.insert(p) || visited.len() > 64 { - return None; - } - if seen.contains(&p) { - return None; - } - top = p; - } -} - -/// A page's tab order (`/Tabs`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TabOrder { - /// Rows, top to bottom, left to right within a row (`/R`). - Row, - /// Columns, left to right, top to bottom within a column (`/C`). - Column, - /// The document structure (`/S`; annotation order when the file has none). - Structure, - /// Unspecified: annotation order. - Annotations, -} - -impl TabOrder { - fn of(name: Option<&[u8]>) -> TabOrder { - match name { - Some(b"R") => TabOrder::Row, - Some(b"C") => TabOrder::Column, - Some(b"S") => TabOrder::Structure, - _ => TabOrder::Annotations, - } - } -} - -/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). -type TabItem = (usize, usize, usize, [f64; 4], usize); - -/// Every widget's place in the tab order. -fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::collections::HashMap, out: &mut [Field]) { - let mut items: Vec = Vec::new(); - for (fi, f) in out.iter().enumerate() { - for (wi, w) in f.widgets.iter().enumerate() { - if let Some(p) = w.page { - items.push((p, fi, wi, w.rect, annot_index.get(&w.obj).copied().unwrap_or(usize::MAX))); - } - } - } - let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); - for (pi, p) in pages.iter().enumerate() { - let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); - if on.is_empty() { - continue; - } - let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs"))); - match tabs { - TabOrder::Row | TabOrder::Column => { - let row = tabs == TabOrder::Row; - // Primary axis: rows by top edge (down the page), columns by left edge. - on.sort_by(|a, b| if row { b.3[3].total_cmp(&a.3[3]) } else { a.3[0].total_cmp(&b.3[0]) }); - let mut groups: Vec> = Vec::new(); - for it in on { - let fits = groups.last().is_some_and(|g| { - let first = g[0]; - if row { - (first.3[3] - it.3[3]).abs() < ((first.3[3] - first.3[1]) / 2.0).max(2.0) - } else { - (it.3[0] - first.3[0]).abs() < ((first.3[2] - first.3[0]) / 2.0).max(2.0) - } - }); - if fits && let Some(g) = groups.last_mut() { - g.push(it); - } else { - groups.push(vec![it]); - } - } - for mut g in groups { - g.sort_by(|a, b| if row { a.3[0].total_cmp(&b.3[0]) } else { b.3[3].total_cmp(&a.3[3]) }); - ordered.extend(g.iter().map(|i| (i.1, i.2))); - } - } - TabOrder::Structure | TabOrder::Annotations => { - on.sort_by_key(|i| i.4); - ordered.extend(on.iter().map(|i| (i.1, i.2))); - } - } - } - for f in out.iter_mut() { - for w in &mut f.widgets { - w.tab = usize::MAX; - } - } - for (rank, (fi, wi)) in ordered.into_iter().enumerate() { - out[fi].widgets[wi].tab = rank; - } -} - -/// Set the tab order of pages (0-based). -pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { - let refs = page_refs(doc); - for &p in pages { - let r = *refs.get(p).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", p + 1)))?; - doc.update_dict(r, |d| match order { - TabOrder::Row => d.set(b"Tabs".to_vec(), Object::name("R")), - TabOrder::Column => d.set(b"Tabs".to_vec(), Object::name("C")), - TabOrder::Structure => d.set(b"Tabs".to_vec(), Object::name("S")), - TabOrder::Annotations => { - d.remove(b"Tabs"); - } - })?; - } - Ok(()) -} - -fn walk( - doc: &Document, - r: ObjRef, - parent: &Inherited, - page_of: &std::collections::HashMap, - seen: &mut std::collections::HashSet, - out: &mut Vec, - depth: usize, -) { - if depth > 64 || !seen.insert(r) { - return; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { return }; - let mut inh = parent.clone(); - if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { - inh.name = if inh.name.is_empty() { t } else { format!("{}.{t}", inh.name) }; - } - if let Some(ft) = d.name(b"FT") { - inh.ft = Some(ft.to_vec()); - } - if let Some(ff) = field_flags(doc, d) { - inh.ff = Some(ff); - } - for (k, slot) in [(&b"V"[..], &mut inh.v), (b"DV", &mut inh.dv)] { - if let Some(v) = d.get(k) { - *slot = Some(v.clone()); - } - } - if let Some(da) = d.get(b"DA").and_then(|o| text_of(&doc.resolve(o))) { - inh.da = Some(da); - } - if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) { - inh.q = Some(q); - } - if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) { - inh.max_len = usize::try_from(m).ok(); - } - let kids: Vec = - d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); - // Kids with /T are fields; kids without are this field's widgets. - let field_kids: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); - if !field_kids.is_empty() { - for k in field_kids { - walk(doc, k, &inh, page_of, seen, out, depth + 1); - } - return; - } - let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; - let actions = actions_of(doc, d, &widget_refs); - let button = - (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0).then(|| button_action(doc, d, &widget_refs)).flatten(); - let ff = inh.ff.unwrap_or(0); - let kind = match inh.ft.as_deref() { - Some(b"Tx") => FieldKind::Text, - Some(b"Btn") if ff & flags::PUSH_BUTTON != 0 => FieldKind::PushButton, - Some(b"Btn") if ff & flags::RADIO != 0 => FieldKind::Radio, - Some(b"Btn") => FieldKind::CheckBox, - Some(b"Ch") if ff & flags::COMBO != 0 => FieldKind::Combo, - Some(b"Ch") => FieldKind::List, - Some(b"Sig") => FieldKind::Signature, - _ => return, - }; - let widgets = widget_refs - .into_iter() - .filter_map(|w| { - let wo = doc.get(w); - let wd = wo.as_dict()?; - let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]); - let annot_flags = wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - let on_state = wd - .get(b"AP") - .map(|ap| doc.resolve(ap)) - .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) - .and_then(|n| doc.resolve(&n).as_dict().cloned()) - .and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off")); - Some(Widget { - obj: w, - page: page_of.get(&w).copied(), - rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])], - on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)), - state: wd.name(b"AS").map(name_text), - tab: usize::MAX, - locked: annot_flags & 128 != 0, - hidden: annot_flags & (2 | 32) != 0, - rotation: appearance::mk_rotation(doc, wd), - caption: (kind == FieldKind::PushButton) - .then(|| { - let mk = doc.resolve(wd.get(b"MK")?); - let value = doc.resolve(mk.as_dict()?.get(b"CA")?); - value.as_string().map(|s| s.to_text()) - }) - .flatten(), - list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), - }) - }) - .collect(); - let options = d - .get(b"Opt") - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|o| match &*doc.resolve(o) { - Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), - other => text_of(other).map(|t| (t.clone(), t)), - }) - .collect(); - out.push(Field { - name: inh.name.clone(), - obj: r, - kind, - value: values_of(doc, inh.v.as_ref()), - default: values_of(doc, inh.dv.as_ref()), - flags: ff, - max_len: inh.max_len, - options, - da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), - quadding: inh.q.unwrap_or(0), - tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), - widgets, - actions, - button, - }); -} - -fn field_flags(doc: &Document, dict: &Dict) -> Option { - dict.get(b"Ff").and_then(|value| doc.resolve(value).as_int()).and_then(|value| u32::try_from(value).ok()) -} - -// ── writing ───────────────────────────────────────────────────────────────────────────────── - -/// Fill the field `name`. -pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> { - set_value_with(doc, name, value, &mut NoScripts) -} - -/// [`set_value`], running the fields' JavaScript through `scripts`. -pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - let all = fields(doc); - if all.is_empty() { - return Err(FormError::NoForm); - } - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - if f.read_only() { - return Err(FormError::ReadOnly(name.into())); - } - write_value(doc, f, value, scripts)?; - // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a - // name as separate field dictionaries, instead of widgets under one parent, get the value - // the first one took after its scripts, without running them again. - let twins: Vec<&Field> = all.iter().filter(|t| t.name == name && t.obj != f.obj && t.kind == f.kind).collect(); - if !twins.is_empty() { - let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); - for t in twins { - share_value(doc, f.obj, t, &taken, scripts)?; - } - } - recalculate_with(doc, scripts)?; - Ok(()) -} - -/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own -/// widgets. -fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { - match twin.kind { - FieldKind::CheckBox => { - let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) - } - FieldKind::Radio => { - let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); - set_states(doc, twin, choice.map(String::as_str)) - } - FieldKind::Text | FieldKind::Combo | FieldKind::List => { - let src = doc.get(source).as_dict().cloned().unwrap_or_default(); - doc.update_dict(twin.obj, |d| { - for key in [&b"V"[..], b"I"] { - match src.get(key) { - Some(v) => d.set(key.to_vec(), v.clone()), - None => { - d.remove(key); - } - } - } - d.remove(b"RV"); - })?; - redraw(doc, twin, value, scripts) - } - FieldKind::PushButton | FieldKind::Signature => Ok(()), - } -} - -/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other -/// calculated fields), as Acrobat does after any value changes. Returns how many changed. -pub fn recalculate(doc: &mut Document) -> Result { - recalculate_with(doc, &mut NoScripts) -} - -/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. -pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { - let mut all = fields(doc); - let calculated = |f: &Field| f.actions.calculate != af::Calculate::None || f.actions.scripts.calculate.is_some(); - if !all.iter().any(calculated) { - return Ok(0); - } - let co: Vec = acroform(doc) - .and_then(|af| af.get(b"CO").map(|c| doc.resolve(c)).and_then(|c| c.as_array().cloned())) - .unwrap_or_default() - .iter() - .filter_map(Object::as_ref) - .collect(); - let mut order: Vec = co.iter().filter_map(|r| all.iter().find(|f| f.obj == *r)).map(|f| f.name.clone()).collect(); - for f in &all { - if calculated(f) && !order.contains(&f.name) { - order.push(f.name.clone()); - } - } - let mut changed = 0; - for name in order { - let Some(f) = all.iter().find(|f| f.name == name).cloned() else { continue }; - let lookup = |n: &str| -> Vec { - let prefix = format!("{n}."); - all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() - }; - let current = f.value.first().cloned().unwrap_or_default(); - let v = if let Some(js) = &f.actions.scripts.calculate { - let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all); - scripting::apply_changes(doc, &r.changes, &f.name)?; - if !r.changes.is_empty() { - all = fields(doc); - } - if !r.rc { - continue; - } - r.value - } else { - let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; - v - }; - if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; - redraw(doc, &f, std::slice::from_ref(&v), scripts)?; - if let Some(target) = all.iter_mut().find(|x| x.name == name) { - target.value = vec![v]; - } - changed += 1; - } - } - Ok(changed) -} - fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } - -fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { - match (f.kind, value) { - (FieldKind::Text, FieldValue::Text(t)) => { - if let Some(max) = f.max_len - && t.chars().count() > max - { - return invalid(format!("{:?} takes at most {max} characters", f.name)); - } - // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. - let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Invalid)?; - af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; - let t = &scripting::accept(doc, f, t, scripts)?; - doc.update_dict(f.obj, |d| { - if t.is_empty() { - d.remove(b"V"); - } else { - d.set(b"V".to_vec(), PdfString::text(t)); - } - d.remove(b"RV"); - })?; - redraw(doc, f, std::slice::from_ref(t), scripts) - } - (FieldKind::CheckBox, v) => { - let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - let on = match v { - FieldValue::Check(on) => *on, - // The on-state name or a yes/no word also work (agents, FDF-style data). - FieldValue::Text(t) | FieldValue::Radio(Some(t)) => { - let t = t.trim(); - if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) { - true - } else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) { - false - } else { - return invalid(format!("{:?} is a check box: use true or false", f.name)); - } - } - FieldValue::Radio(None) => false, - FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), - }; - set_states(doc, f, on.then_some(state.as_str())) - } - (FieldKind::Radio, v) => { - let choice = match v { - FieldValue::Radio(c) => c.clone(), - FieldValue::Text(t) if t.is_empty() => None, - FieldValue::Text(t) => Some(t.clone()), - FieldValue::Check(false) => None, - _ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)), - }; - // An export value from /Opt selects its widget's on state. - let choice = choice.map(|c| f.state_for(&c).map(str::to_owned).unwrap_or(c)); - if let Some(c) = &choice - && !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str())) - { - let opts: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect(); - return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", "))); - } - if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() { - return invalid(format!("{:?} must keep one option selected", f.name)); - } - set_states(doc, f, choice.as_deref()) - } - (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { - let vals: Vec = match value { - FieldValue::Choice(v) => v.clone(), - FieldValue::Text(t) if t.is_empty() => Vec::new(), - FieldValue::Text(t) => vec![t.clone()], - other => return invalid(format!("{:?} can't take {other:?}", f.name)), - }; - if vals.len() > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { - return invalid(format!("{:?} takes a single value", f.name)); - } - // Accept export values or display texts; free text only in editable combo boxes. - let mut exports = Vec::new(); - for v in &vals { - match f.options.iter().find(|(e, d)| e == v || d == v) { - Some((e, _)) => exports.push(e.clone()), - None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), - None if f.options.is_empty() => exports.push(v.clone()), - None => { - let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); - return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); - } - } - } - let mut indices: Vec = - exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect(); - // /I lists option indices in ascending order; /V keeps the caller's value order. - // Every entry above is an integer, so this sorts by its numeric option index. - indices.sort_unstable_by_key(Object::as_int); - // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as - // in other viewers: a pick below the last visible row would otherwise not show (#1524). - let top = match (f.kind, f.widgets.first()) { - (FieldKind::List, Some(w)) => w.list.map(|rows| { - let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; - let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); - (rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len())) - }), - _ => None, - }; - doc.update_dict(f.obj, |d| { - match exports.as_slice() { - [] => { - d.remove(b"V"); - } - [one] => d.set(b"V".to_vec(), PdfString::text(one)), - many => d.set(b"V".to_vec(), Object::Array(many.iter().map(|e| Object::String(PdfString::text(e))).collect())), - } - if indices.is_empty() { - d.remove(b"I"); - } else { - d.set(b"I".to_vec(), Object::Array(indices)); - } - if let Some((old, new)) = top - && old != new - { - d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); - } - })?; - // A widget with its own `/TI` (read before the field's) scrolls too. - if let Some((old, new)) = top - && old != new - { - for w in f.widgets.iter().filter(|w| w.obj != f.obj) { - if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) { - doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; - } - } - } - redraw(doc, f, &exports, scripts) - } - (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), - (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), - (kind, v) => invalid(format!("{:?} is a {kind:?} field and can't take {v:?}", f.name)), - } -} - -/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. -fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { - let v = on.unwrap_or("Off"); - doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?; - for w in &f.widgets { - let state = match (on, w.on_state.as_deref()) { - (Some(c), Some(s)) if c == s => s, - _ => "Off", - }; - // A widget without appearances for its states gets PdfCraft's own. - let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); - if !has_ap { - let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); - let ap = appearance::check_box_states(doc, w, f.kind, &on_name); - doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; - } - doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; - } - Ok(()) -} - -/// Copy an existing appearance dictionary before replacing its normal appearance. -fn appearance_dict(doc: &Document, widget: ObjRef) -> Dict { - doc.get(widget).as_dict().and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default() -} - -/// `widget`'s appearance dictionary (a copy, so a shared one is left alone) with the freshly -/// drawn entries of `fresh` (its `/N`). Other entries are kept, but the old down and rollover -/// appearances (`/D`, `/R`) would show the old value, caption or colour on press or hover, so -/// they go — except, for check boxes and radio buttons (whose `/N` is a dictionary of states), -/// the states the new `/N` still draws under the same names. -pub(crate) fn merged_appearance(doc: &Document, widget: ObjRef, fresh: &Dict) -> Dict { - let mut apd = appearance_dict(doc, widget); - // Only a dictionary of states counts (`as_dict` would also see a stream's own dictionary). - let states_of = |o: &Object| match &*doc.resolve(o) { - Object::Dict(d) => Some(d.clone()), - _ => None, - }; - let states: Option>> = fresh.get(b"N").and_then(states_of).map(|d| d.iter().map(|(k, _)| k.clone()).collect()); - for key in [&b"D"[..], &b"R"[..]] { - if fresh.contains(key) { - continue; - } - let kept = states.as_ref().and_then(|names| { - let old = apd.get(key).and_then(states_of)?; - let mut d = Dict::new(); - for (k, v) in old.iter().filter(|(k, _)| names.contains(k)) { - d.set(k.clone(), v.clone()); - } - (!d.is_empty()).then_some(d) - }); - match kept { - Some(d) => apd.set(key.to_vec(), Object::Dict(d)), - None => { - apd.remove(key); - } - } - } - for (k, v) in fresh.iter() { - apd.set(k.clone(), v.clone()); - } - apd -} - -/// Regenerate the normal appearance of every widget of a text or choice field. -fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { - let shown = match values { - [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), - _ => None, - }; - let mut undrawn = false; - for w in &f.widgets { - let (stream, missing) = match &shown { - Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), - None => appearance::field_appearance_drawn(doc, f, w, values, true), - }; - undrawn |= missing.is_some(); - let ap = doc.add(Object::Stream(stream)); - let mut fresh = Dict::new(); - fresh.set(b"N".to_vec(), Object::Ref(ap)); - let apd = merged_appearance(doc, w.obj, &fresh); - doc.update_dict(w.obj, |d| { - d.set(b"AP".to_vec(), Object::Dict(apd)); - d.remove(b"AS"); - pdfcraft_fonts::set_undrawn_mark(d, missing); - })?; - } - if undrawn { - ask_viewers_to_redraw(doc)?; - } - Ok(()) -} - -/// A value its widget's font can't show is stored as typed and drawn with `?` (as Acrobat keeps -/// such a value): `/NeedAppearances` then asks other viewers to draw the fields from their values -/// with fonts that have those characters (the interactive form dictionary, §12.7.3). Not in a -/// document that declares PDF/A, which forbids it: there the widget's mark -/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere. -pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> { - if declares_pdfa(doc) || need_appearances(doc) { - return Ok(()); - } - let Some(root) = doc.root() else { return Ok(()) }; - let entry = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()); - match entry { - Some(Object::Ref(r)) => doc.update_dict(r, |d| d.set(b"NeedAppearances".to_vec(), Object::Bool(true)))?, - Some(Object::Dict(mut af)) => { - af.set(b"NeedAppearances".to_vec(), Object::Bool(true)); - doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - Ok(()) -} - -/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). -pub fn need_appearances(doc: &Document) -> bool { - acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) -} - -/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`). -pub fn declares_pdfa(doc: &Document) -> bool { - let Some(root) = doc.root() else { return false }; - let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false }; - let m = doc.resolve(&m); - match &*m { - Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")), - _ => false, - } -} - -/// Every field with a widget whose appearance PdfCraft drew with `?` for a character its font -/// can't hold (`pdfcraft_fonts::UNDRAWN_MARK`): the field's name and that character, once per -/// field. -pub fn undrawn_fields(doc: &Document) -> Vec<(String, char)> { - fields(doc) - .into_iter() - .filter_map(|f| { - let c = f.widgets.iter().find_map(|w| doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark))?; - Some((f.name, c)) - }) - .collect() -} - -/// Acrobat's Clear form: every field (or the named ones) back to its default value. -pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { - let all = fields(doc); - if all.is_empty() { - return Err(FormError::NoForm); - } - if let Some(n) = names - && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n)) - { - return Err(FormError::NoSuchField(missing.clone())); - } - let mut changed = 0; - for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) { - if f.value == f.default { - continue; - } - let v = match f.kind { - FieldKind::Text => FieldValue::Text(f.default.first().cloned().unwrap_or_default()), - FieldKind::CheckBox => FieldValue::Check(!f.default.is_empty()), - FieldKind::Radio => FieldValue::Radio(f.default.first().cloned()), - FieldKind::Combo | FieldKind::List => FieldValue::Choice(f.default.clone()), - FieldKind::PushButton | FieldKind::Signature => continue, - }; - // A reset is not blocked by NoToggleToOff: go through the writer directly. - let mut tmp = f.clone(); - tmp.flags &= !flags::NO_TOGGLE_TO_OFF; - write_value(doc, &tmp, &v, &mut NoScripts)?; - changed += 1; - } - recalculate(doc)?; - Ok(changed) -} - -/// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field): -/// move field `name` one place earlier or later in its page's tab order. The page switches to -/// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other -/// annotations keep their places. -pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> { - let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; - // The page's fields in their current tab order (a field counts once, at its first widget). - let mut order: Vec<(usize, &str)> = - all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect(); - order.sort(); - let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect(); - let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) }; - let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; - let Some(j) = j else { return Ok(()) }; - let mut names = names.into_iter().map(str::to_string).collect::>(); - names.swap(i, j); - // The widgets of this page in the new order. - let mut widgets: Vec = Vec::new(); - for n in &names { - if let Some(g) = all.iter().find(|g| &g.name == n) { - widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)); - } - } - let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?; - let annots_obj = - doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()).ok_or_else(|| FormError::Invalid("the page has no annotations".into()))?; - let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default(); - let mut next = widgets.into_iter(); - let set: std::collections::HashSet = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect(); - let new_list: Vec = list - .iter() - .map(|o| match o.as_ref() { - Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), - _ => o.clone(), - }) - .collect(); - match annots_obj { - Object::Ref(ar) => doc.set(ar, Object::Array(new_list)), - _ => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(new_list)))?, - } - doc.update_dict(pr, |d| { - d.remove(b"Tabs"); - })?; - Ok(()) -} diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs new file mode 100644 index 0000000000..e689c0e813 --- /dev/null +++ b/crates/forms/src/read.rs @@ -0,0 +1,568 @@ +use super::tab_order::rank_tabs; +use super::{DecodeBudget, Dict, Document, Field, FieldKind, ObjRef, Object, PdfString, Widget, af, appearance, flags, name_text, text_of}; + +/// At most this many `/State` entries of a set-layer-visibility action are read. +const MAX_LAYER_STATE: usize = 1024; + +/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget). +fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option { + // A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action): + // clicking it picks a picture, as `buttonImportIcon()` does. + if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { + return Some(af::ButtonAction::ImportIcon); + } + let pick = |dict: &Dict| -> Option { + dict.get(b"A").cloned().or_else(|| dict.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|aa| aa.get(b"U").cloned()))) + }; + let action = pick(d).or_else(|| widgets.iter().find_map(|w| doc.get(*w).as_dict().and_then(pick)))?; + let a = doc.resolve(&action); + let a = a.as_dict()?; + let s = |k: &[u8]| a.get(k).and_then(|v| text_of(&doc.resolve(v))); + Some(match a.name(b"S")? { + b"ResetForm" => { + let fields = a + .get(b"Fields") + .map(|f| doc.resolve(f)) + .and_then(|f| f.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|o| match o { + Object::Ref(r) => doc.get(*r).as_dict().and_then(|fd| fd.get(b"T").and_then(|t| text_of(&doc.resolve(t)))), + other => text_of(&doc.resolve(other)), + }) + .collect(); + let exclude = a.get(b"Flags").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & 1 != 0; + af::ButtonAction::Reset { fields, exclude } + } + b"Named" => af::ButtonAction::Named(String::from_utf8_lossy(a.name(b"N")?).into_owned()), + b"URI" => af::ButtonAction::Uri(s(b"URI")?), + b"SubmitForm" => af::ButtonAction::Submit( + a.get(b"F") + .map(|f| doc.resolve(f)) + .and_then(|f| f.as_dict().and_then(|fd| fd.get(b"F").and_then(|x| text_of(&doc.resolve(x)))).or_else(|| text_of(&f))) + .unwrap_or_default(), + ), + b"GoTo" => { + let dest = doc.resolve(a.get(b"D")?); + let target = dest.as_array()?.first()?.as_ref()?; + af::ButtonAction::GoTo(page_refs(doc).iter().position(|p| *p == target)?) + } + b"Hide" => { + // /T: a field name, an annotation (or field) reference, or an array of them. + let target = |o: &Object| match o { + Object::Ref(r) => qualified_name(doc, *r).or_else(|| text_of(&doc.resolve(o))), + other => text_of(other), + }; + let fields = match a.get(b"T") { + Some(t) => match &*doc.resolve(t) { + Object::Array(items) => items.iter().filter_map(target).collect(), + _ => target(t).into_iter().collect(), + }, + None => Vec::new(), + }; + let hide = !a.get(b"H").is_some_and(|h| matches!(&*doc.resolve(h), Object::Bool(false))); + af::ButtonAction::ShowHide { fields, hide } + } + b"SetOCGState" => { + // /State: ON, OFF or Toggle, each followed by the groups it applies to. + let mut changes = Vec::new(); + if let Some(state) = a.get(b"State") { + let mut op = None; + for item in doc.resolve(state).as_array().into_iter().flatten().take(MAX_LAYER_STATE) { + match item { + Object::Name(n) => { + op = match n.as_slice() { + b"ON" => Some(af::LayerOp::On), + b"OFF" => Some(af::LayerOp::Off), + b"Toggle" => Some(af::LayerOp::Toggle), + _ => None, + } + } + Object::Ref(r) => changes.extend(op.map(|op| (op, (r.num, r.generation)))), + _ => {} + } + } + } + let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false))); + af::ButtonAction::SetLayers { changes, preserve_rb } + } + b"JavaScript" => af::button_script(&script(doc, &action, scripts)?), + _ => return None, + }) +} + +/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to, +/// from the partial names (`/T`) up its `/Parent` chain. +fn qualified_name(doc: &Document, r: ObjRef) -> Option { + let mut parts = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut cur = Some(r); + while let Some(c) = cur { + if seen.len() > 64 || !seen.insert(c) { + break; + } + let obj = doc.get(c); + let d = obj.as_dict()?; + if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { + parts.push(t); + } + cur = d.get(b"Parent").and_then(|p| p.as_ref()); + } + parts.reverse(); + (!parts.is_empty()).then(|| parts.join(".")) +} + +/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode +/// in all, however many actions name the same stream. +pub(crate) const MAX_SCRIPT_BYTES: usize = 64 << 20; + +/// A budget for the script streams of one reading of the form. +pub(crate) fn script_budget() -> DecodeBudget { + DecodeBudget::new(MAX_SCRIPT_BYTES, 0) +} + +/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against +/// `budget`, and is `None` once it is spent. +pub(crate) fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option { + let a = doc.resolve(action); + let js = a.as_dict()?.get(b"JS")?.clone(); + match &*doc.resolve(&js) { + Object::String(s) => Some(s.to_text()), + Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + _ => None, + } +} + +/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only +/// widget's when the field has none). +fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions { + let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| { + (widgets.len() == 1) + .then(|| doc.get(widgets[0])) + .and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a)))) + .and_then(|a| a.as_dict().cloned()) + }); + let mut out = af::Actions::default(); + let Some(aa) = aa else { return out }; + let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); + if let Some(f) = js(b"F") { + match af::parse_format(&f) { + Some(fm) => out.format = fm, + None if !f.trim().is_empty() => { + out.unsupported.push("format"); + out.scripts.format = Some(f); + } + None => {} + } + } + if let Some(k) = js(b"K") + && out.format == af::Format::None + { + // A mask has only a keystroke script. + match af::parse_format(&k) { + Some(fm @ af::Format::Mask(_)) => out.format = fm, + Some(_) => {} + None if !k.trim().is_empty() => { + out.unsupported.push("keystroke"); + out.scripts.keystroke = Some(k); + } + None => {} + } + } + if let Some(v) = js(b"V") { + match af::parse_validate(&v) { + Some(vv) => out.validate = vv, + None if !v.trim().is_empty() => { + out.unsupported.push("validate"); + out.scripts.validate = Some(v); + } + None => {} + } + } + if let Some(c) = js(b"C") { + match af::parse_calculate(&c) { + Some(cc) => out.calculate = cc, + None if !c.trim().is_empty() => { + out.unsupported.push("calculate"); + out.scripts.calculate = Some(c); + } + None => {} + } + } + out +} + +fn values_of(doc: &Document, o: Option<&Object>) -> Vec { + let Some(o) = o else { return Vec::new() }; + match &*doc.resolve(o) { + Object::Array(a) => a.iter().filter_map(|x| text_of(&doc.resolve(x))).collect(), + Object::Stream(s) => s.decoded().ok().map(|b| PdfString::literal(b).to_text()).into_iter().collect(), + other => text_of(other).filter(|s| s != "Off" && !s.is_empty()).into_iter().collect(), + } +} + +fn nums(doc: &Document, o: Option<&Object>) -> Option> { + let o = doc.resolve(o?); + o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() +} + +/// Leaf page objects in order (a small walker; `organize` and `annot` have their own). +pub(crate) fn page_refs(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() }; + PageWalk::new(doc, pages).collect() +} + +/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, +/// never one entry per child, so a child listed many times or shared between nodes is skipped by +/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way). +/// Cycles end at the seen check and at most a million nodes are visited. +pub(crate) struct PageWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + pub(crate) pending: Vec<(std::sync::Arc, usize)>, +} + +impl<'a> PageWalk<'a> { + pub(crate) fn new(doc: &'a Document, top: ObjRef) -> Self { + let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); + Self { doc, seen: Default::default(), pending: vec![(top, 0)] } + } +} + +impl Iterator for PageWalk<'_> { + type Item = ObjRef; + + fn next(&mut self) -> Option { + while let Some((kids, next)) = self.pending.last_mut() { + let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { + self.pending.pop(); + continue; + }; + let node = kid.as_ref(); + *next += 1; + let Some(node) = node else { continue }; + if self.seen.len() >= 1_000_000 { + self.pending.clear(); + return None; + } + if !self.seen.insert(node) { + continue; + } + let obj = self.doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + match d.get(b"Kids").map(|k| self.doc.resolve(k)) { + Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)), + _ => return Some(node), + } + } + None + } +} + +/// The AcroForm dictionary. +pub fn acroform(doc: &Document) -> Option { + let root = doc.root()?; + let cat = doc.get(root); + let af = cat.as_dict()?.get(b"AcroForm")?.clone(); + doc.resolve(&af).as_dict().cloned() +} + +#[derive(Clone, Default)] +struct Inherited { + name: String, + ft: Option>, + ff: Option, + v: Option, + dv: Option, + da: Option, + q: Option, + max_len: Option, +} + +/// Every terminal field: the `/Fields` tree in order, then the fields reachable only through the +/// page annotations (see [`adopt_page_fields`]). +pub fn fields(doc: &Document) -> Vec { + fields_with_adopted(doc).items +} + +/// How many of [`fields`] were reachable only through the page annotations: the form's `/Fields` +/// list names none or only some of them, so the leniency that adopts them is worth recording. +pub fn adopted_page_fields(doc: &Document) -> usize { + fields_with_adopted(doc).adopted_page_fields +} + +/// The terminal fields and page-only fields adopted while reading a form. +#[derive(Clone, Debug, PartialEq)] +pub struct Fields { + /// Terminal fields in tree order, followed by adopted page-only fields. + pub items: Vec, + /// How many fields were reachable only through page annotations. + pub adopted_page_fields: usize, +} + +/// Read all terminal fields and the page-only adoption count in one enumeration. +pub fn fields_with_adopted(doc: &Document) -> Fields { + let (items, adopted_page_fields) = enumerate(doc); + Fields { items, adopted_page_fields } +} + +#[cfg(test)] +thread_local! { + /// Whole-form readings ([`enumerate`]) on this thread. + static FORM_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread has read the whole form. +#[cfg(test)] +pub(crate) fn form_reads() -> usize { + FORM_READS.with(std::cell::Cell::get) +} + +pub(crate) fn enumerate(doc: &Document) -> (Vec, usize) { + #[cfg(test)] + FORM_READS.with(|n| n.set(n.get() + 1)); + let af = acroform(doc); + let mut page_of = std::collections::HashMap::new(); + let mut annot_index = std::collections::HashMap::new(); + let pages = page_refs(doc); + for (i, p) in pages.iter().enumerate() { + if let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) { + for (k, e) in doc.resolve(&a).as_array().into_iter().flatten().enumerate() { + if let Some(r) = e.as_ref() { + page_of.entry(r).or_insert(i); + annot_index.entry(r).or_insert(k); + } + } + } + } + let base = match &af { + Some(af) => Inherited { + da: af.get(b"DA").and_then(|o| text_of(&doc.resolve(o))), + q: af.get(b"Q").and_then(|o| doc.resolve(o).as_int()), + ..Default::default() + }, + None => Inherited::default(), + }; + let mut out = Vec::new(); + let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), scripts: script_budget() }; + if let Some(af) = &af { + for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() { + if let Some(r) = f.as_ref() { + walk(doc, r, &base, &page_of, &mut seen, &mut out, 0); + } + } + } + let listed = out.len(); + adopt_page_fields(doc, &pages, &base, &page_of, &mut seen, &mut out); + let adopted = out.len() - listed; + rank_tabs(doc, &pages, &annot_index, &mut out); + (out, adopted) +} + +/// Widget annotations no `/Fields` entry reaches are adopted as fields, as Acrobat and the +/// browsers do: some writers list only some of the fields, or none at all. Each widget is adopted +/// through its topmost unlisted `/Parent`, so a field split across several widgets stays one +/// field. Tree-listed fields keep their order first; the rest follow in page-annotation order. +fn adopt_page_fields( + doc: &Document, + pages: &[ObjRef], + base: &Inherited, + page_of: &std::collections::HashMap, + seen: &mut Seen, + out: &mut Vec, +) { + for p in pages { + let Some(a) = doc.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue }; + let annots = doc.resolve(&a); + let Some(annots) = annots.as_array() else { continue }; + for e in annots { + let Some(r) = e.as_ref() else { continue }; + if seen.fields.contains(&r) || seen.widgets.contains(&r) { + continue; + } + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + if d.name(b"Subtype") != Some(b"Widget") { + continue; + } + if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") { + continue; + } + if let Some(root) = unlisted_root(doc, r, &seen.fields) { + walk(doc, root, base, page_of, seen, out, 0); + } + } + } +} + +/// The topmost field above `r` (itself without a `/Parent`), or `None` when `r` or an ancestor is +/// already reachable from the `/Fields` tree, or a `/Parent` loop hides the top. +fn unlisted_root(doc: &Document, r: ObjRef, seen: &std::collections::HashSet) -> Option { + let mut top = r; + let mut visited = std::collections::HashSet::from([r]); + loop { + let parent = doc.get(top).as_dict().and_then(|d| d.reference(b"Parent")); + let Some(p) = parent else { + return if seen.contains(&top) { None } else { Some(top) }; + }; + if !visited.insert(p) || visited.len() > 64 { + return None; + } + if seen.contains(&p) { + return None; + } + top = p; + } +} + +/// What one reading of the form's fields has used: the field and widget dictionaries read, and +/// the script budget their actions share. +struct Seen { + fields: std::collections::HashSet, + /// A widget belongs to the first field that lists it, however often it is listed. + widgets: std::collections::HashSet, + scripts: DecodeBudget, +} + +/// Read the field at `r` and the fields under it into `out`, with what they inherit from +/// `parent`: kids with a name are fields of their own, kids without are this field's widgets. +fn walk( + doc: &Document, + r: ObjRef, + parent: &Inherited, + page_of: &std::collections::HashMap, + seen: &mut Seen, + out: &mut Vec, + depth: usize, +) { + if depth > 64 || !seen.fields.insert(r) { + return; + } + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { return }; + let mut inh = parent.clone(); + if let Some(t) = d.get(b"T").and_then(|o| text_of(&doc.resolve(o))) { + inh.name = if inh.name.is_empty() { t } else { format!("{}.{t}", inh.name) }; + } + if let Some(ft) = d.name(b"FT") { + inh.ft = Some(ft.to_vec()); + } + if let Some(ff) = field_flags(doc, d) { + inh.ff = Some(ff); + } + for (k, slot) in [(&b"V"[..], &mut inh.v), (b"DV", &mut inh.dv)] { + if let Some(v) = d.get(k) { + *slot = Some(v.clone()); + } + } + if let Some(da) = d.get(b"DA").and_then(|o| text_of(&doc.resolve(o))) { + inh.da = Some(da); + } + if let Some(q) = d.get(b"Q").and_then(|o| doc.resolve(o).as_int()) { + inh.q = Some(q); + } + if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) { + // Compatibility: some writers emit zero for an unlimited text field. Keep /MaxLen + // untouched in the object graph, but expose only positive limits to both UI and API. + // An explicit zero also clears an inherited limit rather than inheriting it again. + inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0); + } + let kids: Vec = + d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); + // Kids with /T are fields; kids without are this field's widgets. + let field_kids: Vec = kids.iter().copied().filter(|k| doc.get(*k).as_dict().is_some_and(|kd| kd.contains(b"T"))).collect(); + if !field_kids.is_empty() { + for k in field_kids { + walk(doc, k, &inh, page_of, seen, out, depth + 1); + } + return; + } + // A field of no kind this reads is skipped before it claims widgets or scripts, which a later + // field listing them then reads. + let ff = inh.ff.unwrap_or(0); + let kind = match inh.ft.as_deref() { + Some(b"Tx") => FieldKind::Text, + Some(b"Btn") if ff & flags::PUSH_BUTTON != 0 => FieldKind::PushButton, + Some(b"Btn") if ff & flags::RADIO != 0 => FieldKind::Radio, + Some(b"Btn") => FieldKind::CheckBox, + Some(b"Ch") if ff & flags::COMBO != 0 => FieldKind::Combo, + Some(b"Ch") => FieldKind::List, + Some(b"Sig") => FieldKind::Signature, + _ => return, + }; + let widget_refs: Vec = if d.contains(b"Rect") { vec![r] } else { kids }; + let widget_refs: Vec = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect(); + let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts); + let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0) + .then(|| button_action(doc, d, &widget_refs, &mut seen.scripts)) + .flatten(); + let widgets = widget_refs + .into_iter() + .filter_map(|w| { + let wo = doc.get(w); + let wd = wo.as_dict()?; + let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]); + let annot_flags = wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + let own_ff = if w == r { 0 } else { field_flags(doc, wd).unwrap_or(0) }; + let on_state = wd + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| doc.resolve(&n).as_dict().cloned()) + .and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off")); + Some(Widget { + obj: w, + page: page_of.get(&w).copied(), + rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])], + on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)), + state: wd.name(b"AS").map(name_text), + tab: usize::MAX, + locked: annot_flags & 128 != 0, + hidden: annot_flags & (2 | 32) != 0, + read_only: (ff | own_ff) & flags::READ_ONLY != 0, + rotation: appearance::mk_rotation(doc, wd), + caption: (kind == FieldKind::PushButton) + .then(|| { + let mk = doc.resolve(wd.get(b"MK")?); + let value = doc.resolve(mk.as_dict()?.get(b"CA")?); + value.as_string().map(|s| s.to_text()) + }) + .flatten(), + list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), + round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)), + }) + }) + .collect(); + let options = d + .get(b"Opt") + .map(|o| doc.resolve(o)) + .and_then(|o| o.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|o| match &*doc.resolve(o) { + Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), + other => text_of(other).map(|t| (t.clone(), t)), + }) + .collect(); + out.push(Field { + name: inh.name.clone(), + obj: r, + kind, + value: values_of(doc, inh.v.as_ref()), + default: values_of(doc, inh.dv.as_ref()), + flags: ff, + max_len: inh.max_len, + options, + da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), + quadding: inh.q.unwrap_or(0), + tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), + widgets, + actions, + button, + }); +} + +pub(crate) fn field_flags(doc: &Document, dict: &Dict) -> Option { + dict.get(b"Ff").and_then(|value| doc.resolve(value).as_int()).and_then(|value| u32::try_from(value).ok()) +} diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 9a857510b9..7dd7bfde6c 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -176,6 +176,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() }; let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() }; let mut out: Vec<(String, String)> = Vec::new(); + let mut scripts = crate::script_budget(); // Kids stay unresolved on the stack, and each node is read once: a node listed many times, // or among its own kids, must not multiply the work. let mut stack = vec![tree]; @@ -203,7 +204,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { Object::String(s) => s.to_text(), _ => continue, }; - if let Some(js) = crate::script(doc, v) { + if let Some(js) = crate::script(doc, v, &mut scripts) { out.push((key, js)); } } @@ -300,25 +301,7 @@ pub fn set_field_script(doc: &mut Document, name: &str, event: &str, js: Option< } })?; if key == b"C" { - let af_ref = crate::author::ensure_form(doc)?; - let mut co: Vec = doc - .get(af_ref) - .as_dict() - .and_then(|d| d.get(b"CO").cloned()) - .map(|c| doc.resolve(&c)) - .and_then(|c| c.as_array().cloned()) - .unwrap_or_default(); - co.retain(|o| o.as_ref() != Some(f.obj)); - if js.is_some() { - co.push(Object::Ref(f.obj)); - } - doc.update_dict(af_ref, |d| { - if co.is_empty() { - d.remove(b"CO"); - } else { - d.set(b"CO".to_vec(), Object::Array(co)); - } - })?; + crate::calc_order::keep_listed(doc, f.obj, js.is_some())?; } Ok(()) } diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs new file mode 100644 index 0000000000..5b60e3927b --- /dev/null +++ b/crates/forms/src/tab_order.rs @@ -0,0 +1,180 @@ +use super::read::{fields, page_refs}; +use super::{Document, Field, FormError, ObjRef, Object, invalid}; + +/// A page's tab order (`/Tabs`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TabOrder { + /// Rows, top to bottom, left to right within a row (`/R`; right to left in a document bound + /// on the right). + Row, + /// Columns, left to right, top to bottom within a column (`/C`; columns right to left in a + /// document bound on the right). + Column, + /// The document structure (`/S`; annotation order when the file has none). + Structure, + /// Unspecified: annotation order. + Annotations, +} + +impl TabOrder { + fn of(name: Option<&[u8]>) -> TabOrder { + match name { + Some(b"R") => TabOrder::Row, + Some(b"C") => TabOrder::Column, + Some(b"S") => TabOrder::Structure, + _ => TabOrder::Annotations, + } + } + + /// The tab order of the page at `r`, with an indirect `/Tabs` resolved. + fn of_page(doc: &Document, r: ObjRef) -> TabOrder { + let tabs = doc.get(r).as_dict().and_then(|d| d.get(b"Tabs")).map(|o| doc.resolve(o)); + TabOrder::of(tabs.as_deref().and_then(Object::as_name)) + } +} + +/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index). +type TabItem = (usize, usize, usize, [f64; 4], usize); + +/// Every widget's place in the tab order. +pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::collections::HashMap, out: &mut [Field]) { + let mut items: Vec = Vec::new(); + for (fi, f) in out.iter().enumerate() { + for (wi, w) in f.widgets.iter().enumerate() { + if let Some(p) = w.page { + items.push((p, fi, wi, w.rect, annot_index.get(&w.obj).copied().unwrap_or(usize::MAX))); + } + } + } + // Rows and columns run right to left in a document bound on the right: rank the mirrored + // rectangles there. + if right_to_left(doc) { + for i in &mut items { + i.3 = [-i.3[2], i.3[1], -i.3[0], i.3[3]]; + } + } + let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len()); + for (pi, p) in pages.iter().enumerate() { + let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect(); + if on.is_empty() { + continue; + } + let tabs = TabOrder::of_page(doc, *p); + match tabs { + TabOrder::Row | TabOrder::Column => { + let row = tabs == TabOrder::Row; + // Primary axis: rows by top edge (down the page), columns by left edge. + on.sort_by(|a, b| if row { b.3[3].total_cmp(&a.3[3]) } else { a.3[0].total_cmp(&b.3[0]) }); + let mut groups: Vec> = Vec::new(); + for it in on { + let fits = groups.last().is_some_and(|g| { + let first = g[0]; + if row { + (first.3[3] - it.3[3]).abs() < ((first.3[3] - first.3[1]) / 2.0).max(2.0) + } else { + (it.3[0] - first.3[0]).abs() < ((first.3[2] - first.3[0]) / 2.0).max(2.0) + } + }); + if fits && let Some(g) = groups.last_mut() { + g.push(it); + } else { + groups.push(vec![it]); + } + } + for mut g in groups { + g.sort_by(|a, b| if row { a.3[0].total_cmp(&b.3[0]) } else { b.3[3].total_cmp(&a.3[3]) }); + ordered.extend(g.iter().map(|i| (i.1, i.2))); + } + } + TabOrder::Structure | TabOrder::Annotations => { + on.sort_by_key(|i| i.4); + ordered.extend(on.iter().map(|i| (i.1, i.2))); + } + } + } + for f in out.iter_mut() { + for w in &mut f.widgets { + w.tab = usize::MAX; + } + } + for (rank, (fi, wi)) in ordered.into_iter().enumerate() { + out[fi].widgets[wi].tab = rank; + } +} + +/// Whether the document is bound on the right (`/ViewerPreferences /Direction /R2L`). +fn right_to_left(doc: &Document) -> bool { + let Some(root) = doc.root() else { return false }; + let Some(prefs) = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences")).map(|o| doc.resolve(o)) else { return false }; + prefs.as_dict().and_then(|p| p.get(b"Direction")).map(|o| doc.resolve(o)).is_some_and(|d| d.as_name() == Some(b"R2L")) +} + +/// Set the tab order of pages (0-based). +pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> { + let refs = page_refs(doc); + for &p in pages { + let r = *refs.get(p).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", p + 1)))?; + doc.update_dict(r, |d| match order { + TabOrder::Row => d.set(b"Tabs".to_vec(), Object::name("R")), + TabOrder::Column => d.set(b"Tabs".to_vec(), Object::name("C")), + TabOrder::Structure => d.set(b"Tabs".to_vec(), Object::name("S")), + TabOrder::Annotations => { + d.remove(b"Tabs"); + } + })?; + } + Ok(()) +} + +/// The tab order of each page, in page order (`/Tabs`; a page without one is unspecified). +pub fn tab_orders(doc: &Document) -> Vec { + page_refs(doc).into_iter().map(|r| TabOrder::of_page(doc, r)).collect() +} + +/// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field): +/// move field `name` one place earlier or later in its page's tab order. The page switches to +/// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other +/// annotations keep their places. +pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; + // The page's fields in their current tab order (a field counts once, at its first widget). + let mut order: Vec<(usize, &str)> = + all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect(); + order.sort(); + let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect(); + let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) }; + let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; + let Some(j) = j else { return Ok(()) }; + let mut names = names.into_iter().map(str::to_string).collect::>(); + names.swap(i, j); + // The widgets of this page in the new order. + let mut widgets: Vec = Vec::new(); + for n in &names { + if let Some(g) = all.iter().find(|g| &g.name == n) { + widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)); + } + } + let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?; + let annots_obj = + doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()).ok_or_else(|| FormError::Invalid("the page has no annotations".into()))?; + let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default(); + let mut next = widgets.into_iter(); + let set: std::collections::HashSet = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect(); + let new_list: Vec = list + .iter() + .map(|o| match o.as_ref() { + Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), + _ => o.clone(), + }) + .collect(); + match annots_obj { + Object::Ref(ar) => doc.set(ar, Object::Array(new_list)), + _ => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(new_list)))?, + } + doc.update_dict(pr, |d| { + d.remove(b"Tabs"); + })?; + Ok(()) +} diff --git a/crates/forms/src/table.rs b/crates/forms/src/table.rs new file mode 100644 index 0000000000..ae565052fc --- /dev/null +++ b/crates/forms/src/table.rs @@ -0,0 +1,668 @@ +//! A table of text fields (Prepare a form ▸ Text field table): an area divided into rows and +//! columns, one text field in each cell, every column naming its cells from a pattern. +//! +//! The table is laid out in view space: points from the top-left of the page as displayed, after +//! `/Rotate`, so its rows run across the page as the person sees it whatever the page's rotation. +//! The engine maps each cell into user space and adds them all as one undoable edit. +//! +//! [`FieldTable::cells_for`] refuses a table [`add_field`](crate::add_field) would refuse part of +//! (a cell under [`MIN_FIELD_SIDE`], a name with a period, two cells with one name, a name the +//! form already has), so either all of the table is added or none of it is. + +use std::collections::HashSet; +use std::fmt; + +use crate::MIN_FIELD_SIDE; + +/// The most rows a table can have. +pub const MAX_ROWS: usize = 100; +/// The most columns a table can have. +pub const MAX_COLUMNS: usize = 50; +/// The highest number the first row can count from. +pub const MAX_FIRST_ROW: usize = 99_999; + +/// One column of a [`FieldTable`]. +#[derive(Clone, Debug, PartialEq)] +pub struct TableColumn { + /// How the column names its cells: see [`cell_name`]. + pub pattern: String, + /// The column's share of the table's width, against the other columns' (any positive number). + pub width: f64, +} + +/// An area divided into rows and columns of text fields. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldTable { + /// The area in view space, `[x0, y0, x1, y1]` with y down, corners in either order. + pub rect: [f64; 4], + pub rows: usize, + pub columns: Vec, + /// Points left empty between two rows. + pub row_gap: f64, + /// Points left empty between two columns. + pub column_gap: f64, + /// The number the first row's names count from. + pub first_row: usize, +} + +/// One cell of a [`FieldTable`]: where its field goes (view space, `[x0, y0, x1, y1]` with y down) +/// and what it is called. +#[derive(Clone, Debug, PartialEq)] +pub struct Cell { + pub rect: [f64; 4], + pub name: String, +} + +impl FieldTable { + /// Four rows in one column filling `rect`, named after `base` (see [`column_pattern`]). + pub fn new(rect: [f64; 4], base: &str) -> Self { + FieldTable { + rect, + rows: 4, + columns: vec![TableColumn { pattern: column_pattern(base, 1), width: 1.0 }], + row_gap: 0.0, + column_gap: 0.0, + first_row: 1, + } + } + + /// Make the table `n` columns wide, `n` held to 1 to [`MAX_COLUMNS`]. Columns past `n` are + /// dropped; a new one is named after `base` and is as wide as the others are on average, so + /// the widths already set keep their proportions. + pub fn set_columns(&mut self, n: usize, base: &str) { + let n = n.clamp(1, MAX_COLUMNS); + self.columns.truncate(n); + let sum: f64 = self.columns.iter().map(|c| c.width).sum(); + let average = sum / self.columns.len().max(1) as f64; + let width = if average.is_finite() && average > 0.0 { average } else { 1.0 }; + while self.columns.len() < n { + let pattern = column_pattern(base, self.columns.len() + 1); + self.columns.push(TableColumn { pattern, width }); + } + } + + /// Where each cell goes and what it is called, row by row. Refuses a table out of bounds or + /// with a cell under [`MIN_FIELD_SIDE`]; the names are not checked (see [`Self::cells_for`]). + pub fn cells(&self) -> Result, TableError> { + let (rows, cols) = (self.rows, self.columns.len()); + if !(1..=MAX_ROWS).contains(&rows) { + return Err(TableError::Rows); + } + if !(1..=MAX_COLUMNS).contains(&cols) { + return Err(TableError::Columns); + } + if self.first_row > MAX_FIRST_ROW { + return Err(TableError::FirstRow); + } + let gaps = [self.row_gap, self.column_gap]; + if !self.rect.iter().all(|v| v.is_finite()) + || !gaps.iter().all(|g| g.is_finite() && *g >= 0.0) + || !self.columns.iter().all(|c| c.width.is_finite() && c.width > 0.0) + { + return Err(TableError::Numbers); + } + let r = self.rect; + let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); + // `rows` and `cols` are at most 100 and 50 from here on, so the counts below can't overflow + // and are exact as floats. + let row_height = (y1 - y0 - self.row_gap * (rows - 1) as f64) / rows as f64; + let room = x1 - x0 - self.column_gap * (cols - 1) as f64; + let total: f64 = self.columns.iter().map(|c| c.width).sum(); + // Corners far enough apart overflow to infinity. + if !(row_height.is_finite() && room.is_finite() && total.is_finite()) { + return Err(TableError::Numbers); + } + // Every number from here on is finite: the corners, gaps and widths are, and their sums were + // checked above. + if row_height < MIN_FIELD_SIDE { + return Err(TableError::RowsTooShort); + } + let mut spans = Vec::with_capacity(cols); + // The widths of the columns so far: each edge is worked out from the start, not from the + // last edge, so rounding doesn't add up across the table. + let mut before = 0.0; + for (c, column) in self.columns.iter().enumerate() { + let left = x0 + room * before / total + c as f64 * self.column_gap; + before += column.width; + // The last column ends at the table's edge, not where rounding puts it. + let right = if c + 1 == cols { x1 } else { x0 + room * before / total + c as f64 * self.column_gap }; + if right - left < MIN_FIELD_SIDE { + return Err(TableError::ColumnTooNarrow); + } + spans.push((left, right)); + } + let mut cells = Vec::with_capacity(rows * cols); + for row in 0..rows { + let top = y0 + row as f64 * (row_height + self.row_gap); + let bottom = if row + 1 == rows { y1 } else { top + row_height }; + if bottom - top < MIN_FIELD_SIDE { + return Err(TableError::RowsTooShort); + } + for (c, (&(left, right), column)) in spans.iter().zip(&self.columns).enumerate() { + // At most MAX_FIRST_ROW + MAX_ROWS * MAX_COLUMNS. + let seq = self.first_row + row * cols + c; + let name = cell_name(&column.pattern, self.first_row + row, c + 1, seq); + cells.push(Cell { rect: [left, top, right, bottom], name }); + } + } + Ok(cells) + } + + /// [`Self::cells`] for a form whose fields are called `existing` (full names), refusing a + /// name with a period, a name two cells share and a name the form already has, as a field + /// or as the parent of one. + pub fn cells_for<'a>(&self, existing: impl IntoIterator) -> Result, TableError> { + let cells = self.cells()?; + // "a.b" also takes "a": a new field of that name would sit beside the parent of "a.b". + let mut taken = HashSet::new(); + for name in existing { + taken.insert(name); + taken.extend(name.match_indices('.').filter_map(|(at, _)| name.get(..at))); + } + let mut seen = HashSet::with_capacity(cells.len()); + for cell in &cells { + let name = cell.name.as_str(); + if name.contains('.') { + return Err(TableError::Period(cell.name.clone())); + } + if !seen.insert(name) { + return Err(TableError::Twice(cell.name.clone())); + } + if taken.contains(name) { + return Err(TableError::Taken(cell.name.clone())); + } + } + Ok(cells) + } +} + +/// The pattern a new table's column `column` (counted from 1) starts with: `{base}_N` for the +/// first, `{base}_c2_N`, `{base}_c3_N`, … after it. +pub fn column_pattern(base: &str, column: usize) -> String { + if column <= 1 { format!("{base}_N") } else { format!("{base}_c{column}_N") } +} + +/// The first of "Table1", "Table2", … that no field in `existing` (full names) starts with, so a +/// new table's names are free. One pass over the names, however many the form has. +pub fn free_base<'a>(existing: impl IntoIterator) -> String { + // The K of every "TableK" a name starts with: "Table1", "Table1_3" and "Table1.x" take 1, and + // "Table12_1" takes 12, not 1. + let taken: HashSet = existing + .into_iter() + .filter_map(|name| { + let rest = name.strip_prefix("Table")?; + let digits = rest.get(..rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()))?; + digits.parse().ok() + }) + .collect(); + // One of the first `taken.len() + 1` is always free. + let k = (1..=taken.len() as u64 + 1).find(|k| !taken.contains(k)).unwrap_or(1); + format!("Table{k}") +} + +/// What may stand on either side of an `N` that is the row number. +const SEPARATORS: [char; 4] = ['_', '-', '/', ':']; + +/// The name `pattern` gives the cell in `row` and `column` (`column` counted from 1); `seq` is the +/// cell's number counting along the rows. +/// +/// - `{row}`, `{r}`, `{n}` and `#`: the row number; `[row]`, `[r]` and `[n]`: the row number in +/// brackets (`item[N]` → `item[1]`); +/// - `{col}` and `{c}`, `[col]` and `[c]`: the column number; +/// - `{seq}` and `[seq]`: the cell number, counting along the rows from the table's first row +/// number, so a table starting at row 1 numbers its cells 1, 2, 3, …; +/// - with no row or cell number, an `N` or `n` standing alone after `_`, `-`, `/` or `:` +/// (`price_N`, `line-n-total`) is the row number, so the N in `Name` or `NET_N` stays; +/// - with no row or cell number still, the row number goes at the end. +/// +/// Braces and brackets match ignoring case. Anything else is kept as written, a period included, +/// though no field name may contain one: [`FieldTable::cells_for`] refuses those. +pub fn cell_name(pattern: &str, row: usize, column: usize, seq: usize) -> String { + let row_number = row.to_string(); + let mut out = String::with_capacity(pattern.len() + 8); + // Whether the name has a row or cell number yet. + let mut numbered = false; + let mut rest = pattern.trim(); + while let Some(at) = rest.find(['{', '[', '#']) { + // `at` is where an ASCII character starts, so both halves are whole characters. + let (before, from) = rest.split_at(at); + out.push_str(before); + if let Some(after) = from.strip_prefix('#') { + out.push_str(&row_number); + numbered = true; + rest = after; + continue; + } + let (open, close) = if from.starts_with('[') { ('[', ']') } else { ('{', '}') }; + let inside = from.get(1..).unwrap_or_default(); + let token = inside.split_once(close).and_then(|(word, after)| { + let is = |name: &str| word.eq_ignore_ascii_case(name); + if is("row") || is("r") || is("n") { + Some((row, true, after)) + } else if is("seq") { + Some((seq, true, after)) + } else if is("col") || is("c") { + Some((column, false, after)) + } else { + None + } + }); + match token { + Some((n, counts, after)) => { + if open == '[' { + out.push('['); + } + out.push_str(&n.to_string()); + if open == '[' { + out.push(']'); + } + numbered |= counts; + rest = after; + } + None => { + out.push(open); + rest = inside; + } + } + } + out.push_str(rest); + if numbered { + return out; + } + substitute_isolated_n(&out, &row_number) +} + +fn substitute_isolated_n(out: &str, row_number: &str) -> String { + if out == "N" || out == "n" { + return row_number.to_string(); + } + let chars: Vec = out.chars().collect(); + let mut named = String::with_capacity(out.len() + row_number.len()); + let mut numbered = false; + for (i, &ch) in chars.iter().enumerate() { + let after_separator = i.checked_sub(1).and_then(|p| chars.get(p)).is_some_and(|c| SEPARATORS.contains(c)); + let before_separator = chars.get(i + 1).is_none_or(|c| SEPARATORS.contains(c)); + if matches!(ch, 'N' | 'n') && after_separator && before_separator { + named.push_str(row_number); + numbered = true; + } else { + named.push(ch); + } + } + if !numbered { + named.push_str(row_number); + } + named +} + +/// Why a table can't be added. Each is a mistake a person can make, with the numbers and names +/// apart from the words, so an interface can say it in its own language with [`TableError::said`]. +#[derive(Clone, Debug, PartialEq)] +pub enum TableError { + /// Fewer than one row, or more than [`MAX_ROWS`]. + Rows, + /// Fewer than one column, or more than [`MAX_COLUMNS`]. + Columns, + /// A first row number past [`MAX_FIRST_ROW`]. + FirstRow, + /// A corner, gap or width that is not a usable number. + Numbers, + /// The rows come out under [`MIN_FIELD_SIDE`] high. + RowsTooShort, + /// A column comes out under [`MIN_FIELD_SIDE`] wide. + ColumnTooNarrow, + /// A cell's name has a period, which would make it a child of another field. + Period(String), + /// Two cells would have this name. + Twice(String), + /// The form already has a field of this name. + Taken(String), +} + +const ROWS: &str = "a table has 1 to {max} rows"; +const COLUMNS: &str = "a table has 1 to {max} columns"; +const FIRST_ROW: &str = "the first row number can be at most {max}"; +const NUMBERS: &str = "the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0"; +const ROWS_TOO_SHORT: &str = "the rows are too short: make the area taller, or use fewer rows or less row spacing"; +const COLUMN_TOO_NARROW: &str = + "a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing"; +const PERIOD: &str = "field names can't contain a period: {name}"; +const TWICE: &str = "two cells would both be named {name}"; +const TAKEN: &str = "a field named {name} already exists"; + +/// Every sentence a [`TableError`] says, in English with its holes still in it, for an interface +/// to translate; `wordings_are_said` keeps the list in step with the errors. +pub const WORDINGS: &[&str] = &[ROWS, COLUMNS, FIRST_ROW, NUMBERS, ROWS_TOO_SHORT, COLUMN_TOO_NARROW, PERIOD, TWICE, TAKEN]; + +/// How an interface says one of our wordings in its own language, holes and all: given the English +/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. +pub type Words<'a> = &'a dyn Fn(&str) -> String; + +/// The [`Words`] that translate nothing: the wording as it is written here. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +impl TableError { + /// This error as one sentence, said in the language `words` translates into. The wording is + /// translated and the numbers and names go in afterwards, in a single pass, so a name with + /// braces in it is never read as a hole. With [`in_english`] this is the error's `Display`. + pub fn said(&self, words: Words) -> String { + let limit = |wording: &str, max: usize| say(&words(wording), &[("{max}", max.to_string())]); + // A name goes in quoted, as `add_field` quotes it. + let named = |wording: &str, name: &str| say(&words(wording), &[("{name}", format!("{name:?}"))]); + match self { + TableError::Rows => limit(ROWS, MAX_ROWS), + TableError::Columns => limit(COLUMNS, MAX_COLUMNS), + TableError::FirstRow => limit(FIRST_ROW, MAX_FIRST_ROW), + TableError::Numbers => words(NUMBERS), + TableError::RowsTooShort => words(ROWS_TOO_SHORT), + TableError::ColumnTooNarrow => words(COLUMN_TOO_NARROW), + TableError::Period(name) => named(PERIOD, name), + TableError::Twice(name) => named(TWICE, name), + TableError::Taken(name) => named(TAKEN, name), + } + } +} + +impl fmt::Display for TableError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.said(&in_english)) + } +} + +impl std::error::Error for TableError {} + +/// A wording with its holes filled. A hole is a name in braces, such as `{max}`; what is not in +/// `values` is left as it is written. +fn say(wording: &str, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(wording.len() + 8); + let mut rest = wording; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // `find` counts from the brace and `}` is one byte, so this is a char boundary. + let Some(close) = from_brace.find('}') else { + out.push_str(from_brace); + return out; + }; + let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; + match values.iter().find(|(name, _)| *name == hole) { + Some((_, value)) => out.push_str(value), + None => out.push_str(hole), + } + rest = from_brace.get(close + 1..).unwrap_or_default(); + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + fn table(rect: [f64; 4], rows: usize, patterns: &[&str]) -> FieldTable { + FieldTable { + rect, + rows, + columns: patterns.iter().map(|p| TableColumn { pattern: (*p).to_owned(), width: 1.0 }).collect(), + row_gap: 0.0, + column_gap: 0.0, + first_row: 1, + } + } + + #[test] + fn a_row_number_goes_where_the_pattern_says() { + assert_eq!(cell_name("fieldCategory_N", 1, 1, 1), "fieldCategory_1"); + assert_eq!(cell_name("fieldCategory_N", 10, 1, 10), "fieldCategory_10"); + assert_eq!(cell_name("row_{n}", 3, 1, 3), "row_3"); + assert_eq!(cell_name("item-#", 5, 1, 5), "item-5"); + assert_eq!(cell_name("cell[N]", 2, 1, 2), "cell[2]"); + assert_eq!(cell_name("N", 4, 1, 4), "4"); + assert_eq!(cell_name("line-n-total", 6, 1, 6), "line-6-total"); + assert_eq!(cell_name("a:N/b", 7, 1, 7), "a:7/b"); + } + + #[test] + fn rows_columns_and_cells_have_their_own_tokens() { + assert_eq!(cell_name("item_{row}_{col}", 2, 3, 5), "item_2_3"); + assert_eq!(cell_name("cell_R{row}C{col}", 1, 4, 4), "cell_R1C4"); + assert_eq!(cell_name("grid[{r},{c}]", 3, 2, 8), "grid[3,2]"); + assert_eq!(cell_name("table_r{r}_c{c}", 4, 5, 20), "table_r4_c5"); + assert_eq!(cell_name("cell{seq}", 2, 2, 7), "cell7"); + assert_eq!(cell_name("cell[seq]", 2, 2, 7), "cell[7]"); + // Braces and brackets match ignoring case. + assert_eq!(cell_name("x{ROW}y{Col}z[SEQ]", 2, 3, 9), "x2y3z[9]"); + // A column number alone leaves the row to be found or added. + assert_eq!(cell_name("price{c}_N", 2, 3, 9), "price3_2"); + assert_eq!(cell_name("col{c}", 1, 2, 2), "col21"); + } + + #[test] + fn without_a_row_token_the_row_number_goes_at_the_end() { + assert_eq!(cell_name("", 1, 1, 1), "1"); + assert_eq!(cell_name(" ", 3, 1, 3), "3"); + assert_eq!(cell_name("test_", 1, 1, 1), "test_1"); + assert_eq!(cell_name("Phone_Number", 4, 1, 4), "Phone_Number4"); + // What isn't a token is kept as written. + assert_eq!(cell_name("{x}_N", 1, 1, 1), "{x}_1"); + assert_eq!(cell_name("{row", 1, 1, 1), "{row1"); + assert_eq!(cell_name("a]b[", 2, 1, 2), "a]b[2"); + // A period is kept, for `cells_for` to refuse. + assert_eq!(cell_name("test.", 1, 1, 1), "test.1"); + } + + #[test] + fn an_n_inside_a_word_is_not_the_row_number() { + assert_eq!(cell_name("Name_N", 1, 1, 1), "Name_1"); + assert_eq!(cell_name("ItemNumber_N", 2, 1, 2), "ItemNumber_2"); + assert_eq!(cell_name("NET_AMOUNT_N", 3, 1, 3), "NET_AMOUNT_3"); + assert_eq!(cell_name("CustomerName_{row}", 5, 1, 5), "CustomerName_5"); + assert_eq!(cell_name("NOTE_#", 6, 1, 6), "NOTE_6"); + assert_eq!(cell_name("BankName[N]", 7, 1, 7), "BankName[7]"); + assert_eq!(cell_name("PLAN_N", 8, 1, 8), "PLAN_8"); + assert_eq!(cell_name("line_n", 9, 1, 9), "line_9"); + assert_eq!(cell_name("Prix_é_N", 1, 1, 1), "Prix_é_1"); + assert_eq!(cell_name("名前_N", 2, 1, 2), "名前_2"); + } + + #[test] + fn a_grid_fills_its_area_with_gaps_between_the_cells() { + let mut t = table([100.0, 100.0, 300.0, 300.0], 2, &["cell_{r}_{c}", "cell_{r}_{c}"]); + t.row_gap = 10.0; + t.column_gap = 20.0; + // Width (200 - 20) / 2 = 90, height (200 - 10) / 2 = 95, rows from the top down. + let cells = t.cells().unwrap(); + let got: Vec<_> = cells.iter().map(|c| (c.rect, c.name.as_str())).collect(); + assert_eq!( + got, + [ + ([100.0, 100.0, 190.0, 195.0], "cell_1_1"), + ([210.0, 100.0, 300.0, 195.0], "cell_1_2"), + ([100.0, 205.0, 190.0, 300.0], "cell_2_1"), + ([210.0, 205.0, 300.0, 300.0], "cell_2_2"), + ] + ); + // Corners in either order are the same area. + t.rect = [300.0, 300.0, 100.0, 100.0]; + assert_eq!(t.cells().unwrap(), cells); + } + + #[test] + fn columns_share_the_width_by_their_widths() { + let mut t = table([100.0, 100.0, 300.0, 300.0], 2, &["desc_N", "price_N"]); + t.columns[0].width = 3.0; + let cells = t.cells().unwrap(); + assert_eq!(cells[0].rect, [100.0, 100.0, 250.0, 200.0]); + assert_eq!(cells[1].rect, [250.0, 100.0, 300.0, 200.0]); + assert_eq!((cells[2].name.as_str(), cells[3].name.as_str()), ("desc_2", "price_2")); + // However the shares round, the last column ends at the table's edge. + let mut t = table([0.0, 0.0, 100.0, 50.0], 1, &["a", "b", "c"]); + t.column_gap = 1.0 / 3.0; + assert_eq!(t.cells().unwrap()[2].rect[2], 100.0); + } + + #[test] + fn rows_count_from_the_first_row_and_cells_along_the_rows() { + let mut t = table([0.0, 0.0, 100.0, 100.0], 2, &["a_{seq}_{r}", "b_{seq}_{r}"]); + t.first_row = 10; + let names: Vec<_> = t.cells().unwrap().into_iter().map(|c| c.name).collect(); + assert_eq!(names, ["a_10_10", "b_11_10", "a_12_11", "b_13_11"]); + } + + #[test] + fn a_table_out_of_bounds_is_refused() { + let ok = table([0.0, 0.0, 200.0, 200.0], 4, &["a_N"]); + assert!(ok.cells().is_ok()); + let with = |f: &dyn Fn(&mut FieldTable)| { + let mut t = ok.clone(); + f(&mut t); + t.cells() + }; + assert_eq!(with(&|t| t.rows = 0), Err(TableError::Rows)); + assert_eq!(with(&|t| t.rows = MAX_ROWS + 1), Err(TableError::Rows)); + assert_eq!(with(&|t| t.columns.clear()), Err(TableError::Columns)); + // 200 points hold 50 columns of exactly 4, and 199 don't. + assert_eq!(with(&|t| t.set_columns(MAX_COLUMNS, "x")).map(|c| c.len()), Ok(4 * MAX_COLUMNS)); + assert_eq!( + with(&|t| { + t.rect[2] = 199.0; + t.set_columns(MAX_COLUMNS, "x"); + }), + Err(TableError::ColumnTooNarrow) + ); + assert_eq!(with(&|t| t.columns = vec![t.columns[0].clone(); MAX_COLUMNS + 1]), Err(TableError::Columns)); + assert_eq!(with(&|t| t.first_row = MAX_FIRST_ROW + 1), Err(TableError::FirstRow)); + assert_eq!(with(&|t| t.rect[0] = f64::NAN), Err(TableError::Numbers)); + assert_eq!(with(&|t| t.rect = [-f64::MAX, 0.0, f64::MAX, 200.0]), Err(TableError::Numbers)); + assert_eq!(with(&|t| t.row_gap = -1.0), Err(TableError::Numbers)); + assert_eq!(with(&|t| t.column_gap = f64::INFINITY), Err(TableError::Numbers)); + assert_eq!(with(&|t| t.columns[0].width = 0.0), Err(TableError::Numbers)); + // 200 points hold 50 rows of exactly 4, not 51. + assert_eq!(with(&|t| t.rows = 50).map(|c| c.len()), Ok(50)); + assert_eq!(with(&|t| t.rows = 51), Err(TableError::RowsTooShort)); + assert_eq!(with(&|t| t.row_gap = 70.0), Err(TableError::RowsTooShort)); + assert_eq!(with(&|t| t.rect = [0.0, 0.0, 3.0, 200.0]), Err(TableError::ColumnTooNarrow)); + // One column squeezed under 4 points by the others' widths. + assert_eq!( + with(&|t| t.columns = vec![TableColumn { pattern: "a_N".into(), width: 1.0 }, TableColumn { pattern: "b_N".into(), width: 99.0 }]), + Err(TableError::ColumnTooNarrow) + ); + } + + #[test] + fn names_a_form_would_refuse_are_refused_before_anything_is_added() { + let t = table([0.0, 0.0, 200.0, 200.0], 2, &["a_N", "b_N"]); + assert!(t.cells_for(["c_1", "a_10"]).is_ok()); + assert_eq!(t.cells_for(["x", "b_2"]), Err(TableError::Taken("b_2".into()))); + // The parent of an existing field is taken too. + assert_eq!(t.cells_for(["a_1.street"]), Err(TableError::Taken("a_1".into()))); + let same = table([0.0, 0.0, 200.0, 200.0], 2, &["item_N", "item_N"]); + assert_eq!(same.cells_for([]), Err(TableError::Twice("item_1".into()))); + let dotted = table([0.0, 0.0, 200.0, 200.0], 2, &["row.{n}"]); + assert_eq!(dotted.cells_for([]), Err(TableError::Period("row.1".into()))); + } + + #[test] + fn a_new_table_takes_the_first_free_base() { + assert_eq!(free_base([]), "Table1"); + assert_eq!(free_base(["Table1_1", "Table1_c2_4"]), "Table2"); + assert_eq!(free_base(["Table1", "Table2.x", "Table3_N"]), "Table4"); + // "Table12_1" belongs to Table12, not Table1. + assert_eq!(free_base(["Table12_1", "Tables"]), "Table1"); + // As fast for a form with thousands of fields: one pass, not one per candidate. + let many: Vec = (1..=20_000).map(|k| format!("Table{k}_1")).collect(); + assert_eq!(free_base(many.iter().map(String::as_str)), "Table20001"); + let t = FieldTable::new([0.0, 0.0, 200.0, 200.0], &free_base(["Table1_1"])); + let names: Vec<_> = t.cells_for(["Table1_1"]).unwrap().into_iter().map(|c| c.name).collect(); + assert_eq!(names, ["Table2_1", "Table2_2", "Table2_3", "Table2_4"]); + } + + #[test] + fn added_columns_keep_the_widths_already_set() { + let mut t = FieldTable::new([0.0, 0.0, 400.0, 100.0], "T"); + t.columns[0].width = 2.0; + t.set_columns(3, "T"); + let got: Vec<_> = t.columns.iter().map(|c| (c.pattern.as_str(), c.width)).collect(); + assert_eq!(got, [("T_N", 2.0), ("T_c2_N", 2.0), ("T_c3_N", 2.0)]); + t.columns[2].width = 4.0; + t.set_columns(4, "T"); + assert_eq!(t.columns[3].width, 8.0 / 3.0); + t.set_columns(2, "T"); + assert_eq!(t.columns.len(), 2); + // Held to the bounds. + t.set_columns(0, "T"); + assert_eq!(t.columns.len(), 1); + t.set_columns(usize::MAX, "T"); + assert_eq!(t.columns.len(), MAX_COLUMNS); + } + + #[test] + fn wordings_are_said() { + let mistakes = [ + TableError::Rows, + TableError::Columns, + TableError::FirstRow, + TableError::Numbers, + TableError::RowsTooShort, + TableError::ColumnTooNarrow, + TableError::Period("a.1".into()), + TableError::Twice("a_1".into()), + TableError::Taken("a_1".into()), + ]; + assert_eq!(WORDINGS.len(), mistakes.len(), "one wording per mistake"); + // Each mistake says one of the wordings: translated to a marker, only the values remain. + for e in &mistakes { + let marked = e.said(&|w| format!("<{}>", WORDINGS.iter().position(|x| *x == w).unwrap_or(usize::MAX))); + assert!(!marked.contains(&usize::MAX.to_string()), "{e:?} says a wording that isn't listed"); + assert_eq!(e.said(&in_english), e.to_string()); + } + assert_eq!(TableError::Rows.to_string(), "a table has 1 to 100 rows"); + assert_eq!(TableError::Taken("a_1".into()).to_string(), "a field named \"a_1\" already exists"); + // A name goes in as it stands, braces and all, and is never read as a hole. + let other = |w: &str| if w == TAKEN { "{name} existe ya".to_owned() } else { w.to_owned() }; + assert_eq!(TableError::Taken("{max}".into()).said(&other), "\"{max}\" existe ya"); + } + + proptest! { + /// Any pattern names every cell, and never panics. + #[test] + fn any_pattern_gives_a_name(pattern in "(\\PC|[{}\\[\\]#Nn_:/.-]){0,24}", row in 0usize..200_000, column in 1usize..60, seq in 0usize..300_000) { + let name = cell_name(&pattern, row, column, seq); + prop_assert!(!name.is_empty()); + prop_assert_eq!(name.trim(), name.as_str()); + } + + /// Any table either lays out cells of at least the smallest side inside its area, or says + /// why not. + #[test] + fn any_table_lays_out_or_says_why( + rect in prop::array::uniform4(-2000.0f64..2000.0), + rows in 0usize..120, + widths in prop::collection::vec(0.01f64..100.0, 0..60), + row_gap in -1.0f64..50.0, + column_gap in -1.0f64..50.0, + ) { + let t = FieldTable { + rect, + rows, + columns: widths.iter().map(|w| TableColumn { pattern: "a{seq}".into(), width: *w }).collect(), + row_gap, + column_gap, + first_row: 1, + }; + if let Ok(cells) = t.cells_for([]) { + prop_assert_eq!(cells.len(), rows * widths.len()); + let (x0, y0, x1, y1) = (rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])); + for c in &cells { + let [l, t, r, b] = c.rect; + prop_assert!(r - l >= MIN_FIELD_SIDE && b - t >= MIN_FIELD_SIDE, "{:?}", c); + prop_assert!(l >= x0 - 1e-6 && r <= x1 + 1e-6 && t >= y0 - 1e-6 && b <= y1 + 1e-6, "{:?}", c); + } + } + } + } +} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index fef5bcfbd8..cca4cb4881 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -80,6 +80,7 @@ fn field<'a>(all: &'a [Field], name: &str) -> &'a Field { all.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}")) } +/// The decoded text of widget `w`'s normal (`/N`) appearance stream. fn ap(doc: &Document, w: &Widget) -> String { let wd = doc.get(w.obj).as_dict().cloned().unwrap(); let n = wd.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").expect("normal appearance stream"); @@ -87,6 +88,58 @@ fn ap(doc: &Document, w: &Widget) -> String { String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() } +/// A push button's caption is drawn in the font, size and color its `/DA` asks for; with an +/// automatic size (0) it is sized to fit the button, in the form's default font and color. +#[test] +fn a_button_caption_is_drawn_in_its_fields_font_size_and_color() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(), + "<< /Fields [7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /HeBo 6 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(), + // Its /DA asks for 9pt Helvetica Bold in blue. + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (menu) /Ff 65536 /DA (/HeBo 9 Tf 0 0 1 rg) /MK << /CA (Menu) >> /Rect [50 700 140 711] /P 3 0 R >>".into(), + // An automatic size (0): sized to the button, as before. + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (auto) /Ff 65536 /MK << /CA (Auto) >> /Rect [50 600 140 630] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + redraw_field(&mut doc, "menu").unwrap(); + redraw_field(&mut doc, "auto").unwrap(); + let all = fields(&doc); + let menu = ap(&doc, &field(&all, "menu").widgets[0]); + assert!(menu.contains("/HeBo 9.00 Tf") && menu.contains("0 0 1 rg"), "{menu}"); + let auto = ap(&doc, &field(&all, "auto").widgets[0]); + assert!(auto.contains("/Helv 14.00 Tf") && auto.contains(" 0 g"), "{auto}"); +} + +/// A button caption in a font whose UCS-2 CMap holds 签 is not reported; once `set_props` adds a +/// character beyond U+FFFF, the caption is still drawn in that font and `undrawn_fields` reports +/// the character. +#[test] +fn a_button_caption_its_fonts_encoding_cant_hold_is_reported() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R] >>".into(), + "<< /Fields [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /CN 6 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into(), + // A UCS-2 CMap holds 签 but not a character beyond U+FFFF, which is drawn as `?`. + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (go) /Ff 65536 /DA (/CN 9 Tf 0 g) /MK << /CA > >> /Rect [50 700 140 711] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + redraw_field(&mut doc, "go").unwrap(); + assert!(undrawn_fields(&doc).is_empty(), "签 is drawn"); + set_props(&mut doc, "go", &FieldProps { caption: Some((0, "签\u{2000B}".into())), ..Default::default() }).unwrap(); + assert!(ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains("/CN 9.00 Tf")); + assert_eq!(undrawn_fields(&doc), [("go".to_string(), '\u{2000B}')]); +} + +/// Changing one widget's caption on a push button with two widgets leaves the other widget, the +/// shared `/MK` and the parent's action untouched, keeps the other `/MK` and `/AP` entries, and +/// drops the stale rollover and down appearances, with a direct or indirect caption and either save. #[test] fn button_caption_preserves_other_widgets_and_shared_dictionaries() { for indirect_caption in [false, true] { @@ -334,6 +387,229 @@ fn opt_export_values_select_button_states() { assert!(err.to_string().contains("options: Post, Pick-up"), "{err}"); } +/// What fits a text field is measured as its appearance draws it: in the field's own font (its +/// `/DR` font, Helvetica where that can't show the text), inside the border and padding, at its +/// size, wrapped for multi-line, at the smallest automatic size, as asterisks for a password, +/// and without a limit for a comb field. +#[test] +fn a_text_field_says_what_fits_it_as_its_appearance_draws_it() { + // Each widget is 100 x 20 pt (multi-line: 100 x 40 pt) with no border: 96 pt inside the 2 pt + // padding (36 pt high for the multi-line one). + let tx = |name: &str, da: &str, ff: u32, extra: &str, top: i32, h: i32| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA ({da}) /Ff {ff}{extra} /Rect [50 {} 150 {top}] /P 3 0 R >>", top - h) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] >>".into(), + "<< /Fields [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Cour 6 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(), + tx("one", "/Helv 12 Tf 0 g", 0, "", 780, 20), + tx("auto", "/Helv 0 Tf 0 g", 0, "", 750, 20), + tx("many", "/Helv 12 Tf 0 g", flags::MULTILINE, "", 720, 40), + tx("cour", "/Cour 12 Tf 0 g", 0, "", 670, 20), + tx("pin", "/Helv 12 Tf 0 g", flags::PASSWORD, "", 640, 20), + tx("comb", "/Helv 12 Tf 0 g", flags::COMB, " /MaxLen 5", 610, 20), + ]; + let doc = document(&objs); + let all = fields(&doc); + let tb = |name: &str| { + let f = field(&all, name); + appearance::text_box(&doc, f, &f.widgets[0]) + }; + let helv = |s: &str, size: f64| pdfcraft_fonts::helvetica_width(s, size); + // Single line at 12 pt: as wide as the 96 pt inside, and no wider. + let fits_12: String = std::iter::repeat_n('W', (96.0 / helv("W", 12.0)) as usize).collect(); + assert!(tb("one").fits(&fits_12) && !tb("one").fits(&format!("{fits_12}W")), "{}", fits_12.len()); + // An automatic size shrinks first: full only when 4 pt no longer fits. + let fits_4: String = std::iter::repeat_n('W', (96.0 / helv("W", 4.0)) as usize).collect(); + assert!(!tb("one").fits(&fits_4) && tb("auto").fits(&fits_4) && !tb("auto").fits(&format!("{fits_4}W"))); + // Multi-line: lines of 12 x 1.15 pt each in 36 pt, so two lines fit and three don't. + assert!(tb("many").fits("one\ntwo") && !tb("many").fits("one\ntwo\nthree")); + let two_lines_of_words = "word ".repeat(((96.0 / helv("word ", 12.0)) as usize) * 2); + assert!(tb("many").fits(two_lines_of_words.trim_end()) && !tb("many").fits(&format!("{two_lines_of_words}word"))); + // The field's own font: narrow i's that Helvetica fits are too wide in Courier (600/1000 em). + let i_s: String = std::iter::repeat_n('i', 14).collect(); + assert!(tb("one").fits(&i_s) && !tb("cour").fits(&i_s), "Courier is 0.6 em a character: {}", 14.0 * 0.6 * 12.0); + // A password is drawn, so measured, as asterisks. + let thirty_i: String = std::iter::repeat_n('i', 30).collect(); + assert!(tb("one").fits(&thirty_i) && !tb("pin").fits(&thirty_i), "{} pt of asterisks", helv(&"*".repeat(30), 12.0)); + // A comb field's cells bound it, not its width. + assert!(tb("comb").fits(&"W".repeat(50))); +} + +/// #4339 review: a multi-line field is full when its appearance stops drawing lines (a line is +/// drawn while its descent stays inside the border), not when `lines x size x 1.15` outgrows the +/// padded height: a 16 pt field at 12 pt draws one line. +#[test] +fn a_short_multiline_field_takes_the_one_line_it_draws() { + let tx = |name: &str, extra: &str, h: i32| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/Helv 12 Tf 0 g) /Ff {}{extra} /Rect [50 {} 150 700] /P 3 0 R >>", + flags::MULTILINE, + 700 - h + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + tx("plain", "", 16), + tx("boxed", " /MK << /BC [0] >>", 16), + ]; + let doc = document(&objs); + let all = fields(&doc); + let tb = |name: &str| { + let f = field(&all, name); + appearance::text_box(&doc, f, &f.widgets[0]) + }; + assert!(tb("plain").fits("one line") && !tb("plain").fits("one\ntwo")); + // A 1 pt border takes 1 pt from each side: the line's descent then falls outside it. + assert!(!tb("boxed").fits("one line")); +} + +/// A multi-line field narrower than one glyph: wrapping can't break inside a character, so the +/// appearance cuts that character at the sides (`Overflow::Wide`), and `fits` must say so too. +#[test] +fn a_multiline_field_rejects_a_glyph_wider_than_the_field() { + // 12 x 40 pt with no border: 8 pt inside the 2 pt padding. At 12 pt Helvetica a W is 11.3 pt + // wide and an i 2.7 pt. + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (thin) /V (W) /DA (/Helv 12 Tf 0 g) /Ff {} /Rect [50 660 62 700] /P 3 0 R >>", + flags::MULTILINE + ), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "thin"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + assert!(tb.fits("i") && tb.fits("i\ni")); + assert!(matches!(appearance::value_overflow(&doc, f, &f.widgets[0]), Some(appearance::Overflow::Wide { .. }))); + assert!(!tb.fits("W") && !tb.fits("iW")); +} + +/// An edit to a field that doesn't scroll keeps as much of what it inserted as fits, wherever the +/// caret was, and always keeps a deletion. +#[test] +fn an_edit_to_a_full_field_keeps_what_fits() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Helv 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "one"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + // Typing in the middle of "Short": the X's go in until the field is full, the end stays. + let typed = format!("Sh{}ort", "X".repeat(40)); + let (kept, caret) = tb.fitted_edit("Short", &typed); + let xs = kept.chars().filter(|c| *c == 'X').count(); + assert!(kept.starts_with("ShX") && kept.ends_with("Xort") && xs < 40, "{kept}"); + assert!(tb.fits(&kept) && !tb.fits(&format!("Sh{}ort", "X".repeat(xs + 1))), "as many as fit: {xs}"); + assert_eq!(caret, 2 + xs, "the caret after the last X kept"); + // What fits is kept whole. + assert_eq!(tb.fitted_edit("Short", "Shorter"), ("Shorter".to_string(), 7)); + // A deletion is kept even from text that already overflows (set by a script, say). + let long = "W".repeat(20); + assert!(!tb.fits(&long)); + assert_eq!(tb.fitted_edit(&long, &"W".repeat(19)), ("W".repeat(19), 19)); + // Replacing a selection with too much keeps the removal and what fits of the replacement. + let (kept, _) = tb.fitted_edit("Short", &format!("S{}t", "W".repeat(30))); + assert!(kept.starts_with("SW") && kept.ends_with('t') && tb.fits(&kept), "{kept}"); +} + +/// When the field's own font can't show a character of a paste, the text from there on is drawn +/// in Helvetica, which can be narrower: the longest part that fits may come after that point, and +/// is still the one kept. +#[test] +fn an_edit_keeps_the_longest_part_that_fits_across_a_change_of_font() { + // A subset font holding only A, 1 em wide: 12 pt an A at 12 pt, against Helvetica's 8 pt. + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Wide 12 Tf 0 g) /DR << /Font << /Wide 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Wide /FirstChar 65 /LastChar 65 /Widths [1000] /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Wide 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "one"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + // 96 pt inside: 8 A's in the subset font, or 11 characters (8.004 pt each) once a B brings + // Helvetica in. + assert!(tb.fits(&"A".repeat(8)) && !tb.fits(&"A".repeat(9))); + let pasted = format!("{}B{}", "A".repeat(10), "A".repeat(30)); + let (kept, caret) = tb.fitted_edit("", &pasted); + assert_eq!(kept, format!("{}B", "A".repeat(10)), "the 11 characters that fit in Helvetica, not the 8 before the B"); + assert!(!tb.fits(&format!("{}BA", "A".repeat(10))), "and no more"); + assert_eq!(caret, 11); +} + +/// A check box laid out for a round XFA check button (`/PCXfaRound true`) draws its background +/// and border as a circle inside the widget, inset by half the border so the stroke stays in, +/// or a disc when the border is as wide as the circle (an underline stays one); one without the +/// key keeps its rectangle. +#[test] +fn a_round_xfa_check_button_draws_a_round_background_and_border() { + let widget = |name: &str, extra: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Btn /T ({name}) /Rect [50 700 70 720] /P 3 0 R /MK << /BG [0.8 0.85 1] /BC [1 0 0] >> /BS << /W 1 /S /S >>{extra} >>" + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), + widget("round", " /PCXfaRound true"), + widget("square", ""), + widget("dashed", " /PCXfaRound true /BS << /W 2 /S /D >>").replace("/BS << /W 1 /S /S >>", ""), + widget("thick", " /PCXfaRound true /BS << /W 30 /S /S >>").replace("/BS << /W 1 /S /S >>", ""), + widget("underlined", " /PCXfaRound true /BS << /W 1 /S /U >>").replace("/BS << /W 1 /S /S >>", ""), + ]; + let mut doc = document(&objs); + for name in ["round", "square", "dashed", "thick", "underlined"] { + redraw_field(&mut doc, name).unwrap(); + } + // The Off state: only the frame. + let off = |name: &str| { + let all = fields(&doc); + let w = field(&all, name).widgets[0].obj; + let wd = doc.get(w).as_dict().cloned().unwrap(); + let n = doc.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap()); + let r = n.as_dict().unwrap().reference(b"Off").expect("an Off appearance"); + let Object::Stream(s) = &*doc.get(r) else { panic!("Off is a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + let round = off("round"); + assert!(!round.contains(" re "), "no rectangle: {round}"); + assert!(round.contains("0.8 0.85 1 rg\n20 10 m"), "the background is a disc as wide as the box: {round}"); + assert!(round.contains("1 0 0 RG\n1 w\n19.5 10 m") && round.contains(" c S"), "the border is a circle inset by half its width: {round}"); + let square = off("square"); + assert!(square.contains("0 0 20 20 re f") && square.contains("re S"), "a square box keeps its rectangle: {square}"); + let dashed = off("dashed"); + assert!(dashed.contains("[3] 0 d\n19 10 m"), "a dashed round border stays dashed: {dashed}"); + // A border wider than the 20pt circle fills it: a disc in the border's colour, no stroke. + let thick = off("thick"); + assert!(thick.contains("1 0 0 rg\n20 10 m") && !thick.contains(" S\n"), "a border as wide as the circle is a disc: {thick}"); + // An underline stays the bottom edge, under a round background. + let underlined = off("underlined"); + assert!(underlined.contains("1 0 0 RG\n1 w\n0 0.5 m 20 0.5 l S"), "an underline stays an underline: {underlined}"); +} + /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an /// empty value clears it. #[test] @@ -379,6 +655,44 @@ fn text_fields_get_new_appearances() { assert!(raw.windows(8).any(|x| x == b"(Z\xfcrich)")); } +/// Acrobat regenerates only the `/Tx BMC` block when it redraws a text field, so background and +/// border must live inside it rather than immediately before it (#1613). +fn marked_content_borders_field(appearance: &str, label: &str) { + let start = appearance.find("/Tx BMC").expect("no /Tx BMC"); + let marked = appearance[start..].split_once("EMC").expect("no matching EMC").0; + assert!(marked.contains("1 1 0.9 rg"), "background inside /Tx BMC: {appearance} {label}"); + assert!(marked.contains("0 0 0 RG\n1 w\n0.5 0.5 199 19 re S"), "border inside /Tx BMC: {appearance} {label}"); +} + +#[test] +fn text_and_date_fields_keep_background_and_border_inside_marked_content() { + let mut doc = one_page(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &NewField::Date, Some("due")).unwrap(); + let props = || FieldProps { + look: Some(Look { + border: Some([0.0; 3]), + fill: Some([1.0, 1.0, 0.9]), + width: 1.0, + style: BorderStyle::Solid, + text: [0.0, 0.0, 0.0], + font: FieldFont::Helvetica, + }), + ..FieldProps::default() + }; + set_props(&mut doc, "name", &props()).unwrap(); + set_props(&mut doc, "due", &props()).unwrap(); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + set_value(&mut doc, "due", &FieldValue::Text("10/14/2026".into())).unwrap(); + let name_ap = ap(&doc, &field(&fields(&doc), "name").widgets[0]); + let due_ap = ap(&doc, &field(&fields(&doc), "due").widgets[0]); + marked_content_borders_field(&name_ap, "text field"); + marked_content_borders_field(&due_ap, "date field"); + assert!(name_ap.contains("(Ada) Tj"), "{name_ap}"); + assert!(due_ap.contains("(10/14/2026) Tj"), "{due_ap}"); + assert!(!need_appearances(&doc), "drawable ASCII fields do not ask other viewers to redraw"); +} + #[test] fn existing_field_font_encoding_keeps_italian_punctuation_visible() { // ReportLab-style Helvetica: an indirect encoding dictionary with PDFDoc-like Differences. @@ -466,6 +780,82 @@ fn multiline_wrapping_keeps_encodable_text_without_requiring_ascii_space() { assert_eq!(metrics.decode(&lines.concat()), value.replace('\n', "")); } +/// A field drawn in its `/DR` font is laid out with that font's widths, not Helvetica's (#2894): +/// Courier is 0.6 em a character, so 10 pt text holds 94 to a line in a 564 pt box, and a +/// right-aligned value ends at the padding in Courier and in Times. +#[test] +fn reused_standard_fonts_wrap_and_align_with_their_own_widths() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R] >>".into(), + "<< /Fields [7 0 R 8 0 R 9 0 R] /DA (/Cour 10 Tf 0 g) /DR << /Font << /Cour 5 0 R /TiRo 6 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (notes) /Rect [21 537 591 722] /Ff 4096 /DA (/Cour 10 Tf 0 g) /MK << /BC [0 0 0] >> /P 3 0 R >>" + .into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (code) /Q 2 /Rect [21 500 221 520] /DA (/Cour 10 Tf 0 g) /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (initials) /Q 2 /Rect [21 470 221 490] /DA (/TiRo 10 Tf 0 g) /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + let value = "The quick brown fox jumps over the lazy dog, then it runs into the field and keeps on going well past the right edge of the box. Every line here is plain mixed-case ASCII text in Courier at 10 points."; + set_value(&mut doc, "notes", &FieldValue::Text(value.into())).unwrap(); + set_value(&mut doc, "code", &FieldValue::Text("iiii".into())).unwrap(); + set_value(&mut doc, "initials", &FieldValue::Text("iiii".into())).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let (_, lines) = appearance_text(&doc, &field(&all, "notes").widgets[0], b"Cour"); + let lengths: Vec = lines.iter().map(Vec::len).collect(); + assert!(lengths.iter().all(|&l| l <= 94), "every line fits the box in Courier: {lengths:?}"); + assert!(lengths[0] > 85, "and the first is not wrapped early: {lengths:?}"); + // Where each right-aligned value starts: the box is 200 pt with 2 pt padding, so `iiii` ends + // at 198 and starts 24 pt (Courier) or 11.12 pt (Times) before it, against 8.88 in Helvetica. + let start = |name: &str| { + let w = &field(&all, name).widgets[0]; + let n = doc.get(w.obj).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + let tm = pdfcraft_content::parse(&s.decoded().unwrap()).ops.into_iter().find(|op| op.is("Tm")).unwrap(); + tm.operands[4].as_f64().unwrap() + }; + assert!((start("code") - 174.0).abs() < 1e-3, "Courier: {}", start("code")); + assert!((start("initials") - 186.88).abs() < 1e-3, "Times: {}", start("initials")); +} + +/// A standard-14 font whose `/Differences` swap A and W draws `AAAA` with code 87, so it is +/// measured as four A's (0.667 em), not as the W that code has in WinAnsi. The same remap with +/// `/Widths` takes the width the font gives code 87, here 0.5 em. +#[test] +fn remapped_standard_fonts_are_measured_by_the_glyph_each_code_draws() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(), + "<< /Fields [7 0 R 8 0 R] /DA (/Dif 10 Tf 0 g) /DR << /Font << /Dif 5 0 R /DifW 6 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /BaseEncoding /WinAnsiEncoding /Differences [65 /W 87 /A] >> >>" + .into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /FirstChar 87 /LastChar 87 /Widths [500] /Encoding << /BaseEncoding /WinAnsiEncoding /Differences [65 /W 87 /A] >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (std) /Q 2 /Rect [21 500 221 520] /DA (/Dif 10 Tf 0 g) /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (widths) /Q 2 /Rect [21 470 221 490] /DA (/DifW 10 Tf 0 g) /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + set_value(&mut doc, "std", &FieldValue::Text("AAAA".into())).unwrap(); + set_value(&mut doc, "widths", &FieldValue::Text("AAAA".into())).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(appearance_text(&doc, &field(&all, "std").widgets[0], b"Dif").1, vec![b"WWWW".to_vec()], "A is drawn by code 87"); + let start = |name: &str| { + let w = &field(&all, name).widgets[0]; + let n = doc.get(w.obj).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + let tm = pdfcraft_content::parse(&s.decoded().unwrap()).ops.into_iter().find(|op| op.is("Tm")).unwrap(); + tm.operands[4].as_f64().unwrap() + }; + // The text ends at 198 (200 pt box, 2 pt padding): 26.68 pt of A's, or 20 pt by /Widths. + assert!((start("std") - 171.32).abs() < 1e-3, "no /Widths: {}", start("std")); + assert!((start("widths") - 178.0).abs() < 1e-3, "/Widths: {}", start("widths")); +} + #[test] fn japanese_choices_use_the_unicode_cid_font() { // A choice field whose /DA names a non-embedded CID font with a predefined Unicode CMap @@ -935,6 +1325,80 @@ fn properties_rename_and_delete() { assert_eq!(annots.as_array().unwrap().len(), 13 - 3, "the deleted widgets left the page"); } +/// A parent `MASTER` with kids `a` and `b` (each a field merged with its widget) and `e` (a field +/// with two widget kids), and the top-level fields `c` and `d`. +fn dotted_form() -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 11 0 R 12 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /T (MASTER) /Kids [6 0 R 7 0 R 10 0 R] >>".into(), // 5 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /Parent 5 0 R /V (one) /Rect [50 700 250 720] /P 3 0 R >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (b) /Parent 5 0 R /V (two) /Rect [50 650 250 670] /P 3 0 R >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (c) /V (three) /Rect [50 600 250 620] /P 3 0 R >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (d) /Rect [50 550 250 570] /P 3 0 R >>".into(), // 9 + "<< /FT /Tx /T (e) /Parent 5 0 R /V (four) /Kids [11 0 R 12 0 R] >>".into(), // 10 + "<< /Type /Annot /Subtype /Widget /Parent 10 0 R /Rect [50 500 250 520] /P 3 0 R >>".into(), // 11 + "<< /Type /Annot /Subtype /Widget /Parent 10 0 R /Rect [300 500 500 520] /P 3 0 R >>".into(), // 12 + ]; + document(&objs) +} + +fn rename(doc: &mut Document, name: &str, to: &str) -> Result { + set_props(doc, name, &FieldProps { name: Some(to.into()), ..FieldProps::default() }) +} + +/// The partial name (`/T`) of object `num`. +fn own_name(doc: &Document, num: u32) -> Option { + doc.get(ObjRef::new(num, 0)).as_dict().unwrap().get(b"T").and_then(|t| text_of(&doc.resolve(t))) +} + +#[test] +fn renaming_a_kid_is_checked_against_the_full_name_it_takes() { + let mut doc = dotted_form(); + // MASTER.b is taken, though no field's full name is the bare "b". Two fields of one fully + // qualified name are one field and share its value (ISO 32000-2 §12.7.4.2). + let err = rename(&mut doc, "MASTER.a", "b").unwrap_err(); + assert_eq!(err, FormError::Invalid("a field named \"MASTER.b\" already exists".into())); + assert_eq!(own_name(&doc, 6).as_deref(), Some("a"), "a refused rename changes nothing"); + // The same between a field merged with its widget and a field with widget kids. + assert!(rename(&mut doc, "MASTER.e", "a").is_err()); + assert!(rename(&mut doc, "MASTER.a", "e").is_err()); + // A kid keeping its own name is no clash with itself. + assert_eq!(rename(&mut doc, "MASTER.b", "b").unwrap(), "MASTER.b"); + // The top-level "c" does not take MASTER.c. + assert_eq!(rename(&mut doc, "MASTER.a", "c").unwrap(), "MASTER.c"); + assert_eq!(rename(&mut doc, "MASTER.e", "f").unwrap(), "MASTER.f"); + let doc = reopen(&doc); + let all = fields(&doc); + let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["MASTER.c", "MASTER.b", "MASTER.f", "c", "d"]); + for (name, value) in [("MASTER.c", "one"), ("MASTER.b", "two"), ("MASTER.f", "four"), ("c", "three")] { + assert_eq!(field(&all, name).value, [value], "{name} keeps its own value"); + } + // Only the field's own /T changes: the kids stay under MASTER, a merged kid stays its own + // widget, and the widget kids get no /T (which would make them fields). + let widgets = |name: &str| field(&all, name).widgets.iter().map(|w| w.obj.num).collect::>(); + assert_eq!((field(&all, "MASTER.c").obj, widgets("MASTER.c")), (ObjRef::new(6, 0), vec![6])); + assert_eq!((field(&all, "MASTER.f").obj, widgets("MASTER.f")), (ObjRef::new(10, 0), vec![11, 12])); + let partial: Vec> = [5, 6, 10, 11, 12].into_iter().map(|n| own_name(&doc, n)).collect(); + assert_eq!(partial.iter().map(Option::as_deref).collect::>(), [Some("MASTER"), Some("c"), Some("f"), None, None]); +} + +#[test] +fn renaming_a_top_level_field_is_still_checked_against_top_level_names() { + let mut doc = dotted_form(); + let err = rename(&mut doc, "c", "d").unwrap_err(); + assert_eq!(err, FormError::Invalid("a field named \"d\" already exists".into())); + assert_eq!(own_name(&doc, 8).as_deref(), Some("c"), "a refused rename changes nothing"); + // A kid's partial name is no top-level name: MASTER.a leaves "a" free. + assert_eq!(rename(&mut doc, "c", "a").unwrap(), "a"); + let doc = reopen(&doc); + let names: Vec = fields(&doc).into_iter().map(|f| f.name).collect(); + assert_eq!(names, ["MASTER.a", "MASTER.b", "MASTER.e", "a", "d"]); +} + #[test] fn deleting_works_with_a_form_dictionary_inside_the_catalog() { let mut doc = fixture(); @@ -1029,7 +1493,7 @@ fn formats_validation_and_calculations_run_like_acrobat() { // Keystroke and validation errors use Acrobat's messages and change nothing. assert_eq!( set_value(&mut doc, "Qty", &FieldValue::Text("lots".into())), - Err(FormError::Invalid("The value entered does not match the format of the field [ Qty ]".into())) + Err(FormError::Format("The value entered does not match the format of the field [ Qty ]".into())) ); assert_eq!( set_value(&mut doc, "Qty", &FieldValue::Text("120".into())), @@ -1048,10 +1512,364 @@ fn formats_validation_and_calculations_run_like_acrobat() { assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None); } +/// A Calculate script that spells the notation's markers in a string, not in a comment, is kept +/// as a script and runs: the form used to calculate the string's notation instead. +#[test] +fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { + struct Ran; + impl Scripts for Ran { + fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { + ScriptResult { rc: true, value: "ran".into(), changes: Vec::new(), message: None } + } + } + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &text, Some("a")).unwrap(); + add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &text, Some("total")).unwrap(); + let js = "var note = \"BVCALC a EVCALC\"; event.value = 7;"; + set_field_script(&mut doc, "total", "calculate", Some(js)).unwrap(); + set_value(&mut doc, "a", &FieldValue::Text("5".into())).unwrap(); + let all = fields(&doc); + let total = &field(&all, "total").actions; + assert_eq!((&total.calculate, total.scripts.calculate.as_deref()), (&af::Calculate::None, Some(js))); + recalculate_with(&mut doc, &mut Ran).unwrap(); + assert_eq!(field(&fields(&doc), "total").value, ["ran"]); +} + +/// Renaming a field renames it in the calculations that read it: a sum dropped it, notation +/// read it as 0 and a script read a field that no longer existed. +#[test] +fn renaming_a_field_renames_it_in_the_calculations_that_read_it() { + use crate::af::{CalcOp, Calculate}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "sum", "product", "script"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() }; + set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] })).unwrap(); + set_props(&mut doc, "product", &calculate(Calculate::Notation("a * b + ab".into()))).unwrap(); + set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value + getField(\"b\").value;")).unwrap(); + set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap(); + let check = |doc: &Document| { + let all = fields(doc); + assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "b".into()] }); + assert_eq!(field(&all, "product").actions.calculate, Calculate::Notation("qty * b + ab".into())); + let script = field(&all, "script").actions.scripts.calculate.clone(); + assert_eq!(script.as_deref(), Some("event.value = getField(\"qty\").value + getField(\"b\").value;")); + }; + check(&doc); + let mut doc = reopen(&doc); + check(&doc); + set_value(&mut doc, "qty", &FieldValue::Text("10".into())).unwrap(); + set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap(); + let all = fields(&doc); + assert_eq!((field(&all, "sum").value.clone(), field(&all, "product").value.clone()), (vec!["12".to_string()], vec!["20".to_string()])); +} + +/// A dotted name moves the field under new parent fields (#2696), and the calculations that read +/// it follow it to its full new name, as they follow a plain rename. +#[test] +fn a_dotted_rename_moves_the_field_and_its_calculations_follow() { + use crate::af::{CalcOp, Calculate}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "sum", "script"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = + FieldProps { calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] }), ..FieldProps::default() }; + set_props(&mut doc, "sum", &calculate).unwrap(); + set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value;")).unwrap(); + set_props(&mut doc, "a", &FieldProps { name: Some("Group.a".into()), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let all = fields(&doc); + assert!(all.iter().any(|f| f.name == "Group.a") && all.iter().all(|f| f.name != "a")); + assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["Group.a".into(), "b".into()] }); + assert_eq!(field(&all, "script").actions.scripts.calculate.as_deref(), Some("event.value = getField(\"Group.a\").value;")); + set_value(&mut doc, "Group.a", &FieldValue::Text("10".into())).unwrap(); + set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap(); + assert_eq!(field(&fields(&doc), "sum").value, ["12"]); +} + +/// The rename reaches a script stored as a stream, an indirect action and the only widget's +/// actions; it leaves a script that isn't UTF-8 as it is, and a name another field still has. +#[test] +fn a_rename_reaches_every_stored_form_of_a_calculation() { + let widget = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 11 0 R 12 0 R 13 0 R 15 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + widget("a", 700, ""), + widget("stream", 660, "/AA << /C 8 0 R >>"), + widget("bytes", 620, "/AA << /C << /S /JavaScript /JS 10 0 R >> >>"), + "<< /S /JavaScript /JS 9 0 R >>".into(), + "<< /Length 34 >>\nstream\nevent.value = getField(\"a\").value;\nendstream".into(), + "<< /Length 85 /Filter /ASCIIHexDecode >>\nstream\n2F2A20FF202A2F206576656E742E76616C7565203D206765744669656C6428226122292E76616C75653B>\nendstream".into(), + widget("dup", 580, ""), + widget("dup", 540, ""), + widget("both", 500, "/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"dup\", \"a\"\\)\\);) >> >>"), + "<< /FT /Tx /T (kid) /Kids [15 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 460 250 480] /P 3 0 R /AA << /C << /S /JavaScript /JS (event.value = getField\\('a'\\).value;) >> >> >>".into(), + ]; + let mut doc = document(&objs); + let calc = |doc: &Document, name: &str| { + let all = fields(doc); + let f = field(&all, name); + (f.actions.calculate.clone(), f.actions.scripts.calculate.clone().unwrap_or_default()) + }; + assert!(calc(&doc, "stream").1.contains("getField(\"a\")"), "{:?}", calc(&doc, "stream")); + set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap(); + let doc = reopen(&doc); + assert_eq!(calc(&doc, "stream").1, "event.value = getField(\"qty\").value;"); + assert_eq!(calc(&doc, "kid").1, "event.value = getField(\"qty\").value;"); + assert!(calc(&doc, "bytes").1.contains("getField(\"a\")"), "a script that isn't UTF-8 is not written back"); + assert_eq!(calc(&doc, "both").0, af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] }); + let mut doc = doc; + set_props(&mut doc, "dup", &FieldProps { name: Some("dup2".into()), ..FieldProps::default() }).unwrap(); + assert_eq!( + calc(&doc, "both").0, + af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] }, + "the other dup keeps the name" + ); +} + +/// The calculation order is read with what each formula reads and what is wrong with the order; +/// Fix order moves only what must move, and the order it writes is kept across a save. +#[test] +fn the_calculation_order_is_checked_fixed_and_kept() { + use crate::af::{CalcOp, Calculate}; + use crate::calc_order::CalculationIssue::{Cycle, RunsTooEarly}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["qty", "price", "shipping", "sub", "total", "grand", "tax"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() }; + let sum = |names: &[&str]| calculate(Calculate::Simple { op: CalcOp::Sum, fields: names.iter().map(|n| n.to_string()).collect() }); + // Each calculation joins the end of the order, so this chain is written backwards. + set_props(&mut doc, "grand", &sum(&["total"])).unwrap(); + set_props(&mut doc, "total", &sum(&["sub", "shipping"])).unwrap(); + set_props(&mut doc, "sub", &calculate(Calculate::Notation("qty * price".into()))).unwrap(); + set_field_script(&mut doc, "tax", "calculate", Some("event.value = getField(\"total\").value * 0.2; // getField(\"grand\")")).unwrap(); + for (name, v) in [("qty", "2"), ("price", "10"), ("shipping", "5")] { + set_value(&mut doc, name, &FieldValue::Text(v.into())).unwrap(); + } + let value = |doc: &Document, name: &str| field(&fields(doc), name).value.clone(); + assert_eq!((value(&doc, "total"), value(&doc, "grand")), (vec!["25".to_string()], vec!["0".to_string()]), "grand runs too early: stale"); + let order = calculation_order(&doc, &fields(&doc)); + let names: Vec<&str> = order.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["grand", "total", "sub", "tax"]); + let issues: Vec<_> = order.fields.iter().map(|f| f.issues.clone()).collect(); + assert_eq!(issues, [vec![RunsTooEarly { field: "total".into() }], vec![RunsTooEarly { field: "sub".into() }], vec![], vec![]]); + assert_eq!(order.fields[1].reads, ["sub", "shipping"]); + assert_eq!(order.fields[3].reads, ["total"], "a name in a comment isn't read"); + assert!(order.fields.iter().all(|f| f.listed) && order.extra.is_empty()); + assert!(order.fixable()); + assert_eq!(order.fixed(), ["sub", "total", "grand", "tax"]); + set_calculation_order(&mut doc, &order.fixed()).unwrap(); + recalculate(&mut doc).unwrap(); + assert_eq!((value(&doc, "total"), value(&doc, "grand")), (vec!["25".to_string()], vec!["25".to_string()])); + let mut doc = reopen(&doc); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields.iter().map(|f| f.name.as_str()).collect::>(), ["sub", "total", "grand", "tax"], "kept across a save"); + assert!(order.fields.iter().all(|f| f.issues.is_empty()) && !order.fixable()); + // Names given first, the others after them in their order. + set_calculation_order(&mut doc, &["tax".into()]).unwrap(); + let names = |doc: &Document| calculation_order(doc, &fields(doc)).fields.into_iter().map(|f| f.name).collect::>(); + assert_eq!(names(&doc), ["tax", "sub", "total", "grand"]); + for (bad, error) in [ + (vec!["qty"], FormError::Invalid("qty has no calculation".into())), + (vec!["nope"], FormError::NoSuchField("nope".into())), + (vec!["sub", "sub"], FormError::Invalid("sub is in the order twice".into())), + ] { + let bad: Vec = bad.into_iter().map(String::from).collect(); + assert_eq!(set_calculation_order(&mut doc, &bad), Err(error)); + } + assert_eq!(names(&doc), ["tax", "sub", "total", "grand"], "a refused order changes nothing"); + // A loop: sub now reads grand, which reads total, which reads sub. Its new formula keeps its + // place in the order. Fix order can't fix a loop, and leaves it in its order. + set_props(&mut doc, "sub", &calculate(Calculate::Notation("qty * price + grand".into()))).unwrap(); + let order = calculation_order(&doc, &fields(&doc)); + let issues: Vec<(&str, &[_])> = order.fields.iter().map(|f| (f.name.as_str(), f.issues.as_slice())).collect(); + assert_eq!(issues[0], ("tax", &[RunsTooEarly { field: "total".into() }][..])); + assert_eq!( + issues[1..], + [ + ("sub", &[Cycle { fields: vec!["grand".into()] }][..]), + ("total", &[Cycle { fields: vec!["sub".into()] }][..]), + ("grand", &[Cycle { fields: vec!["total".into()] }][..]), + ] + ); + assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total"); +} + +/// `set_props` refuses a notation it couldn't store as written before it writes anything: the +/// tooltip given with it, which is written before the calculation, isn't set either. +#[test] +fn a_notation_that_cannot_be_stored_changes_nothing() { + use crate::af::Calculate; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (total) /Rect [50 700 140 711] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + for n in ["", "a EVCALC b", "a */ b", "a\\"] { + let props = FieldProps { tooltip: Some("Total".into()), calculate: Some(Calculate::Notation(n.into())), ..Default::default() }; + assert!(set_props(&mut doc, "total", &props).is_err(), "{n:?}"); + let all = fields(&doc); + let f = field(&all, "total"); + assert_eq!((f.tooltip.as_deref(), &f.actions.calculate), (None, &Calculate::None), "{n:?} changed nothing"); + } +} + +/// A formula that reads a field the form doesn't have is flagged, whichever kind of formula it +/// is: a field deleted from under a sum, a typo in notation, a script's `getField`. So is a +/// script that builds the names it reads. Neither is an order Fix order can fix. +#[test] +fn missing_fields_and_built_names_are_flagged() { + use crate::af::{CalcOp, Calculate}; + use crate::calc_order::CalculationIssue::{BuiltNames, MissingField}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["qty", "gone", "sum", "notation", "script", "built"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() }; + set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "gone".into()] })).unwrap(); + set_props(&mut doc, "notation", &calculate(Calculate::Notation("qty * nope".into()))).unwrap(); + // qty has one widget, but a widget name is no field: recalculation and getField give qty.0 + // nothing, so it is as missing as qty.1. + let script = "event.value = getField(\"qty\").value + getField(\"ghost\").value + getField(\"qty.0\").value + getField(\"qty.1\").value;"; + set_field_script(&mut doc, "script", "calculate", Some(script)).unwrap(); + set_field_script(&mut doc, "built", "calculate", Some("event.value = getField(\"qty\").value + getField(\"row\" + 1).value;")).unwrap(); + let issues = |doc: &Document| { + let order = calculation_order(doc, &fields(doc)); + assert!(!order.fixable(), "nothing Fix order can fix"); + order.fields.into_iter().map(|f| (f.name, f.issues)).collect::>() + }; + let expected = |sum: Vec<_>| { + vec![ + ("sum".to_owned(), sum), + ("notation".to_owned(), vec![MissingField { field: "nope".into() }]), + ( + "script".to_owned(), + vec![MissingField { field: "ghost".into() }, MissingField { field: "qty.0".into() }, MissingField { field: "qty.1".into() }], + ), + ("built".to_owned(), vec![BuiltNames]), + ] + }; + assert_eq!(issues(&doc), expected(vec![])); + delete_field(&mut doc, "gone").unwrap(); + assert_eq!(issues(&doc), expected(vec![MissingField { field: "gone".into() }]), "a deleted field leaves the sum reading nothing"); + assert_eq!(issues(&reopen(&doc)), expected(vec![MissingField { field: "gone".into() }])); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read"); +} + +/// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an +/// entry repeated, a reference to nothing and a number. The order reads around them, and setting +/// the order writes `/CO` afresh. +#[test] +fn a_calculation_order_with_gaps_and_extra_entries() { + let widget = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20) + }; + let sum = |names: &str| format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{names}\"\\);) >> >>"); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 5 0 R 6 0 R 99 0 R 12] >>".into(), + widget("a", 700, "/V (3)"), + widget("listed", 660, &sum("a")), + widget("unlisted", 620, &sum("listed")), + widget("b", 580, "/V (4)"), + ]; + let mut doc = document(&objs); + let order = calculation_order(&doc, &fields(&doc)); + let rows: Vec<(&str, bool, &[_])> = order.fields.iter().map(|f| (f.name.as_str(), f.listed, f.issues.as_slice())).collect(); + assert_eq!(rows, [("listed", true, &[][..]), ("unlisted", false, &[crate::calc_order::CalculationIssue::NotInOrder][..])]); + assert_eq!(order.extra, ["a", "listed", "99 0 R", "not a reference"]); + assert!(order.fixable()); + assert_eq!(order.fixed(), ["listed", "unlisted"]); + assert_eq!(recalculate(&mut doc), Ok(2), "the unlisted field calculates too, after the listed one"); + assert_eq!(field(&fields(&doc), "unlisted").value, ["3"]); + set_calculation_order(&mut doc, &[]).unwrap(); + let doc = reopen(&doc); + let co = acroform(&doc).and_then(|af| af.get(b"CO").cloned()).unwrap(); + let refs: Vec<_> = doc.resolve(&co).as_array().unwrap().iter().filter_map(Object::as_ref).collect(); + assert_eq!(refs, [field(&fields(&doc), "listed").obj, field(&fields(&doc), "unlisted").obj], "every calculated field once, nothing else"); + assert!(!calculation_order(&doc, &fields(&doc)).fixable()); +} + +/// Two calculated fields with one full name (a malformed form: a full name is one field). The +/// order names them both, so a repeated name stands for the next of them in the current order: +/// the current order sets back unchanged, and Fix order and moves work around them. +#[test] +fn two_fields_with_one_name_keep_their_places_in_the_order() { + let widget = |name: &str, y: u32, reads: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>", + y + 20 + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>".into(), + widget("dup", 700, "x"), + widget("dup", 660, "src"), + widget("src", 620, "x"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>".into(), + ]; + let mut doc = document(&objs); + // Each dup told apart by what it reads. + let rows = |doc: &Document| -> Vec<(String, Vec, bool)> { + calculation_order(doc, &fields(doc)).fields.into_iter().map(|f| (f.name, f.reads, f.issues.is_empty())).collect() + }; + let row = |name: &str, reads: &str, ok: bool| (name.to_owned(), vec![reads.to_owned()], ok); + assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)]); + // Each row is its own field object, so a caller can find the right formula for each dup. + let all = fields(&doc); + for c in calculation_order(&doc, &all).fields { + let f = all.iter().find(|f| f.obj == c.obj).unwrap(); + assert_eq!((&f.name, f.actions.calculate.reads()), (&c.name, c.reads.clone())); + } + let current: Vec = ["dup", "src", "dup"].map(String::from).into(); + set_calculation_order(&mut doc, ¤t).unwrap(); + assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged"); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fixed(), ["src", "dup", "dup"]); + set_calculation_order(&mut doc, &order.fixed()).unwrap(); + assert_eq!(rows(&doc), [row("src", "x", true), row("dup", "src", true), row("dup", "x", true)], "the dup that reads src moves after it"); + // Each dup calculates, not the first of them twice. + assert_eq!(recalculate(&mut doc), Ok(3)); + let values: Vec> = fields(&doc).into_iter().filter(|f| f.name == "dup").map(|f| f.value).collect(); + assert_eq!(values, [["3"], ["3"]]); + // A name given alone stands for the first of its fields in the current order. + set_calculation_order(&mut doc, &["dup".into()]).unwrap(); + assert_eq!(rows(&doc)[0], row("dup", "src", false)); + let thrice: Vec = ["dup", "dup", "dup"].map(String::from).into(); + assert_eq!(set_calculation_order(&mut doc, &thrice), Err(FormError::Invalid("dup is in the order twice".into()))); +} + #[test] fn new_text_fields_fit_their_box() { // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's - // Auto size: 12 pt where there is room, smaller where there isn't. + // Auto size: the size the box's height gives, smaller where the text is too wide. let mut doc = one_page(); let tf_size = |ap: &str| { ap.split("/Helv ") @@ -1070,7 +1888,9 @@ fn new_text_fields_fit_their_box() { } add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap(); - assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look"); + // 22 pt tall, less a 1 pt border and 2 pt padding each side: 16 / 1.15. + let tall = tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])); + assert!((tall - 16.0 / 1.15).abs() < 0.01, "a box with room fills its height, not 12 pt: {tall}"); // An explicit size still wins. set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0); @@ -1130,6 +1950,73 @@ fn tab_order_follows_the_page_setting() { assert!(set_tab_order(&mut doc.clone(), &[3], TabOrder::Row).is_err()); } +#[test] +fn rows_and_columns_tab_right_to_left_in_a_document_bound_on_the_right() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + // A (50,700) B (300,700) + // C (50,600) D (300,600) + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let order = |doc: &Document| -> String { + let mut all: Vec<(usize, String)> = fields(doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + all.into_iter().map(|x| x.1).collect() + }; + let root = doc.root().unwrap(); + let mut prefs = pdfcraft_cos::Dict::new(); + prefs.set(b"Direction".to_vec(), Object::name("R2L")); + let prefs = doc.add(prefs); + doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), Object::Ref(prefs))).unwrap(); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "BADC", "rows top to bottom, each right to left"); + set_tab_order(&mut doc, &[0], TabOrder::Column).unwrap(); + assert_eq!(order(&doc), "BDAC", "columns right to left, each top to bottom"); + assert_eq!(order(&reopen(&doc)), "BDAC"); + set_tab_order(&mut doc, &[0], TabOrder::Annotations).unwrap(); + assert_eq!(order(&doc), "CADB", "unspecified keeps annotation order"); + doc.update_dict(prefs, |p| p.set(b"Direction".to_vec(), Object::name("L2R"))).unwrap(); + set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap(); + assert_eq!(order(&doc), "ABCD", "bound on the left"); +} + +#[test] +fn tab_orders_are_read_for_each_page() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /C >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /S >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs /W >>".into(), + "<< /Type /Page /Parent 2 0 R /Tabs 8 0 R >>".into(), + "/R".into(), + ]); + use TabOrder::{Annotations, Column, Row, Structure}; + assert_eq!(tab_orders(&doc), [Column, Annotations, Structure, Annotations, Row], "no /Tabs, or an unknown name: unspecified"); + set_tab_order(&mut doc, &[1], Row).unwrap(); + set_tab_order(&mut doc, &[0, 4], Annotations).unwrap(); + let doc = reopen(&doc); + assert_eq!(tab_orders(&doc), [Annotations, Row, Structure, Annotations, Annotations]); +} + +#[test] +fn an_indirect_tabs_value_orders_the_page() { + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] { + add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap(); + } + let column = doc.add(Object::name("C")); + let page = page_refs(&doc)[0]; + doc.update_dict(page, |p| p.set(b"Tabs".to_vec(), Object::Ref(column))).unwrap(); + let mut all: Vec<(usize, String)> = fields(&doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect(); + all.sort(); + assert_eq!(all.into_iter().map(|x| x.1).collect::(), "ACBD", "columns, as a direct /C orders them"); + assert_eq!(tab_orders(&doc), [TabOrder::Column]); +} + #[test] fn options_tab_flags_alignment_and_defaults() { let mut doc = one_page(); @@ -1230,6 +2117,49 @@ fn check_box_styles_change_the_mark() { ); } +#[test] +fn check_mark_takes_the_fields_text_colour() { + // #4261: Appearance ▸ Text Color is written to /DA; the mark of a check box or radio button + // is drawn in it (Acrobat does the same), black when the /DA has no colour. + let mut doc = one_page(); + let check = add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &NewField::CheckBox, None).unwrap(); + let radio = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::Radio { group: None, export: "A".into() }, None).unwrap(); + let on_ap = |doc: &Document, name: &str| { + let all = fields(doc); + let w = &field(&all, name).widgets[0]; + let on = w.on_state.clone().unwrap(); + let n = doc + .get(w.obj) + .as_dict() + .unwrap() + .get(b"AP") + .unwrap() + .as_dict() + .unwrap() + .get(b"N") + .unwrap() + .as_dict() + .unwrap() + .reference(on.as_bytes()) + .unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + assert!(on_ap(&doc, &check).contains("0 G"), "black by default"); + let blue = FieldProps { appearance: Some(LookPatch { text: Some([0.0, 0.0, 1.0]), ..Default::default() }), ..Default::default() }; + for name in [&check, &radio] { + set_props(&mut doc, name, &blue).unwrap(); + let ap = on_ap(&reopen(&doc), name); + // The check strokes, the radio's default circle fills. + let op = if name == &check { "0 0 1 RG" } else { "0 0 1 rg" }; + assert!(ap.contains(op), "{name}: the mark is blue: {ap}"); + assert!(!ap.contains("0 G\n") && !ap.contains("0 g\n"), "{name}: no black left: {ap}"); + } + // A style change redraws too, and keeps the colour. + set_props(&mut doc, &check, &FieldProps { check_style: Some(CheckStyle::Square), ..FieldProps::default() }).unwrap(); + assert!(on_ap(&doc, &check).contains("0 0 1 rg"), "a square is blue as well"); +} + #[test] fn ordering_tabs_manually() { let mut doc = one_page(); @@ -1411,6 +2341,80 @@ fn field_actions_round_trip_on_every_trigger() { assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into()))); } +#[test] +fn editing_one_script_keeps_the_other_actions_and_the_chain() { + let mut doc = fixture(); + let go = field(&fields(&doc), "go").obj; + let action = |doc: &mut Document, entries: Vec<(&[u8], Object)>| { + let mut a = Dict::new(); + for (k, v) in entries { + a.set(k.to_vec(), v); + } + doc.add(Object::Dict(a)) + }; + let next = action(&mut doc, vec![(b"S", Object::name("URI")), (b"URI", Object::String(PdfString::literal(b"https://example.org".to_vec())))]); + let up = action( + &mut doc, + vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("app.alert(1);"))), (b"Next", Object::Ref(next))], + ); + let blur = action( + &mut doc, + vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("this.dirty = true;"))), (b"Foo", Object::Int(7))], + ); + let mut aa = Dict::new(); + aa.set(b"Bl".to_vec(), Object::Ref(blur)); + doc.update_dict(go, |d| { + d.set(b"A".to_vec(), Object::Ref(up)); + d.set(b"AA".to_vec(), Object::Dict(aa)); + }) + .unwrap(); + let mut acts = field_actions(&doc, "go").unwrap(); + assert_eq!(acts[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(1);".into()))); + acts[0].1 = FieldAction::JavaScript("app.alert(2);".into()); + set_field_actions(&mut doc, "go", &acts).unwrap(); + let w = doc.get(go).as_dict().cloned().unwrap(); + // The action that did not change is the same object, with its keys this crate doesn't read. + let aa = w.get(b"AA").unwrap().as_dict().unwrap().clone(); + assert_eq!(aa.get(b"Bl"), Some(&Object::Ref(blur))); + // The edited script keeps the actions chained after it. + let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(next))); + assert_eq!(field_actions(&doc, "go").unwrap()[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(2);".into()))); +} + +#[test] +fn replacing_a_chained_action_with_another_type_keeps_the_chain() { + let mut doc = fixture(); + let go = field(&fields(&doc), "go").obj; + let s = |t: &str| Object::String(PdfString::text(t)); + let mut named = Dict::new(); + named.set(b"S".to_vec(), Object::name("Named")); + named.set(b"N".to_vec(), Object::name("NextPage")); + let named = doc.add(Object::Dict(named)); + // A chain as an array: a link, then a menu item that points back at the link (a loop). + let mut uri = Dict::new(); + uri.set(b"S".to_vec(), Object::name("URI")); + uri.set(b"URI".to_vec(), s("https://example.org")); + let uri = doc.add(Object::Dict(uri)); + doc.update_dict(named, |d| d.set(b"Next".to_vec(), Object::Ref(uri))).unwrap(); + let chain = Object::Array(vec![Object::Ref(uri), Object::Ref(named)]); + let mut up = Dict::new(); + up.set(b"S".to_vec(), Object::name("JavaScript")); + up.set(b"JS".to_vec(), s("app.alert(1);")); + up.set(b"Next".to_vec(), chain.clone()); + doc.update_dict(go, |d| d.set(b"A".to_vec(), Object::Dict(up))).unwrap(); + assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]); + set_field_actions(&mut doc, "go", &[(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]).unwrap(); + let w = doc.get(go).as_dict().cloned().unwrap(); + let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(a.get(b"Next"), Some(&chain)); + assert_eq!(field_actions(&doc, "go").unwrap(), [(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]); + assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]); + // Deleting the trigger's action deletes its chain with it. + set_field_actions(&mut doc, "go", &[]).unwrap(); + assert_eq!(field_action_chains(&doc, "go").unwrap(), []); +} + #[test] fn push_buttons_read_hide_actions() { let mut doc = fixture(); @@ -1575,6 +2579,38 @@ fn page_shapes_reads_boxes_and_rules() { assert_eq!(sh.rules, [[40.0, 100.0, 240.0, 100.0], [60.0, 600.5, 260.0, 600.5]]); } +#[test] +fn page_shapes_preserves_content_piece_boundaries() { + use crate::detect::{Candidate, Kind, Shapes, detect, page_shapes}; + let read = |pieces: &[&[u8]]| { + let mut doc = one_page(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let contents = + pieces.iter().map(|bytes| Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), bytes))))).collect(); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(contents))).unwrap(); + page_shapes(&doc, 0) + }; + let empty = Shapes::default(); + for pieces in [vec![b"% trailing".as_slice(), b"40 120 300 30 re S\n"], vec![b"% trailing".as_slice(), b"", b"", b"40 120 300 30 re S\n"]] { + let shapes = read(&pieces); + assert_eq!(shapes, empty, "an unterminated comment hides the next piece's first line"); + assert_eq!(detect(&[], &shapes, &[], &[]), Vec::::new()); + } + let visible = Shapes { boxes: vec![[40.0, 120.0, 340.0, 150.0]], rules: vec![] }; + for ending in [b"% trailing\n".as_slice(), b"% trailing\r", b"% trailing\r\n"] { + let shapes = read(&[ending, b"40 120 300 30 re S\n"]); + assert_eq!(shapes, visible); + assert_eq!(detect(&[], &shapes, &[], &[]), vec![Candidate { kind: Kind::Text, rect: [41.0, 121.0, 339.0, 149.0], name: "Text1".into() }]); + } + assert_eq!(read(&[b"40 120", b"", b"300 30", b"re", b"S"]), visible); + assert_eq!( + read(&[b"q 2 0 0 2 0 0 cm 10 10", b"60 20 re", b"S Q 10 20 m 100 20 l S"]), + Shapes { boxes: vec![[20.0, 20.0, 140.0, 60.0]], rules: vec![[10.0, 20.0, 100.0, 20.0]] } + ); + assert_eq!(read(&[]), empty); + assert_eq!(read(&[b""]), empty); +} + fn close(a: f64, b: f64) -> bool { (a - b).abs() < 0.05 } @@ -2239,6 +3275,22 @@ fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() { assert!(filled.rules.is_empty()); } +#[test] +fn zero_max_len_is_unlimited_and_overrides_inherited_limit() { + let mut doc = fixture(); + let city = fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap(); + let parent = doc.get(city.obj).as_dict().unwrap().get(b"Parent").unwrap().as_ref().unwrap(); + doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap(); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap().max_len, Some(3)); + doc.update_dict(city.obj, |d| d.set(b"MaxLen".to_vec(), Object::Int(0))).unwrap(); + assert_eq!(fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap().max_len, None); + set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap(); + let again = reopen(&doc); + assert_eq!(field(&fields(&again), "address.city").value, ["Absalom"]); + assert_eq!(again.get(city.obj).as_dict().unwrap().int(b"MaxLen"), Some(0), "preserve the original entry"); + assert!(set_value(&mut doc, "zip", &FieldValue::Text("123456".into())).is_err(), "positive limits still apply"); +} + #[test] fn auto_text_size_fits_single_and_multiline_text() { use crate::appearance::auto_text_size; @@ -2248,8 +3300,12 @@ fn auto_text_size_fits_single_and_multiline_text() { assert_eq!(auto_text_size("Hi", 94.0, 8.0, false, helvetica_width), 8.0 / 1.15); let s = auto_text_size(long, 194.0, 8.0, false, helvetica_width); assert!(s > 4.0 && s < 8.0 / 1.15 && helvetica_width(long, s) <= 194.0 + 1e-9, "{s}"); - // Never past 12 pt or under 4 pt. - assert_eq!(auto_text_size("Hi", 500.0, 500.0, false, helvetica_width), 12.0); + // A tall single-line box gets text as tall as it allows (Acrobat has no 12 pt cap there), + // bounded by the largest page; never under 4 pt. + assert_eq!(auto_text_size("Hi", 500.0, 24.0, false, helvetica_width), 24.0 / 1.15); + assert_eq!(auto_text_size("", 500.0, 1e300, false, helvetica_width), crate::appearance::MAX_AUTO_HEIGHT / 1.15); + let s = auto_text_size("Hi", 500.0, 500.0, false, helvetica_width); + assert!(s > 12.0 && helvetica_width("Hi", s) <= 500.0 + 1e-9, "{s}"); assert_eq!(auto_text_size(&long.repeat(20), 20.0, 8.0, false, helvetica_width), 4.0); // Multiline: the largest size whose wrapped lines fit the height. let m = auto_text_size(long, 114.0, 44.0, true, helvetica_width); @@ -2277,3 +3333,2155 @@ fn document_scripts_read_each_name_tree_node_once() { ]); assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); } + +/// #3470: a form whose writer left the appearances to the viewer. Field A has an empty one, +/// B none, C its own (`(old C)`), D is a combo without one, E a check box with states, F a list +/// box without one, and G only a down appearance (`/D`, no `/N`). +fn left_to_the_viewer(need_appearances: bool) -> Document { + let flag = if need_appearances { "/NeedAppearances true " } else { "" }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 13 0 R 14 0 R] >>".into(), + format!("<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 13 0 R 14 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /V (Value A) /DA (/Helv 12 Tf 0 g) /Rect [50 700 350 720] /P 3 0 R /AP << /N 11 0 R >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (b) /V (Value B) /DA (/Helv 12 Tf 0 g) /Rect [50 650 350 670] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (c) /V (Value C) /DA (/Helv 12 Tf 0 g) /Rect [50 600 350 620] /P 3 0 R /AP << /N 12 0 R >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (d) /Opt [(Red) (Blue)] /V (Blue) /DA (/Helv 12 Tf 0 g) /Rect [50 550 350 570] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (e) /V /Yes /AS /Yes /Rect [400 700 415 715] /P 3 0 R /AP << /N << /Yes 11 0 R /Off 11 0 R >> >> >>".into(), + "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), + "<< /Length 18 >>\nstream\nBT (old C) Tj ET\nendstream".into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (f) /Opt [(Green) (Grey)] /V (Green) /DA (/Helv 12 Tf 0 g) /Rect [50 450 350 520] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (g) /V (Value G) /DA (/Helv 12 Tf 0 g) /Rect [50 400 350 420] /P 3 0 R /AP << /D 11 0 R >> >>".into(), + ]) +} + +/// #3470: with `/NeedAppearances`, every text and choice widget of the display copy is drawn +/// from its value, its own appearance or not; buttons keep their states, and the document +/// itself is untouched. +#[test] +fn need_appearances_draws_every_text_and_choice_field_in_the_display_copy() { + let doc = left_to_the_viewer(true); + let copy = with_field_appearances(&doc).expect("fields to draw"); + let all = fields(©); + for (name, value) in + [("a", "(Value A) Tj"), ("b", "(Value B) Tj"), ("c", "(Value C) Tj"), ("d", "(Blue) Tj"), ("f", "(Green) Tj"), ("g", "(Value G) Tj")] + { + let shown = ap(©, &field(&all, name).widgets[0]); + assert!(shown.contains(value), "{name}: {shown}"); + } + let e = |d: &Document| d.get(field(&fields(d), "e").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(e(©), e(&doc), "the check box keeps its states"); + let original = fields(&doc); + assert_eq!(ap(&doc, &field(&original, "a").widgets[0]).trim_end(), "q\nQ", "the document keeps its own appearances"); + assert!(doc.get(field(&original, "b").widgets[0].obj).as_dict().is_some_and(|w| !w.contains(b"AP"))); +} + +/// #3470: without `/NeedAppearances`, only widgets with no normal appearance are drawn; an +/// appearance the file has, even an empty one, is the file's. A form with all of them needs no +/// copy. +#[test] +fn without_need_appearances_only_missing_appearances_are_drawn() { + let doc = left_to_the_viewer(false); + let copy = with_field_appearances(&doc).expect("b, d, f and g have none"); + let all = fields(©); + assert_eq!(ap(©, &field(&all, "a").widgets[0]).trim_end(), "q\nQ"); + assert!(ap(©, &field(&all, "b").widgets[0]).contains("(Value B) Tj")); + assert_eq!(ap(©, &field(&all, "c").widgets[0]).trim_end(), "BT (old C) Tj ET"); + assert!(ap(©, &field(&all, "d").widgets[0]).contains("(Blue) Tj")); + assert!(ap(©, &field(&all, "f").widgets[0]).contains("(Green) Tj")); + assert!(ap(©, &field(&all, "g").widgets[0]).contains("(Value G) Tj"), "a down appearance alone is no normal one"); + assert!(with_field_appearances(©).is_none(), "every widget has one now"); +} + +/// #3470: refreshing the display copy redraws only the widgets whose inputs changed. Typing in +/// one field of a form that leaves every appearance to the viewer otherwise redrew all of them. +#[test] +fn the_field_appearance_cache_redraws_only_what_changed() { + let mut doc = left_to_the_viewer(true); + let mut cache = FieldAppearanceCache::default(); + with_field_appearances_cached(&doc, &mut cache).expect("fields to draw"); + let drawn = cache.builds(); + assert_eq!(drawn, 6, "a, b, c, d, f and g"); + with_field_appearances_cached(&doc, &mut cache).expect("fields to draw"); + assert_eq!((cache.builds(), cache.hits()), (drawn, drawn), "nothing changed, nothing redrawn"); + + set_value(&mut doc, "b", &FieldValue::Text("Typed".into())).unwrap(); + let copy = with_field_appearances_cached(&doc, &mut cache).expect("fields to draw"); + assert_eq!(cache.builds(), drawn + 1, "only b is redrawn"); + assert!(ap(©, &field(&fields(©), "b").widgets[0]).contains("(Typed) Tj"), "and shows its new value"); + assert!(ap(©, &field(&fields(©), "a").widgets[0]).contains("(Value A) Tj"), "a reuses its drawing"); +} + +/// #3470: each kind of input a field's drawing depends on invalidates its cached appearance when +/// it changes, wherever it lives: the parent field's value, the form's `/DA`, an indirect `/MK` +/// and a `/DR` font. Field H is a parent with one kid widget; I is a plain field. +#[test] +fn the_field_appearance_cache_sees_every_input() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>".into(), + "<< /Fields [5 0 R 7 0 R] /NeedAppearances true /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >>".into(), + "<< /FT /Tx /T (h) /V (Kid value) /Kids [6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 350 720] /P 3 0 R /MK 9 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (i) /V (Plain) /Rect [50 650 350 670] /P 3 0 R >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /BG [1 1 1] >>".into(), + ]); + let r = |n: u32| pdfcraft_cos::ObjRef::new(n, 0); + let mut cache = FieldAppearanceCache::default(); + let mut redraw = |doc: &Document| { + let before = cache.builds(); + let copy = with_field_appearances_cached(doc, &mut cache).expect("fields to draw"); + (cache.builds() - before, copy) + }; + assert_eq!(redraw(&doc).0, 2); + assert_eq!(redraw(&doc).0, 0, "unchanged"); + + doc.update_dict(r(5), |d| d.set(b"V".to_vec(), Object::String(PdfString::text("New value")))).unwrap(); + let (n, copy) = redraw(&doc); + assert_eq!(n, 1, "the parent's value: only its kid"); + assert!(ap(©, &field(&fields(©), "h").widgets[0]).contains("(New value) Tj")); + + doc.update_dict(r(9), |d| d.set(b"BG".to_vec(), Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0)]))).unwrap(); + let (n, copy) = redraw(&doc); + assert_eq!(n, 1, "the kid's indirect /MK"); + assert!(ap(©, &field(&fields(©), "h").widgets[0]).contains("1 0 0 rg"), "the new background"); + + doc.update_dict(r(4), |d| d.set(b"DA".to_vec(), Object::String(PdfString::text("/Helv 9 Tf 0 g")))).unwrap(); + let (n, copy) = redraw(&doc); + assert_eq!(n, 2, "the form's /DA: both fields"); + assert!(ap(©, &field(&fields(©), "i").widgets[0]).contains("/Helv 9 Tf")); + + doc.update_dict(r(8), |d| d.set(b"BaseFont".to_vec(), Object::Name(b"Courier".to_vec()))).unwrap(); + assert_eq!(redraw(&doc).0, 2, "a /DR font: both fields"); +} + +/// #3470: an indirect `/DR` and an indirect `/Font` inside it are inputs too, with a font written +/// directly into the `/Font` dictionary, and so is the form's `/DA` when it is an indirect string. +#[test] +fn the_field_appearance_cache_sees_indirect_resources() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + "<< /Fields [5 0 R] /NeedAppearances true /DA 8 0 R /DR 6 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (i) /V (Plain) /Rect [50 650 350 670] /P 3 0 R >>".into(), + "<< /Font 7 0 R >>".into(), + "<< /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>".into(), + "(/Helv 12 Tf 0 g)".into(), + ]); + let r = |n: u32| pdfcraft_cos::ObjRef::new(n, 0); + let mut cache = FieldAppearanceCache::default(); + let mut redraw = |doc: &Document| { + let before = cache.builds(); + with_field_appearances_cached(doc, &mut cache).expect("a field to draw"); + cache.builds() - before + }; + assert_eq!(redraw(&doc), 1); + assert_eq!(redraw(&doc), 0); + let courier = || { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::Name(b"Font".to_vec())); + f.set(b"Subtype".to_vec(), Object::Name(b"Type1".to_vec())); + f.set(b"BaseFont".to_vec(), Object::Name(b"Courier".to_vec())); + Object::Dict(f) + }; + doc.update_dict(r(7), |d| d.set(b"Helv".to_vec(), courier())).unwrap(); + assert_eq!(redraw(&doc), 1, "the indirect /Font dictionary"); + doc.update_dict(r(6), |d| { + let mut fonts = Dict::new(); + fonts.set(b"Helv".to_vec(), courier()); + d.set(b"Font".to_vec(), Object::Dict(fonts)); + }) + .unwrap(); + assert_eq!(redraw(&doc), 1, "the indirect /DR"); + doc.set(r(8), Object::String(PdfString::text("/Helv 9 Tf 0 g"))); + assert_eq!(redraw(&doc), 1, "the form's indirect /DA"); +} + +#[test] +fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() { + let mut doc = fixture(); + let root = ObjRef { num: 2, generation: 0 }; + let page = ObjRef { num: 3, generation: 0 }; + // A cycle back to the root, then the page 128 times over. + let mut kids = vec![Object::Ref(root)]; + kids.extend(std::iter::repeat_n(Object::Ref(page), 128)); + doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap(); + let mut walk = PageWalk::new(&doc, root); + assert_eq!(walk.next(), Some(page)); + assert!(walk.pending.len() <= 2, "repeated kids retained {} pending entries", walk.pending.len()); + assert_eq!(walk.next(), None); + assert_eq!(page_refs(&doc), [page]); +} + +/// A Calculate script naming thousands of fields is merged with the field's other reads in one +/// pass, not by searching the names so far for each one. +#[test] +fn a_script_reading_many_fields_merges_its_reads_in_one_pass() { + use crate::calc_order::NAMES_COMPARED; + let mut doc = one_page(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("total")).unwrap(); + let n = 2_000; + let script: String = (0..n).map(|i| format!("getField(\"f{i}\").value + ")).collect::() + "0"; + set_field_script(&mut doc, "total", "calculate", Some(&format!("event.value = {script};"))).unwrap(); + NAMES_COMPARED.with(|c| c.set(0)); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields[0].reads.len(), n); + let compared = NAMES_COMPARED.with(std::cell::Cell::get); + assert!(compared <= 4 * n, "{compared} names compared for {n} reads"); +} + +/// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream. +fn repeated_script_references() -> Document { + use pdfcraft_cos::Stream; + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let js = doc.add(Stream::flate(pdfcraft_cos::Dict::new(), &vec![b'x'; 1 << 20])); + let action = doc.add(dict(vec![(b"S", Object::Name(b"JavaScript".to_vec())), (b"JS", Object::Ref(js))])); + let fields: Vec = (0..300) + .map(|i| { + Object::Ref(doc.add(dict(vec![ + (b"FT", Object::Name(b"Tx".to_vec())), + (b"T", Object::String(pdfcraft_cos::PdfString::literal(format!("f{i}")))), + (b"Rect", Object::Array([0, 0, 10, 10].map(Object::from).to_vec())), + (b"AA", dict(vec![(b"C", Object::Ref(action))])), + ]))) + }) + .collect(); + let names: Vec = + (0..300).flat_map(|i| [Object::String(pdfcraft_cos::PdfString::literal(format!("s{i:03}"))), Object::Ref(action)]).collect(); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"AcroForm", dict(vec![(b"Fields", Object::Array(fields))])), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Names", Object::Array(names))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_script_references_share_one_decode_budget() { + let doc = repeated_script_references(); + let limit = MAX_SCRIPT_BYTES; + let named: usize = document_scripts_named(&doc).iter().map(|(_, js)| js.len()).sum(); + assert!(named > 0 && named <= limit, "{named} bytes of document scripts"); + let all = fields(&doc); + assert_eq!(all.len(), 300); + let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum(); + assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts"); +} + +/// A widget listed many times, by one field's `/Kids` or by a `/Kids` array several fields share, +/// is read once: the first field to list it owns it. +#[test] +fn a_widget_listed_repeatedly_is_read_once() { + let kids = "7 0 R ".repeat(500); + let doc = document(&[ + "<< /Type /Catalog /Pages 4 0 R /AcroForm << /Fields [2 0 R 3 0 R] >> >>".into(), + "<< /FT /Tx /T (a) /Kids 6 0 R >>".into(), + "<< /FT /Tx /T (b) /Kids 6 0 R >>".into(), + "<< /Type /Pages /Kids [5 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 4 0 R /MediaBox [0 0 100 100] /Annots [7 0 R] >>".into(), + format!("[{kids}]"), + "<< /Type /Annot /Subtype /Widget /Parent 2 0 R /Rect [0 0 10 10] >>".into(), + ]); + let all = fields(&doc); + let widgets: Vec = all.iter().map(|f| f.widgets.len()).collect(); + assert_eq!(widgets, [1, 0], "{:?}", all.iter().map(|f| &f.name).collect::>()); +} + +/// A field of a kind that isn't read doesn't claim the widget it shares with a field that is. +#[test] +fn a_skipped_field_leaves_its_widget_to_the_next() { + let doc = document(&[ + "<< /Type /Catalog /Pages 4 0 R /AcroForm << /Fields [2 0 R 3 0 R] >> >>".into(), + "<< /FT /Zz /T (odd) /Kids [6 0 R] >>".into(), + "<< /FT /Tx /T (b) /Kids [6 0 R] >>".into(), + "<< /Type /Pages /Kids [5 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 4 0 R /MediaBox [0 0 100 100] /Annots [6 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 3 0 R /Rect [0 0 10 10] >>".into(), + ]); + let all = fields(&doc); + let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); + assert_eq!(widgets, [("b", 1)]); +} + +/// #2696: a dotted name is a full name. `MASTER.first` is a field `first` under a parent field +/// `MASTER` (a partial name can't contain a period, ISO 32000-2 §12.7.4.2); a second `MASTER.` +/// name joins that parent; renaming to a dotted name moves the field, keeping what it inherited; +/// and a name that would make one field both hold a value and hold fields is refused. +#[test] +fn dotted_field_names_are_made_with_parent_fields() { + let mut doc = fixture(); + let top = |doc: &Document| { + let root = doc.root().unwrap(); + let af = doc.get(root).as_dict().and_then(|d| d.reference(b"AcroForm")).unwrap(); + doc.get(af).as_dict().and_then(|d| d.get(b"Fields").and_then(|o| o.as_array().map(|a| a.len()))).unwrap() + }; + let own_name = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)); + let parent_of = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + let text = NewField::Text { multiline: false }; + let before = top(&doc); + assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some("MASTER.account_firstname")).unwrap(), "MASTER.account_firstname"); + add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("MASTER.street1")).unwrap(); + assert_eq!(top(&doc), before + 1, "one parent field at the top"); + let saved = reopen(&doc); + let all = fields(&saved); + let (a, b) = (field(&all, "MASTER.account_firstname"), field(&all, "MASTER.street1")); + assert_eq!(own_name(&saved, b).as_deref(), Some("street1"), "the field's own name has no period"); + let master = parent_of(&saved, a); + assert_eq!(parent_of(&saved, b), master, "both under one MASTER"); + assert_eq!(saved.get(master).as_dict().and_then(|d| d.get(b"T").and_then(text_of)).as_deref(), Some("MASTER")); + + // Renaming to a dotted name moves the field: zip joins the existing address parent. + assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some("address.zip".into()), ..FieldProps::default() }).unwrap(), "address.zip"); + assert_eq!(top(&doc), before, "zip left the top"); + let all = fields(&doc); + let (zip, city) = (field(&all, "address.zip"), field(&all, "address.city")); + assert_eq!(parent_of(&doc, zip), parent_of(&doc, city)); + assert_eq!(zip.max_len, Some(5), "its own settings move with it"); + + // What a moved field inherited stays; what a new field would inherit from a parent it + // joins doesn't apply to it. + let address = parent_of(&doc, city); + doc.update_dict(address, |d| { + d.set(b"Ff".to_vec(), Object::Int(flags::READ_ONLY as i64)); + d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec()))); + }) + .unwrap(); + let city = field(&fields(&doc), "address.city").clone(); + assert!(city.read_only() && city.value == ["Springfield"]); + set_props(&mut doc, "address.city", &FieldProps { name: Some("billing.city".into()), ..FieldProps::default() }).unwrap(); + let moved = field(&fields(&doc), "billing.city").clone(); + assert!(moved.read_only() && moved.value == ["Springfield"], "the moved field keeps what it inherited"); + // A parent's value, reset value or length limit would pass to a new field under it (review + // on #2957), and a field can't turn them off: such a parent takes no new field. + let street = |doc: &mut Document| add_field(doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("address.street")); + for (key, value) in [ + (&b"V"[..], Object::String(PdfString::literal(b"x".to_vec()))), + (b"DV", Object::String(PdfString::literal(b"x".to_vec()))), + (b"MaxLen", Object::Int(3)), + ] { + doc.update_dict(address, |d| { + for k in [&b"V"[..], b"DV", b"MaxLen"] { + d.remove(k); + } + d.set(key.to_vec(), value); + }) + .unwrap(); + let mut copy = doc.clone(); + assert!(street(&mut copy).is_err(), "address holds /{}", String::from_utf8_lossy(key)); + } + doc.update_dict(address, |d| { + d.remove(b"MaxLen"); + }) + .unwrap(); + street(&mut doc).unwrap(); + let new = field(&fields(&doc), "address.street").clone(); + assert!(!new.read_only(), "a new field doesn't take its parent's flags"); + assert!(new.value.iter().all(|v| v.is_empty()) && new.max_len.is_none(), "nor a value or length limit: {:?} {:?}", new.value, new.max_len); + + // A parent left empty by a move isn't a terminal field, but its name is still taken. + add_field(&mut doc, 0, [300.0, 340.0, 450.0, 360.0], &text, Some("box.only")).unwrap(); + set_props(&mut doc, "box.only", &FieldProps { name: Some("elsewhere.only".into()), ..FieldProps::default() }).unwrap(); + assert!(fields(&doc).iter().all(|f| !f.name.starts_with("box")), "box holds no field now"); + let mut copy = doc.clone(); + assert!(add_field(&mut copy, 0, [300.0, 380.0, 450.0, 400.0], &text, Some("box")).is_err(), "a new field named box"); + let mut copy = doc.clone(); + assert!(set_props(&mut copy, "name", &FieldProps { name: Some("box".into()), ..FieldProps::default() }).is_err(), "a rename to box"); + // Under it, the name is free: the empty parent takes a field again. + add_field(&mut doc, 0, [300.0, 380.0, 450.0, 400.0], &text, Some("box.again")).unwrap(); + // Only a whole path that exists can be taken: under box.sub, made new, "again" is free. + add_field(&mut doc, 0, [300.0, 420.0, 450.0, 440.0], &text, Some("box.sub.again")).unwrap(); + // A field holding its own value may join a parent holding one; and the parents a new path + // makes are new, even where an existing field has the name of a later part. + doc.update_dict(address, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"x".to_vec())))).unwrap(); + set_props(&mut doc, "billing.city", &FieldProps { name: Some("address.city".into()), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").value, ["Springfield"]); + add_field(&mut doc, 0, [300.0, 460.0, 450.0, 480.0], &text, Some("nope.address.x")).unwrap(); + // Renaming the last part: to its own name is no change; to an empty parent's name beside it + // is taken. + assert_eq!(set_props(&mut doc, "address.city", &FieldProps { name: Some("city".into()), ..FieldProps::default() }).unwrap(), "address.city"); + doc.update_dict(address, |d| { + d.remove(b"V"); + }) + .unwrap(); + add_field(&mut doc, 0, [300.0, 500.0, 450.0, 520.0], &text, Some("address.grp.x")).unwrap(); + set_props(&mut doc, "address.grp.x", &FieldProps { name: Some("elsewhere.x".into()), ..FieldProps::default() }).unwrap(); + assert!( + set_props(&mut doc.clone(), "address.city", &FieldProps { name: Some("grp".into()), ..FieldProps::default() }).is_err(), + "address.grp is taken" + ); + // A move the destination refuses leaves the field where it was (review on #2957): it was + // taken out of the tree before the destination was checked, and stayed out. + doc.update_dict(address, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap(); + let at_top = top(&doc); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some("address.name".into()), ..FieldProps::default() }).is_err()); + assert_eq!(top(&doc), at_top, "name is still in the form's /Fields"); + // Nor is what it inherits copied onto it first (review on #2957): src.f inherits /V from src. + add_field(&mut doc, 0, [300.0, 540.0, 450.0, 560.0], &text, Some("src.f")).unwrap(); + let f = field(&fields(&doc), "src.f").obj; + let src = doc.get(f).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + doc.update_dict(src, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"inh".to_vec())))).unwrap(); + assert!(set_props(&mut doc, "src.f", &FieldProps { name: Some("address.f".into()), ..FieldProps::default() }).is_err()); + assert!(!doc.get(f).as_dict().unwrap().contains(b"V"), "src.f still only inherits its value"); + // What it inherits counts as its own where it goes: with /V on address and no /MaxLen, + // src.f moves, keeping its value. + doc.update_dict(address, |d| { + d.remove(b"MaxLen"); + d.set(b"V".to_vec(), Object::String(PdfString::literal(b"x".to_vec()))); + }) + .unwrap(); + set_props(&mut doc, "src.f", &FieldProps { name: Some("address.f".into()), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.f").value, ["inh"]); + doc.update_dict(address, |d| { + d.remove(b"V"); + }) + .unwrap(); + doc.update_dict(address, |d| { + d.remove(b"MaxLen"); + }) + .unwrap(); + + invalid_dotted_field_names_are_rejected(&mut doc, &text); +} + +fn invalid_dotted_field_names_are_rejected(doc: &mut Document, text: &NewField) { + for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + } + let deep = vec!["p"; 33].join("."); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); +} + +/// Review on #2957: a parent left empty by a move keeps its name from the next default name +/// (`Text1` after `Text1.x` moved away); and a full name that keeps the parents is a plain +/// rename, so the field stays in its place among its parent's /Kids and keeps inheriting. +#[test] +fn empty_parents_and_same_parent_renames() { + let mut doc = fixture(); + let text = NewField::Text { multiline: false }; + let auto = |doc: &mut Document, y: f64| add_field(doc, 0, [50.0, y, 200.0, y + 20.0], &text, None); + let mut copy = doc.clone(); + let first = auto(&mut copy, 100.0).unwrap(); + add_field(&mut doc, 0, [50.0, 140.0, 200.0, 160.0], &text, Some(&format!("{first}.x"))).unwrap(); + set_props(&mut doc, &format!("{first}.x"), &FieldProps { name: Some("other.x".into()), ..FieldProps::default() }).unwrap(); + assert!(fields(&doc).iter().all(|f| !f.name.starts_with(&first)), "{first} is an empty parent now"); + let next = auto(&mut doc, 100.0).expect("a default name the empty parent doesn't hold"); + assert_ne!(next, first); + + let all = fields(&doc); + let city = field(&all, "address.city").obj; + let address = doc.get(city).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + doc.update_dict(address, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec())))).unwrap(); + let kids = |doc: &Document| doc.get(address).as_dict().and_then(|d| d.get(b"Kids").and_then(|o| o.as_array().cloned())).unwrap(); + let before = kids(&doc); + assert_eq!( + set_props(&mut doc, "address.city", &FieldProps { name: Some("address.town".into()), ..FieldProps::default() }).unwrap(), + "address.town" + ); + assert_eq!(kids(&doc), before, "same place among the kids"); + assert!(!doc.get(city).as_dict().unwrap().contains(b"V"), "still inherits its value rather than owning a copy"); + assert_eq!(field(&fields(&doc), "address.town").value, ["Springfield"]); +} + +/// Review on #2957: spaces in a typed name. The ends of the whole name are dropped, as Acrobat +/// does when a field is renamed; a space beside a period stays in the part it belongs to (`a. b` +/// is `b` with a leading space under `a`), as Acrobat keeps it and as ISO 32000-2 §12.7.4.2 +/// allows: only a period is barred from a partial name. +#[test] +fn a_typed_name_loses_its_ends_but_keeps_spaces_beside_periods() { + let mut doc = fixture(); + let own_name = |doc: &Document, full: &str| { + let all = fields(doc); + doc.get(field(&all, full).obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) + }; + let text = NewField::Text { multiline: false }; + assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some(" plain ")).unwrap(), "plain"); + assert_eq!(add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("a. b")).unwrap(), "a. b"); + assert_eq!(add_field(&mut doc, 0, [50.0, 220.0, 200.0, 240.0], &text, Some("c .d")).unwrap(), "c .d"); + assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some(" e. f ".into()), ..FieldProps::default() }).unwrap(), "e. f"); + let saved = reopen(&doc); + assert_eq!(own_name(&saved, "plain").as_deref(), Some("plain")); + assert_eq!(own_name(&saved, "a. b").as_deref(), Some(" b")); + assert_eq!(own_name(&saved, "c .d").as_deref(), Some("d")); + assert_eq!(own_name(&saved, "e. f").as_deref(), Some(" f")); + let parent = |full: &str| { + let all = fields(&saved); + let p = saved.get(field(&all, full).obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + saved.get(p).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) + }; + assert_eq!(parent("c .d").as_deref(), Some("c "), "the space before the period is the parent's"); + assert_eq!(parent("e. f").as_deref(), Some("e")); + // A part that is only spaces is still empty. + assert!(add_field(&mut doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("g. .h")).is_err()); +} + +/// The form's `/DR /Font` entries, resolved through an indirect `/DR` or `/Font`. +fn dr_fonts(doc: &Document) -> Dict { + let af = acroform(doc).unwrap(); + let dr = doc.resolve(af.get(b"DR").unwrap()); + doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap() +} + +/// #3441: Acrobat shows nothing in a text field it redraws after typing when the field's `/DR` +/// font is a dictionary written inline. A form PdfCraft makes holds each font as its own object. +#[test] +fn a_new_form_holds_its_fonts_as_objects() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { + let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object: {fonts:?}")); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes())); + } +} + +/// #3441: a form saved earlier with inline `/DR` fonts gets them as objects once a field is +/// added, saying the same; an indirect `/Font` dictionary stays the object it was. +#[test] +fn inline_form_fonts_become_objects_and_an_indirect_font_dictionary_stays_one() { + for indirect in [false, true] { + let font = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> /Mine << /Type /Font /Subtype /Type1 /BaseFont /Symbol >>"; + let (fonts_entry, extra) = + if indirect { ("6 0 R".to_string(), vec![format!("<< {font} >>")]) } else { (format!("<< {font} >>"), Vec::new()) }; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font {fonts_entry} >> >>"), + "null".into(), + ]; + objs.extend(extra); + let mut doc = document(&objs); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for (name, base) in [("Helv", "Helvetica"), ("Mine", "Symbol"), ("TiRo", "Times-Roman")] { + let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object (indirect {indirect}): {fonts:?}")); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes()), "{name} says the same"); + } + let dr = doc.resolve(acroform(&doc).unwrap().get(b"DR").unwrap()); + assert_eq!(dr.as_dict().unwrap().reference(b"Font").is_some(), indirect, "the /Font dictionary keeps its form"); + } +} + +/// #3441: an indirect `/DR` stays the object it is, and a form whose three standard fonts are +/// all already there (inline) still gets them as objects, though none is added. +#[test] +fn an_indirect_resource_dictionary_and_a_complete_inline_set_are_handled() { + let inline = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + let all = format!( + "/Helv {inline} /TiRo << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> /Cour << /Type /Font /Subtype /Type1 /BaseFont /Courier >>" + ); + for indirect_dr in [false, true] { + let dr = if indirect_dr { "5 0 R".to_string() } else { format!("<< /Font << {all} >> >>") }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), + format!("<< /Font << {all} >> >>"), + ]); + let mut doc = doc; + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.reference(name.as_bytes()).is_some(), "{name} is an object (indirect /DR {indirect_dr}): {fonts:?}"); + } + assert_eq!(acroform(&doc).unwrap().reference(b"DR").is_some(), indirect_dr, "/DR keeps its form"); + } +} + +/// #3441: fonts the form already has are its own, not replaced by the standard ones: an inline +/// `/Helv` that is Arial becomes an object that is still Arial, a `/TiRo` object stays that +/// object, and a form needing no change leaves its `/DR` object unwritten. The fonts PdfCraft +/// adds use WinAnsi, as Acrobat's do. +#[test] +fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewritten() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Arial >> /TiRo 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + let font = |name: &str| doc.get(fonts.reference(name.as_bytes()).unwrap()).as_dict().cloned().unwrap(); + assert_eq!(font("Helv").name(b"BaseFont"), Some(&b"Arial"[..]), "the form's own Helv"); + assert_eq!(fonts.reference(b"TiRo"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "the same object"); + assert_eq!(font("Cour").name(b"Encoding"), Some(&b"WinAnsiEncoding"[..])); + + // Every font an object already, /DR an object: adding a field leaves /DR as it was. + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR 5 0 R >>".into(), + "<< /Font << /Helv 6 0 R /TiRo 6 0 R /Cour 6 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + assert!(!doc.modified_objects().contains(&5), "/DR unchanged: {:?}", doc.modified_objects()); +} + +/// #3441, exhaustively: every way a form's `/DR` may hold its fonts. `/DR` missing, inline or an +/// object; its `/Font` missing, inline, an object or not a dictionary; `/Helv` missing, inline, +/// an object, a reference to nothing, `null` or a number; and a font of the form's own, inline or +/// an object. After a field is added, whatever the shape: +/// - no font is a dictionary written inline; +/// - a font that was an object is the same object, and one that was inline says the same as an +/// object; +/// - a `/DR` or `/Font` that was an object (and a dictionary) still is one; +/// - an entry that is not a dictionary (`null`, a number, a reference to nothing) is left alone; +/// - Helv, TiRo and Cour are there. +#[test] +fn every_shape_of_form_fonts_ends_as_objects() { + let mut cases = 0; + for dr_shape in ["missing", "inline", "object"] { + for font_shape in ["missing", "inline", "object", "number"] { + for helv in ["missing", "inline", "object", "dangling", "null", "number"] { + for mine in ["missing", "inline", "object"] { + let case = FontShapeCase::new(dr_shape, font_shape, helv, mine); + if !case.is_valid() { + continue; + } + cases += 1; + assert_font_shape_case(&case); + } + } + } + } + assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); +} + +struct FontShapeCase<'a> { + dr: &'a str, + fonts: &'a str, + helv: &'a str, + mine: &'a str, + description: String, +} + +impl<'a> FontShapeCase<'a> { + fn new(dr: &'a str, fonts: &'a str, helv: &'a str, mine: &'a str) -> Self { + Self { dr, fonts, helv, mine, description: format!("/DR {dr}, /Font {fonts}, /Helv {helv}, /Mine {mine}") } + } + + fn is_valid(&self) -> bool { + let only_default_dr = self.dr == "missing" && self.fonts == "missing" && self.helv == "missing" && self.mine == "missing"; + let invalid_fonts = matches!(self.fonts, "missing" | "number") && (self.helv != "missing" || self.mine != "missing"); + !(self.dr == "missing" && !only_default_dr || invalid_fonts) + } +} + +fn assert_font_shape_case(case: &FontShapeCase<'_>) { + let mut doc = font_shape_document(case); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{}: {e}", case.description)); + let doc = reopen(&doc); + let acro = acroform(&doc).unwrap(); + let dr = doc.resolve(acro.get(b"DR").unwrap_or_else(|| panic!("{}: no /DR", case.description))); + let fonts = dr_fonts(&doc); + assert_font_entries_are_references(&fonts, &case.description); + assert_required_form_fonts(&fonts, &case.description); + assert_requested_font_shapes(&doc, &acro, &dr, &fonts, case); +} + +fn font_shape_document(case: &FontShapeCase<'_>) -> Document { + let mut entries = String::new(); + match case.helv { + "inline" => entries += &format!("/Helv {} ", form_font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match case.mine { + "inline" => entries += &format!("/Mine {} ", form_font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match case.fonts { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match case.dr { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let acroform = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + acroform, + if case.dr == "object" { dr_dict } else { "null".into() }, + if case.fonts == "object" { format!("<< {entries}>>") } else { "null".into() }, + form_font("Helvetica-Oblique"), + form_font("ZapfDingbats"), + ]) +} + +fn form_font(base: &str) -> String { + format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>") +} + +fn assert_font_entries_are_references(fonts: &Dict, description: &str) { + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{description}: {} is inline", String::from_utf8_lossy(name)); + } +} + +fn assert_required_form_fonts(fonts: &Dict, description: &str) { + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{description}: no {name}"); + } +} + +fn assert_requested_font_shapes(doc: &Document, acro: &Dict, dr: &Object, fonts: &Dict, case: &FontShapeCase<'_>) { + let base = |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match case.helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{}", case.description), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{}", case.description), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{}", case.description), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{}", case.description), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{}", case.description), + } + match case.mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{}", case.description), + _ => assert!(!fonts.contains(b"Mine"), "{}", case.description), + } + if case.dr == "object" { + assert_eq!(acro.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{}: /DR stays its object", case.description); + } + if case.fonts == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{}: /Font stays its object", case.description); + } +} + +/// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form +/// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A +/// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects +/// already leaves its `/DR` object unwritten. +#[test] +fn filling_a_field_makes_inline_form_fonts_objects_unless_the_form_is_signed() { + let inline = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + let form = |signed: bool, dr: &str| { + let sig = if signed { "<< /Type /Annot /Subtype /Widget /FT /Sig /T (sig) /V 7 0 R /Rect [0 0 0 0] /P 3 0 R >>" } else { "null" }; + let fields = if signed { "[5 0 R 6 0 R]" } else { "[5 0 R]" }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + format!("<< /Fields {fields} /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + sig.into(), + "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), + "<< /Font << /Helv 9 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]) + }; + let mut doc = form(false, &format!("<< /Font << {inline} >> >>")); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); + assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); + + let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "a signed form keeps /DR as it was"); + + // An empty signature field signs nothing: the form is converted as an unsigned one. + let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + d.remove(b"V"); + }) + .unwrap(); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + assert!(dr_fonts(&reopen(&doc)).reference(b"Helv").is_some(), "an unsigned signature field doesn't hold /DR back"); + + let mut doc = form(false, "8 0 R"); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + assert!(!doc.modified_objects().contains(&8), "/DR unchanged: {:?}", doc.modified_objects()); +} + +/// #3441 (review follow-up): a signature value held by a parent field signs its kid too (`/V` is +/// inheritable, ISO 32000-2 Table 226), so the form still counts as signed and keeps its `/DR`. +#[test] +fn a_signature_value_on_a_parent_field_keeps_the_form_fonts_as_signed() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + "<< /FT /Sig /T (sig) /V 7 0 R /Kids [8 0 R] >>".into(), + "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), + "<< /Type /Annot /Subtype /Widget /T (s1) /Parent 6 0 R /Rect [0 0 0 0] /P 3 0 R >>".into(), + ]); + let kid = pdfcraft_cos::ObjRef::new(8, 0); + assert!(fields(&doc).iter().any(|f| f.obj == kid && f.kind == FieldKind::Signature), "the kid is the signature field"); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "an inherited signature value keeps /DR as it was"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); +} + +/// #3441 (review follow-up): a catalog that holds `/AcroForm` as a direct dictionary gets its +/// inline `/DR` fonts converted too when a field is filled. +#[test] +fn filling_a_field_converts_inline_fonts_of_a_direct_form_dictionary() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + ]); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); + assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); +} + +/// #3926 / #3943 review: a password value can be inherited from an ancestor field or sit on a +/// separate widget. Clearing removes both; an ancestor's value shared with a non-password field +/// stays for that field, and the password field gets an empty value of its own instead. +#[test] +fn inherited_and_widget_password_values_are_cleared() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 9 0 R 11 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /T (acct) /FT /Tx /Ff 8192 /V (secret1) /Kids [6 0 R] >>".into(), // 5 flag and value inherited + "<< /Type /Annot /Subtype /Widget /T (pin) /Parent 5 0 R /Rect [50 700 150 720] /P 3 0 R >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /V (secret3) /Rect [50 650 150 670] /P 3 0 R >>".into(), // 7 value on the widget + "<< /FT /Tx /T (code) /Ff 8192 /Kids [7 0 R] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /Parent 10 0 R /Rect [50 600 150 620] /P 3 0 R >>".into(), // 9 + "<< /T (grp) /V (shared2) /Kids [9 0 R 11 0 R] >>".into(), // 10 shared with a plain field + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (plain) /Parent 10 0 R /Rect [50 550 150 570] /P 3 0 R >>".into(), // 11 + ]; + let mut doc = document(&objs); + assert!(has_password_values(&doc)); + let value = |doc: &Document, n: &str| fields(doc).into_iter().find(|f| f.name == n).map(|f| f.value).unwrap_or_default(); + assert_eq!(value(&doc, "acct.pin"), ["secret1"], "inherited before"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 3); + assert!(!has_password_values(&doc)); + for n in ["acct.pin", "code", "grp.pw"] { + assert!(value(&doc, n).iter().all(|v| v.is_empty()), "{n} cleared"); + } + assert_eq!(value(&doc, "grp.plain"), ["shared2"], "the shared value stays for the plain field"); + let has_v = |doc: &Document, n: u32| doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }).as_dict().is_some_and(|d| d.get(b"V").is_some()); + assert!(!has_v(&doc, 5), "an ancestor only password fields inherit from loses its value"); + assert!(!has_v(&doc, 7), "a widget's value is removed"); + assert!(has_v(&doc, 10), "a shared ancestor keeps its value"); +} + +/// #3943 review: a cleared password field saves as Acrobat saves it (black-box test). One with a +/// default value gets it back, and its appearance shows as many masking characters as the +/// default has, not as the typed password had; one without is left empty and draws none. The +/// default value and the scripts stay, and a field already at its default needs no clearing. +#[test] +fn a_cleared_password_field_returns_to_its_default_value() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), // 3 + "<< /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), // 4 + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 5 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /DV (DEF-1) /V (NEWPASS-77777) /AA << /K << /S /JavaScript /JS (1;) >> >> /Rect [50 700 250 720] /P 3 0 R >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw3) /Ff 8192 /V (PW3-TYPED-9) /Rect [50 650 250 670] /P 3 0 R >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (blank) /Ff 8192 /V [()] /Rect [50 600 250 620] /P 3 0 R >>".into(), // 8 holds nothing (an empty string in an array) + ]; + let mut doc = document(&objs); + assert_eq!(clear_password_values(&mut doc).unwrap(), 2); + let all = fields(&doc); + let (pw, pw3) = (field(&all, "pw"), field(&all, "pw3")); + assert_eq!(pw.value, ["DEF-1"], "back to its default"); + assert_eq!(pw.default, ["DEF-1"], "the default stays"); + assert!(pw.actions.scripts.keystroke.is_some(), "the scripts stay"); + assert!(pw3.value.iter().all(|v| v.is_empty()), "no default: empty"); + let shown = ap(&doc, &pw.widgets[0]); + assert!(shown.contains("(*****)") && !shown.contains("NEWPASS") && !shown.contains("******"), "{shown}"); + let shown3 = ap(&doc, &pw3.widgets[0]); + assert!(!shown3.contains('*') && !shown3.contains("PW3"), "{shown3}"); + assert!(!has_password_values(&doc), "a field at its default holds no typed password"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 0); +} + +/// #3943 review: the quick check that skips forms without a Password field still sees one whose +/// widget is only on the page, not listed in `/Fields`; one that inherits the flag from a parent +/// that is not listed either; and one deeper in the field tree whose widget is on no page. Each +/// is alone in its own document, so each path of the check is needed. +#[test] +fn a_password_field_missing_from_the_field_list_is_still_cleared() { + let form = |fields: &str, annots: &str, rest: &[&str]| { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"), + format!("<< /Fields [{fields}] /DA (/Helv 0 Tf 0 g) >>"), + ]; + objs.extend(rest.iter().map(|o| o.to_string())); + document(&objs) + }; + let page_only = + form("", "5 0 R", &["<< /Type /Annot /Subtype /Widget /FT /Tx /T (pin) /Ff 8192 /V (secret5) /Rect [50 700 150 720] /P 3 0 R >>"]); + let inherited = form( + "", + "5 0 R", + &[ + "<< /Type /Annot /Subtype /Widget /T (kid) /Parent 6 0 R /Rect [50 650 150 670] /P 3 0 R >>", + "<< /FT /Tx /T (acct) /Ff 8192 /V (secret6) /Kids [5 0 R] >>", + ], + ); + let off_page = form( + "5 0 R", + "", + &[ + "<< /T (grp) /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Ff 8192 /V (secret7) /Parent 5 0 R /Rect [0 0 0 0] >>", + ], + ); + for (what, mut doc) in [("page only", page_only), ("inherited", inherited), ("on no page", off_page)] { + assert!(has_password_values(&doc), "{what}"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1, "{what}"); + assert!(fields(&doc).iter().all(|f| f.value.iter().all(|v| v.is_empty())), "{what}: {:?}", fields(&doc)); + } +} + +/// A password field nested beyond depth 32 (e.g. depth 35) is recognized by `has_password_flag` +/// and `has_password_values`, matching the field tree walk limit of 64. +#[test] +fn a_password_field_nested_beyond_depth_32_is_recognized() { + let target_depth = 35; + let leaf_obj_num = 5 + target_depth; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + ]; + for d in 0..target_depth { + let obj_num = 5 + d; + let next_obj_num = obj_num + 1; + let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) }; + objs.push(format!("<< /T (node{d}) /Kids [{next_obj_num} 0 R]{parent_entry} >>")); + } + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /Ff 8192 /V (secret35) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>", + leaf_obj_num - 1 + )); + let mut doc = document(&objs); + assert!(has_password_flag(&doc)); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + +/// A password field whose widget is reachable through a page's Annots and whose inherited Password +/// flag is deeper than 32 levels is recognized by `has_password_flag`. +#[test] +fn a_page_widget_inheriting_password_flag_beyond_depth_32_is_recognized() { + let target_depth = 35; + let leaf_obj_num = 5 + target_depth; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{leaf_obj_num} 0 R] >>"), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + ]; + for d in 0..target_depth { + let obj_num = 5 + d; + let next_obj_num = obj_num + 1; + let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) }; + let ff_entry = if d == 0 { " /Ff 8192" } else { "" }; + objs.push(format!("<< /T (node{d}){ff_entry} /Kids [{next_obj_num} 0 R]{parent_entry} >>")); + } + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /V (secret_chain) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>", + leaf_obj_num - 1 + )); + let mut doc = document(&objs); + assert!(has_password_flag(&doc)); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + +/// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut +/// at the box). An auto-sized field shrinks to fit, and a short or empty value fits. +#[test] +fn a_value_wider_or_taller_than_its_box_is_reported() { + let long = "Doe, Jane (represented by John Doe)"; + let field = |name: &str, rect: &str, extra: &str, value: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") + }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + field("single", "50 700 190 718", "", long), + field("multi", "50 650 190 668", "/Ff 4096", long), + field("auto", "50 600 190 618", "/DA (/Helv 0 Tf 0 g)", long), + field("wide", "50 550 350 568", "", long), + field("short", "50 500 190 518", "/Ff 4096", "Ada"), + ]); + let overflow = |name: &str| { + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + appearance::value_overflow(&doc, &f, &f.widgets[0]) + }; + match overflow("single") { + Some(appearance::Overflow::Wide { width, room, size }) => { + assert_eq!(size, 12.0); + assert!((width - 201.4).abs() < 1.0 && room < 140.0, "{width} in {room}"); + } + other => panic!("single: {other:?}"), + } + assert_eq!(overflow("multi"), Some(appearance::Overflow::Tall { lines: 2, room: 1, size: 12.0 })); + assert_eq!(overflow("auto"), None, "auto size shrinks to fit"); + assert_eq!(overflow("wide"), None); + assert_eq!(overflow("short"), None); +} + +/// #4018: a single line taller than its box is cut at the top and bottom, and a character wider +/// than a multiline box is a line of its own that is still cut at the sides; both are reported. +/// An empty value draws nothing, so nothing is cut however small the box. +#[test] +fn a_line_taller_than_its_box_or_a_character_wider_than_it_is_reported() { + let field = |name: &str, rect: &str, extra: &str, value: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") + }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + field("low", "50 700 190 708", "", "Ada"), + field("comb", "50 650 190 658", "/Ff 16777216 /MaxLen 5", "Ada"), + field("narrow", "50 500 56 560", "/Ff 4096", "WW"), + field("roomy", "50 400 190 418", "", "Ada"), + field("empty", "50 350 190 358", "", ""), + field("empty-multi", "50 300 190 304", "/Ff 4096", ""), + ]); + let overflow = |name: &str| { + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + appearance::value_overflow(&doc, &f, &f.widgets[0]) + }; + for name in ["low", "comb"] { + match overflow(name) { + Some(o @ appearance::Overflow::High { height, room, size }) => { + assert_eq!(size, 12.0); + assert!((height - 11.1).abs() < 0.01 && room <= 8.0, "{name}: {height} in {room}"); + assert!(o.to_string().starts_with("the value does not fit: 11.1 pt high at 12 pt"), "{o}"); + } + other => panic!("{name}: {other:?}"), + } + } + match overflow("narrow") { + Some(appearance::Overflow::Wide { width, room, size }) => { + assert_eq!(size, 12.0); + assert!(width > room && (width - 11.32).abs() < 0.1, "{width} in {room}"); + } + other => panic!("narrow: {other:?}"), + } + assert_eq!(overflow("roomy"), None); + assert_eq!(overflow("empty"), None); + assert_eq!(overflow("empty-multi"), None); +} + +/// Check boxes, radio buttons and push buttons whose file gives them no appearance, a comb field, +/// a combo box and a list box, for the display copy. +fn left_buttons() -> Document { + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>"); + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + widget("i", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [50 700 74 724]"), // 6 + widget("j", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 0 1 rg) /MK << /CA (l) /BC [0 0 0] >> /Rect [100 700 124 724]"), + widget("k", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [150 700 174 724]"), + widget("l", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 9 Tf 0 g) /MK << /CA (H) /BC [0 0 0] /BG [1 1 0] >> /Rect [200 700 224 724]"), + widget("off", "/FT /Btn /V /Off /AS /Off /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [250 700 274 724]"), // 10 + widget("radio", "/FT /Btn /Ff 32768 /V /A /AS /A /DA (/ZaDb 0 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [300 700 324 724]"), + widget( + "o", + "/FT /Btn /Ff 65536 /DA (/Helv 12 Tf 0 g) /MK << /CA (Press me) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [50 600 170 624]", + ), + widget("own", "/FT /Btn /V /Yes /AS /Yes /MK << /CA (4) /BC [0 0 0] >> /Rect [350 700 374 724] /AP << /N << /Yes 19 0 R /Off 19 0 R >> >>"), + widget("half", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (8) >> /Rect [400 700 424 724] /AP << /N << /Off 19 0 R >> >>"), + widget("bad", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724]"), // 15 + widget("d", "/FT /Tx /Ff 16777216 /MaxLen 6 /V (ABC123) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [50 500 170 524]"), + widget("m", "/FT /Ch /Ff 131072 /Opt [(Apple) (Banana)] /V (Banana) /Q 2 /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [50 400 170 424]"), + widget("n", "/FT /Ch /Opt [(One) (Two)] /V (Two) /DA (/Helv 11 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [200 400 320 460]"), + "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), // 19 + ]) +} + +/// The appearance stream of widget `w` in `state` (a check box's `/AP /N /`). +fn state_ap(doc: &Document, w: &Widget, state: &str) -> String { + let wd = doc.get(w.obj).as_dict().cloned().unwrap(); + let ap = doc.resolve(wd.get(b"AP").unwrap()).as_dict().cloned().unwrap(); + let n = doc.resolve(ap.get(b"N").unwrap()).as_dict().cloned().unwrap(); + let r = n.reference(state.as_bytes()).expect("the state's appearance"); + let Object::Stream(s) = &*doc.get(r) else { panic!("a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() +} + +/// The decoded appearance PdfCraft would save for text or choice field `name` of `doc`. +fn saved_look(doc: &Document, name: &str) -> String { + let all = fields(doc); + let f = field(&all, name); + String::from_utf8_lossy(&appearance::field_appearance(doc, f, &f.widgets[0], &f.value).decoded().unwrap()).into_owned() +} + +/// ISO 32000-2 Table 192 and 12.7.5.2.3: a check box with no appearance shows its `/MK /BC` +/// border and, when on, its caption `/MK /CA` as a ZapfDingbats character in the `/DA` colour, +/// sized to the box when the `/DA` size is 0. +#[test] +fn check_boxes_without_an_appearance_show_their_caption_symbol() { + let doc = left_buttons(); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + for (name, symbol, colour) in [("i", "(4) Tj", "0 g"), ("j", "(l) Tj", "0 0 1 rg"), ("k", "(8) Tj", "1 0 0 rg"), ("l", "(H) Tj", "0 g")] { + let shown = state_ap(©, &field(&all, name).widgets[0], "Yes"); + assert!(shown.contains(symbol), "{name}: {shown}"); + assert!(shown.contains(&format!("{colour}\n")), "{name} in its colour: {shown}"); + assert!(shown.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "{name} has its border: {shown}"); + } + // A /DA size is kept; the automatic one fills the box inside the border. + let l = state_ap(©, &field(&all, "l").widgets[0], "Yes"); + assert!(l.contains("/ZaDb 9 Tf") && l.contains("1 1 0 rg\n0 0 24 24 re f"), "{l}"); + let i = state_ap(©, &field(&all, "i").widgets[0], "Yes"); + let size: f64 = i.split("/ZaDb ").nth(1).and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()).unwrap(); + assert!((18.0..=26.0).contains(&size), "{size}"); + let w = copy.get(field(&all, "i").widgets[0].obj); + let ap = copy.resolve(w.as_dict().unwrap().get(b"AP").unwrap()); + let n = copy.resolve(ap.as_dict().unwrap().get(b"N").unwrap()); + let Object::Stream(s) = &*copy.get(n.as_dict().unwrap().reference(b"Yes").unwrap()) else { panic!() }; + let font = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).and_then(|f| f.get(b"ZaDb")); + assert_eq!(font.and_then(|f| f.as_dict()).and_then(|f| f.name(b"BaseFont")), Some(&b"ZapfDingbats"[..]), "a standard-14 font, no program"); +} + +/// An Off check box shows only its border, a radio button without a caption a dot (`l`), a box +/// whose file has only an Off look gets an on look, and one with its own look keeps it. +#[test] +fn button_states_without_an_appearance() { + let doc = left_buttons(); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + let off = state_ap(©, &field(&all, "off").widgets[0], "Off"); + assert!(off.contains("re S") && !off.contains("Tj"), "{off}"); + assert!(state_ap(©, &field(&all, "radio").widgets[0], "A").contains("(l) Tj")); + assert!(state_ap(©, &field(&all, "half").widgets[0], "Yes").contains("(8) Tj")); + let own = |d: &Document| d.get(field(&fields(d), "own").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(own(©), own(&doc), "a check box with its own look keeps it"); + // Nonsense in /MK, /DA and /BS: the default symbol, no border, and no panic. + let bad = state_ap(©, &field(&all, "bad").widgets[0], "Yes"); + assert!(bad.contains("(4) Tj") && !bad.contains("re S"), "{bad}"); + // The document itself is untouched. + assert!(doc.get(field(&fields(&doc), "i").widgets[0].obj).as_dict().is_some_and(|w| !w.contains(b"AP"))); +} + +/// Table 192 and Table 168: a push button with no appearance shows its `/MK /BG` background, its +/// beveled `/MK /BC` border in the `/BS` width, and its caption `/MK /CA` centred. +#[test] +fn push_buttons_without_an_appearance_show_their_caption_and_frame() { + let copy = with_field_appearances(&left_buttons()).expect("buttons to draw"); + let shown = ap(©, &field(&fields(©), "o").widgets[0]); + for part in ["0.8 0.8 0.8 rg\n0 0 120 24 re f", "0 0 0 RG\n2 w\n1 1 118 22 re S", "1 g\n", "0.5 g\n", "(Press me) Tj", "/Helv 12.00 Tf"] { + assert!(shown.contains(part), "{part}: {shown}"); + } +} + +/// ISO 32000-2 Table 228, Comb: the field is divided into MaxLen equally spaced positions, and a +/// bordered comb field shows them: MaxLen - 1 lines in the border colour, in its saved look too. +#[test] +fn comb_fields_show_their_cells() { + let mut doc = left_buttons(); + let shown = saved_look(&doc, "d"); + let lines: Vec<&str> = shown.lines().filter(|l| l.ends_with(" l") && l.contains(" 1 m ")).collect(); + assert_eq!(lines, ["20 1 m 20 23 l", "40 1 m 40 23 l", "60 1 m 60 23 l", "80 1 m 80 23 l", "100 1 m 100 23 l"], "{shown}"); + assert!(shown.contains("0 0 1 RG\n1 w\n20 1 m"), "in the border colour: {shown}"); + // Past the cap no lines, and a huge MaxLen is no trouble; without a border, none either. + let r = field(&fields(&doc), "d").widgets[0].obj; + doc.update_dict(r, |w| w.set(b"MaxLen".to_vec(), Object::Int(1 << 40))).unwrap(); + let huge = saved_look(&doc, "d"); + assert!(!huge.contains(" 1 m ") && huge.contains("(A) Tj"), "{huge}"); + doc.update_dict(r, |w| { + w.set(b"MaxLen".to_vec(), Object::Int(6)); + w.remove(b"MK"); + }) + .unwrap(); + let plain = saved_look(&doc, "d"); + assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); +} + +/// Check boxes and radio buttons with an `/MK` look and appearances of their own, in a form that +/// sets `/NeedAppearances` (`need`) or not. +fn mk_buttons(need: bool) -> Document { + let flag = if need { "/NeedAppearances true " } else { "" }; + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({n}) /F 4 /P 3 0 R {rest} >>"); + let states = |on: &str| format!("/AP << /N << /{on} 15 0 R /Off 16 0 R >> >>"); + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(), + format!( + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>" + ), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + widget("check", &format!("/V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [50 700 74 724] {}", states("Yes"))), // 6 + widget("cross", &format!("/V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) >> /Rect [100 700 124 724] {}", states("Yes"))), + widget("diamond", &format!("/V /Yes /AS /Yes /MK << /CA (u) /BG [1 1 0] >> /Rect [150 700 174 724] {}", states("Yes"))), + widget("square", &format!("/V /Yes /AS /Yes /MK << /CA (n) >> /Rect [200 700 224 724] {}", states("Yes"))), + widget("star", &format!("/V /Yes /AS /Yes /MK << /CA (H) >> /Rect [250 700 274 724] {}", states("Yes"))), // 10 + widget("off", &format!("/V /Off /AS /Off /MK << /BC [0 0 0] /CA (4) >> /Rect [300 700 324 724] {}", states("Yes"))), + widget("radio", &format!("/Ff 32768 /V /B /MK << /BC [0 0 1] /CA (l) >> /Rect [350 700 374 724] {}", states("B"))), + widget("plain", &format!("/V /Yes /AS /Yes /Rect [400 700 424 724] {}", states("Yes"))), + widget( + "odd", + &format!( + "/V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724] {}", + states("Yes") + ), + ), + "<< /Length 24 >>\nstream\n0 g 4 4 16 16 re f\nendstream".into(), // 15: a filled square + "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), + ]) +} + +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows each check box and radio button with an `/MK` drawn from it, +/// over the appearance the file carries: the caption's ZapfDingbats symbol in the `/DA` colour +/// when on, only the border when off. A widget with no `/MK` keeps its own; the file is untouched. +#[test] +fn need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions() { + let doc = mk_buttons(true); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + for (name, state, symbol, colour) in [ + ("check", "Yes", "(4) Tj", "0 g"), + ("cross", "Yes", "(8) Tj", "1 0 0 rg"), + ("diamond", "Yes", "(u) Tj", "0 g"), + ("square", "Yes", "(n) Tj", "0 g"), + ("star", "Yes", "(H) Tj", "0 g"), + ("radio", "B", "(l) Tj", "0 g"), + ] { + let shown = state_ap(©, &field(&all, name).widgets[0], state); + assert!(shown.contains(symbol) && shown.contains(&format!("{colour}\n")), "{name}: {shown}"); + assert!(!shown.contains("4 4 16 16 re f"), "{name} is not the file's filled square: {shown}"); + } + let check = state_ap(©, &field(&all, "check").widgets[0], "Yes"); + assert!(check.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "the /BC border: {check}"); + assert!(state_ap(©, &field(&all, "diamond").widgets[0], "Yes").contains("1 1 0 rg\n0 0 24 24 re f"), "the /BG background"); + assert!(state_ap(©, &field(&all, "radio").widgets[0], "B").contains("0 0 1 RG"), "the radio's own border"); + let off = state_ap(©, &field(&all, "off").widgets[0], "Off"); + assert!(off.contains("re S") && !off.contains("Tj"), "off: only the border: {off}"); + // Nonsense in /MK, /DA and /BS: the default check, no border, and no panic. + let odd = state_ap(©, &field(&all, "odd").widgets[0], "Yes"); + assert!(odd.contains("(4) Tj") && !odd.contains("re S"), "{odd}"); + let own = |d: &Document, name: &str| d.get(field(&fields(d), name).widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(own(©, "plain"), own(&doc, "plain"), "a box without /MK keeps its own look"); + assert_eq!(state_ap(&doc, &field(&fields(&doc), "check").widgets[0], "Yes").trim_end(), "0 g 4 4 16 16 re f", "the file is untouched"); +} + +/// Without `/NeedAppearances` the file's own check box and radio button appearances are shown +/// as they are, `/MK` or not: no copy is needed. +#[test] +fn without_need_appearances_buttons_keep_their_own_appearances() { + assert!(with_field_appearances(&mk_buttons(false)).is_none()); +} + +/// A form for filling many values at once: two summed fields and their calculated total, a +/// name two field dictionaries share, a check box, a radio group, a list and a read-only field. +fn batch_form() -> Document { + let tx = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 12 0 R 13 0 R 14 0 R 17 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 14 0 R 17 0 R] /CO [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 15 0 R >> >> >>" + .into(), + tx("a", 700, "/V (1)"), + tx("b", 660, ""), + tx("total", 620, r#"/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array\("a", "b"\)\);) >> >>"#), + tx("dup", 580, ""), + tx("dup", 540, ""), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R >>".into(), + "<< /FT /Btn /T (size) /Ff 49152 /Kids [12 0 R 13 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [300 650 315 665] /P 3 0 R /AP << /N << /S 16 0 R /Off 16 0 R >> >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [330 650 345 665] /P 3 0 R /AP << /N << /L 16 0 R /Off 16 0 R >> >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (country) /Ff 131072 /Opt [[(ca) (Canada)] [(fr) (France)]] /Rect [300 600 450 620] /P 3 0 R >>" + .into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + tx("locked", 500, "/Ff 1 /V (kept)"), + ]; + document(&objs) +} + +/// What a filled form shows: every field as read, and each widget's drawn appearance. +fn filled(doc: &Document) -> (Vec, Vec) { + let all = fields(doc); + let looks = all + .iter() + .flat_map(|f| &f.widgets) + .map(|w| { + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let n = wd.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())); + match n.map(|n| doc.resolve(&n)) { + Some(n) => match &*n { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned(), + other => format!("{other:?}").chars().filter(|c| !c.is_ascii_digit()).collect(), + }, + None => String::new(), + } + }) + .collect(); + (all, looks) +} + +/// Filling values in one batch leaves the form as filling them one by one does: the values, +/// the twin of a shared name, the calculated total and every widget's appearance; a name given +/// twice ends with its last value, and is checked against the value it took earlier in the batch; +/// and each value fails as it would on its own. +#[test] +fn set_values_fills_as_set_value_does_one_by_one() { + let values: Vec<(String, FieldValue)> = vec![ + ("a".into(), FieldValue::Text("2".into())), + ("b".into(), FieldValue::Text("3".into())), + ("dup".into(), FieldValue::Text("shared".into())), + ("agree".into(), FieldValue::Check(true)), + ("size".into(), FieldValue::Radio(Some("L".into()))), + ("country".into(), FieldValue::Choice(vec!["fr".into()])), + ("nowhere".into(), FieldValue::Text("x".into())), + ("locked".into(), FieldValue::Text("changed".into())), + ("size".into(), FieldValue::Radio(Some("XL".into()))), + ("dup".into(), FieldValue::Text("again".into())), + ("a".into(), FieldValue::Text("10".into())), + // Refused only because the batch already selected L: the group keeps one selected. + ("size".into(), FieldValue::Radio(None)), + ]; + let mut one_by_one = batch_form(); + let expected: Vec> = values.iter().map(|(n, v)| set_value(&mut one_by_one, n, v)).collect(); + let mut batch = batch_form(); + let results = set_values(&mut batch, &values).expect("recalculates"); + assert_eq!(results, expected); + assert_eq!( + results.iter().filter(|r| r.is_err()).count(), + 4, + "the unknown name, the read-only field, the unknown option and clearing the group fail: {results:?}" + ); + let (all, looks) = filled(&batch); + assert_eq!((all.clone(), looks), filled(&one_by_one)); + assert_eq!(field(&all, "total").value, ["13"], "recalculated once, after the last value"); + assert!(all.iter().filter(|f| f.name == "dup").all(|f| f.value == ["again"]), "both dup fields take the last value"); + // And as saved. + assert_eq!(filled(&reopen(&batch)), filled(&reopen(&one_by_one))); +} + +/// A form without fields refuses each value, and an empty batch changes nothing. +#[test] +fn set_values_without_a_form_or_values() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + let r = set_values(&mut doc, &[("a".into(), FieldValue::Text("1".into()))]).unwrap(); + assert_eq!(r, vec![Err(FormError::NoForm)]); + let mut form = batch_form(); + assert_eq!(set_values(&mut form, &[]).unwrap(), Vec::new()); + assert!(!form.is_modified()); + // Nothing filled, nothing recalculated: the total stays as the file has it. + let r = set_values(&mut form, &[("nowhere".into(), FieldValue::Text("1".into()))]).unwrap(); + assert_eq!(r, vec![Err(FormError::NoSuchField("nowhere".into()))]); + assert!(field(&fields(&form), "total").value.is_empty() && !form.is_modified()); +} + +/// Filling many fields reads the form a fixed number of times, not once or twice per field +/// (which took minutes to open a form with thousands of fields whose XFA data was filled in +/// elsewhere). +#[test] +fn set_values_reads_the_form_once_not_per_value() { + let n: u32 = 60; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 5)).collect(); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" "))); + objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" "))); + for i in 0..n { + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [50 {y} 250 {top}] /P 3 0 R >>", y = 10 * i, top = 10 * i + 10)); + } + let mut doc = document(&objs); + let values: Vec<(String, FieldValue)> = (0..n).map(|i| (format!("f{i}"), FieldValue::Text(format!("v{i}")))).collect(); + let before = form_reads(); + let results = set_values(&mut doc, &values).unwrap(); + let reads = form_reads() - before; + assert!(results.iter().all(Result::is_ok)); + // One reading to fill, one to recalculate. + assert!(reads <= 2, "{reads} readings of the form for {n} values"); + assert!(fields(&doc).iter().all(|f| f.value == [format!("v{}", &f.name[1..])])); + // A name filled again is read again, once: f0 and f1 again cost one more reading. + let again: Vec<(String, FieldValue)> = ["f0", "f1", "f0", "f1"].iter().map(|n| (n.to_string(), FieldValue::Text("w".into()))).collect(); + let before = form_reads(); + assert!(set_values(&mut doc, &again).unwrap().iter().all(Result::is_ok)); + let reads = form_reads() - before; + assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); +} + +/// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and +/// the group's buttons in the calculation order; and a text field `name` with two widgets. +fn radio_group_form() -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 10 0 R 11 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /CO [7 0 R] >>".into(), // 4 + "<< /FT /Btn /Ff 49152 /T (size) /V /S /Opt [(small) (medium) (large)] /Kids [6 0 R 7 0 R 8 0 R] >>".into(), // 5 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 64 714] /P 3 0 R /AS /S /AP << /N << /S 12 0 R /Off 12 0 R >> >> >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [80 700 94 714] /P 3 0 R /AS /Off /AP << /N << /M 12 0 R /Off 12 0 R >> >> >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [110 700 124 714] /P 3 0 R /AS /Off /AP << /N << /L 12 0 R /Off 12 0 R >> >> >>".into(), // 8 + "<< /FT /Tx /T (name) /V (Ann) /Kids [10 0 R 11 0 R] >>".into(), // 9 + "<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [50 600 250 620] /P 3 0 R >>".into(), // 10 + "<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [300 600 500 620] /P 3 0 R >>".into(), // 11 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 12 + ]; + document(&objs) +} + +fn annots(doc: &Document) -> Vec { + let p = page_refs(doc)[0]; + let a = doc.resolve(doc.get(p).as_dict().unwrap().get(b"Annots").unwrap()); + a.as_array().unwrap().iter().map(|o| o.as_ref().unwrap().num).collect() +} + +#[test] +fn deleting_one_radio_button_keeps_the_group_and_its_other_buttons() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "size", 1).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [6, 8], "only Medium is gone"); + assert_eq!(size.value, ["S"], "the group keeps its choice"); + // The per-widget /Opt loses Medium's entry, so the others keep their export values. + assert_eq!((size.export_of(0), size.export_of(1)), (Some("small"), Some("large"))); + assert_eq!(annots(&doc), [6, 8, 10, 11], "the button left the page"); + let co = doc.resolve(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().get(b"CO").unwrap()).as_array().cloned().unwrap(); + assert!(co.is_empty(), "and the calculation order"); + assert!(field(&all, "name").widgets.len() == 2, "other fields are untouched"); +} + +#[test] +fn deleting_the_chosen_radio_button_turns_the_group_off() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "size", 0).unwrap(); + let all = fields(&reopen(&doc)); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [7, 8]); + assert!(size.value.is_empty(), "no button is on any more: {:?}", size.value); + assert_eq!((size.export_of(0), size.export_of(1)), (Some("medium"), Some("large"))); +} + +#[test] +fn deleting_the_chosen_radio_button_turns_the_group_off_when_a_twin_is_not_shown_on() { + // Medium shares Small's on-state name but shows Off: once Small is gone, no button shows + // the group's /V, so the group must not keep reporting it. + let mut doc = radio_group_form(); + let twin = ObjRef::new(7, 0); + let blank = Object::Ref(ObjRef::new(12, 0)); + let mut n = Dict::new(); + n.set(b"S".to_vec(), blank.clone()); + n.set(b"Off".to_vec(), blank); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut w = doc.get(twin).as_dict().cloned().unwrap(); + w.set(b"AP".to_vec(), Object::Dict(ap)); + doc.set(twin, Object::Dict(w)); + delete_widget(&mut doc, "size", 0).unwrap(); + let all = fields(&reopen(&doc)); + let size = field(&all, "size"); + assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::>(), [7, 8]); + assert!(size.value.is_empty(), "no button shows S any more: {:?}", size.value); +} + +#[test] +fn deleting_the_default_radio_button_clears_the_default_too() { + // The group is off, but Reset would put back Small, which no button has once it is gone. + let mut doc = radio_group_form(); + let size = ObjRef::new(5, 0); + let mut d = doc.get(size).as_dict().cloned().unwrap(); + d.set(b"V".to_vec(), Object::name("Off")); + d.set(b"DV".to_vec(), Object::name("S")); + doc.set(size, Object::Dict(d)); + delete_widget(&mut doc, "size", 0).unwrap(); + let doc = reopen(&doc); + let dv = doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned()); + assert_eq!(dv, Some(Object::name("Off"))); + // A default another button still has stays. + let mut doc = radio_group_form(); + let mut d = doc.get(size).as_dict().cloned().unwrap(); + d.set(b"DV".to_vec(), Object::name("S")); + doc.set(size, Object::Dict(d)); + delete_widget(&mut doc, "size", 1).unwrap(); + assert_eq!(doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned()), Some(Object::name("S"))); +} + +#[test] +fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() { + let mut doc = radio_group_form(); + delete_widget(&mut doc, "name", 0).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let name = field(&all, "name"); + assert_eq!((name.widgets.iter().map(|w| w.obj.num).collect::>(), name.value.clone()), (vec![11], vec!["Ann".to_string()])); + assert_eq!(annots(&doc), [6, 7, 8, 11]); + // The last widget takes the field with it, as deleting the field does. + let mut doc = doc; + delete_widget(&mut doc, "name", 0).unwrap(); + let doc = reopen(&doc); + assert!(!fields(&doc).iter().any(|f| f.name == "name")); + assert_eq!(annots(&doc), [6, 7, 8]); +} + +#[test] +fn deleting_a_radio_button_without_its_parent_link_takes_it_out_of_the_group() { + // A damaged file: the group lists Medium in its /Kids, but Medium has no /Parent. + for parent in [None, Some(Object::Ref(ObjRef::new(9, 0)))] { + let mut doc = radio_group_form(); + let medium = ObjRef::new(7, 0); + let mut w = doc.get(medium).as_dict().cloned().unwrap(); + w.remove(b"Parent"); + if let Some(p) = parent.clone() { + w.set(b"Parent".to_vec(), p); + } + doc.set(medium, Object::Dict(w)); + delete_widget(&mut doc, "size", 1).unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(field(&all, "size").widgets.iter().map(|w| w.obj.num).collect::>(), [6, 8], "/Parent {parent:?}"); + let kids = doc.resolve(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().get(b"Kids").unwrap()).as_array().cloned().unwrap(); + assert!(!kids.iter().any(|k| k.as_ref() == Some(medium)), "/Parent {parent:?}: {kids:?}"); + assert_eq!(field(&all, "name").widgets.len(), 2, "/Parent {parent:?}: the other field is untouched"); + assert_eq!(annots(&doc), [6, 8, 10, 11]); + } +} + +#[test] +fn deleting_a_widget_that_is_not_there_changes_nothing() { + let mut doc = radio_group_form(); + assert_eq!(delete_widget(&mut doc, "size", 3), Err(FormError::Invalid("size has no widget 4".into()))); + assert_eq!(delete_widget(&mut doc, "nope", 0), Err(FormError::NoSuchField("nope".into()))); + assert_eq!(delete_widget(&mut doc, "size", usize::MAX), Err(FormError::Invalid(format!("size has no widget {}", usize::MAX)))); + assert_eq!(field(&fields(&doc), "size").widgets.len(), 3); +} +/// Two new radio groups on one page, "Group1" (export `a`) and "Group2" (export `b`). +fn two_radio_groups(a: &str, b: &str) -> Document { + let mut doc = one_page(); + let radio = |export: &str| NewField::Radio { group: None, export: export.into() }; + assert_eq!(add_field(&mut doc, 0, [50.0, 600.0, 64.0, 614.0], &radio(a), None).unwrap(), "Group1"); + assert_eq!(add_field(&mut doc, 0, [80.0, 600.0, 94.0, 614.0], &radio(b), None).unwrap(), "Group2"); + doc +} + +fn rename_to(doc: &mut Document, from: &str, to: &str) -> Result { + set_props(doc, from, &FieldProps { name: Some(to.into()), ..FieldProps::default() }) +} + +fn page_widgets(doc: &Document) -> Vec { + let page = page_refs(doc)[0]; + let annots = doc.get(page).as_dict().unwrap().get(b"Annots").cloned().unwrap(); + doc.resolve(&annots).as_array().unwrap().iter().filter_map(|o| o.as_ref()).collect() +} + +/// #4949: a radio button given its group's name is one of the group's buttons, so the group can +/// be built up by naming buttons alike, as in Acrobat. +#[test] +fn a_radio_button_renamed_to_a_groups_name_joins_the_group() { + let mut doc = two_radio_groups("Yes", "No"); + set_value(&mut doc, "Group1", &FieldValue::Radio(Some("Yes".into()))).unwrap(); + let before = page_widgets(&doc); + assert_eq!(rename_to(&mut doc, "Group2", "Group1").unwrap(), "Group1"); + assert_eq!(page_widgets(&doc), before, "the buttons stay on the page where they were"); + let mut doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(all.iter().map(|f| f.name.as_str()).collect::>(), ["Group1"]); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Yes", "No"]); + assert_eq!(group.value, ["Yes"], "the group keeps its value"); + assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::>(), [Some("Yes".into()), Some("Off".into())]); + assert_eq!(page_widgets(&doc), group.widgets.iter().map(|w| w.obj).collect::>()); + // Choosing the moved button turns the other off: they are one group. + set_value(&mut doc, "Group1", &FieldValue::Radio(Some("No".into()))).unwrap(); + let group = field(&fields(&doc), "Group1").clone(); + assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::>(), [Some("Off".into()), Some("No".into())]); +} + +/// Joining redraws only the buttons that moved: the group's own keep their appearances, which may +/// be custom ones a redraw would replace. +#[test] +fn joining_a_group_leaves_its_own_buttons_appearances_as_they_are() { + let mut doc = two_radio_groups("Yes", "No"); + let own = field(&fields(&doc), "Group1").widgets[0].obj; + let look = |doc: &Document| doc.get(own).as_dict().and_then(|d| d.get(b"AP").cloned()); + let before = look(&doc); + assert!(before.is_some()); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + assert_eq!(look(&doc), before); + let group = field(&fields(&doc), "Group1").clone(); + assert!(group.widgets[1].on_state.as_deref() == Some("No"), "the moved button is drawn with its states"); +} + +/// A joining button whose export value a button of the group already has takes the next free +/// "ChoiceN", in its appearance states too, so the two can be chosen apart. +#[test] +fn a_joining_radio_button_with_a_taken_export_value_takes_a_free_one() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Choice1", "Choice2"]); + let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone(); + let normal = doc.resolve(moved.get(b"AP").unwrap()).as_dict().unwrap().get(b"N").cloned().unwrap(); + let normal = doc.resolve(&normal).as_dict().unwrap().clone(); + assert!(normal.contains(b"Choice2") && !normal.contains(b"Choice1")); +} + +/// A group whose `/Kids` is an indirect array keeps its own buttons when another joins it. +#[test] +fn a_group_with_indirect_kids_keeps_its_buttons_when_joined() { + let mut doc = two_radio_groups("Yes", "No"); + let group = field(&fields(&doc), "Group1").obj; + let kids = doc.get(group).as_dict().unwrap().get(b"Kids").cloned().unwrap(); + let held = doc.add(kids); + doc.update_dict(group, |d| d.set(b"Kids".to_vec(), Object::Ref(held))).unwrap(); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(field(&all, "Group1").widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Yes", "No"]); +} + +/// Buttons of the joining group that share an export value (a button repeated on another page, +/// on together) still share it after the join, under one fresh name. +#[test] +fn joining_radio_buttons_that_share_a_value_keep_sharing_it() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut p = pdfcraft_cos::Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").unwrap().as_array().unwrap().clone(); + kids.push(Object::Ref(r)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + assert_eq!(duplicate_field(&mut doc, "Group2", &[1]).unwrap(), 1); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Choice1", "Choice2", "Choice2"]); +} + +/// One page holding radio fields that are each their own only widget, as other writers make +/// one-button groups: `(name, export value, x)`. +fn merged_radios(specs: &[(&str, &str, f64)]) -> Document { + let mut doc = one_page(); + let page = page_refs(&doc)[0]; + let af = crate::author::ensure_form(&mut doc).unwrap(); + let mut made = Vec::new(); + for &(name, state, x) in specs { + let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec()))); + let mut n = pdfcraft_cos::Dict::new(); + n.set(state.as_bytes().to_vec(), Object::Ref(look)); + n.set(b"Off".to_vec(), Object::Ref(look)); + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"FT".to_vec(), Object::name("Btn")); + d.set(b"Ff".to_vec(), Object::Int(i64::from(flags::RADIO))); + d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text(name)); + d.set(b"Rect".to_vec(), Object::Array(vec![x.into(), 600.into(), (x + 14.0).into(), 614.into()])); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + d.set(b"AS".to_vec(), Object::name("Off")); + let r = doc.add(Object::Dict(d)); + doc.update_dict(af, |a| { + let mut list = a.get(b"Fields").and_then(|l| l.as_array().cloned()).unwrap_or_default(); + list.push(Object::Ref(r)); + a.set(b"Fields".to_vec(), Object::Array(list)); + }) + .unwrap(); + made.push(r); + } + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(made.iter().map(|r| Object::Ref(*r)).collect()))).unwrap(); + doc +} + +/// A radio field that is its own (only) widget, as other writers make one-button groups, can +/// both join a group and be joined. +#[test] +fn radio_fields_that_are_their_own_widget_join_and_are_joined() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + assert_eq!(rename_to(&mut doc, "other", "solo").unwrap(), "solo"); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(all.iter().map(|f| f.name.as_str()).collect::>(), ["solo"]); + let group = field(&all, "solo"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["A", "B"]); + // The joined field's widget became a kid; the moved one shed its field entries. + let kids: Vec = group.widgets.iter().map(|w| w.obj).collect(); + assert!(!kids.contains(&group.obj)); + let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone(); + assert!(!moved.contains(b"T") && !moved.contains(b"FT") && moved.reference(b"Parent") == Some(group.obj)); + let mut on_page = page_widgets(&doc); + on_page.sort(); + let mut kids = kids; + kids.sort(); + assert_eq!(on_page, kids, "the page lists the widgets, not the field"); +} + +/// A group that is its own only widget keeps its button's actions when another button joins +/// it: the new widget takes `/A` and the widget triggers of `/AA`, and the field keeps its own. +#[test] +fn a_joined_merged_groups_button_keeps_its_actions() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let solo = field(&fields(&doc), "solo").obj; + let js = |code: &str| { + let mut a = pdfcraft_cos::Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code)); + Object::Dict(a) + }; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"Fo".to_vec(), js("focus();")); + aa.set(b"V".to_vec(), js("validate();")); + let aa = doc.add(Object::Dict(aa)); + doc.update_dict(solo, |d| { + d.set(b"A".to_vec(), js("up();")); + d.set(b"AA".to_vec(), Object::Ref(aa)); + }) + .unwrap(); + rename_to(&mut doc, "other", "solo").unwrap(); + let doc = reopen(&doc); + assert_eq!( + field_actions(&doc, "solo").unwrap(), + [(Trigger::MouseUp, FieldAction::JavaScript("up();".into())), (Trigger::OnFocus, FieldAction::JavaScript("focus();".into()))] + ); + let group = field(&fields(&doc), "solo").clone(); + let fd = doc.get(group.obj).as_dict().unwrap().clone(); + let field_aa = doc.resolve(fd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(!fd.contains(b"A") && field_aa.contains(b"V") && !field_aa.contains(b"Fo"), "the field keeps only its own trigger"); + let wd = doc.get(group.widgets[0].obj).as_dict().unwrap().clone(); + let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(widget_aa.contains(b"Fo") && !widget_aa.contains(b"V")); +} + +/// A joining button that was its own field keeps its widget triggers and drops the field's. +#[test] +fn a_joining_merged_button_sheds_its_fields_triggers() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let other = field(&fields(&doc), "other").obj; + let js = |code: &str| { + let mut a = pdfcraft_cos::Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code)); + Object::Dict(a) + }; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"U".to_vec(), js("up();")); + aa.set(b"V".to_vec(), js("validate();")); + aa.set(b"C".to_vec(), js("calc();")); + let aa = doc.add(Object::Dict(aa)); + doc.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Ref(aa))).unwrap(); + rename_to(&mut doc, "other", "solo").unwrap(); + let doc = reopen(&doc); + let group = field(&fields(&doc), "solo").clone(); + let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap(); + let wd = doc.get(moved.obj).as_dict().unwrap().clone(); + let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(widget_aa.contains(b"U") && !widget_aa.contains(b"V") && !widget_aa.contains(b"C"), "only the widget's trigger stays"); + let mut once = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let other = field(&fields(&once), "other").obj; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"V".to_vec(), js("validate();")); + once.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap(); + rename_to(&mut once, "other", "solo").unwrap(); + let once = reopen(&once); + let group = field(&fields(&once), "solo").clone(); + let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap(); + let wd = once.get(moved.obj).as_dict().unwrap().clone(); + assert!(!wd.contains(b"AA"), "a widget left with no trigger has no /AA"); +} + +/// However many "ChoiceN" names a group already uses, a joining button with a clashing export +/// value gets a free one, so the two buttons can still be chosen apart. +#[test] +fn a_joining_button_gets_a_free_export_value_when_choice_names_run_long() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + let group = field(&fields(&doc), "Group1").clone(); + let model = doc.get(group.widgets[0].obj).as_dict().unwrap().clone(); + let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec()))); + let mut kids = vec![Object::Ref(group.widgets[0].obj)]; + for i in 2..=10_000 { + let mut n = pdfcraft_cos::Dict::new(); + n.set(format!("Choice{i}").into_bytes(), Object::Ref(look)); + n.set(b"Off".to_vec(), Object::Ref(look)); + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut w = model.clone(); + w.set(b"AP".to_vec(), Object::Dict(ap)); + w.remove(b"P"); + kids.push(Object::Ref(doc.add(Object::Dict(w)))); + } + doc.update_dict(group.obj, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap(); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let group = field(&fields(&doc), "Group1").clone(); + let states: Vec = group.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); + assert_eq!(states.len(), 10_001); + assert_eq!(states.last().map(String::as_str), Some("Choice10001")); +} + +/// Only a radio button joins by name: other clashes are refused as before, and a locked group +/// isn't changed. +#[test] +fn joining_by_name_is_only_for_radio_buttons_and_unlocked_groups() { + let mut doc = two_radio_groups("Yes", "No"); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("note")).unwrap(); + assert!(rename_to(&mut doc, "note", "Group1").is_err(), "a text field doesn't join a radio group"); + assert!(rename_to(&mut doc, "Group2", "note").is_err(), "a radio button doesn't join a text field"); + set_props(&mut doc, "Group1", &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap(); + assert!(rename_to(&mut doc, "Group2", "Group1").is_err(), "a locked group"); + assert_eq!(fields(&doc).len(), 3); +} + +/// `/I` of field `name`: the selected positions in `/Opt`, or `None` when absent. +fn selection_indices(doc: &Document, name: &str) -> Option> { + let f = field(&fields(doc), name).clone(); + let d = doc.get(f.obj).as_dict().cloned().unwrap(); + d.get(b"I").map(|i| doc.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect()) +} + +#[test] +fn changing_a_lists_options_repoints_its_selected_indices() { + let mut doc = one_page(); + let list = + add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["A".into(), "B".into(), "C".into()], multi: true }, None) + .unwrap(); + set_value(&mut doc, &list, &FieldValue::Choice(vec!["C".into(), "B".into()])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![1, 2])); + let f = field(&fields(&doc), &list).clone(); + doc.update_dict(f.obj, |d| d.set(b"TI".to_vec(), Object::Int(2))).unwrap(); + // Removing A moves B and C up one place. + let opts = |o: &[&str]| FieldProps { options: Some(o.iter().map(|s| s.to_string()).collect()), ..FieldProps::default() }; + set_props(&mut doc, &list, &opts(&["B", "C", "D"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![0, 1])); + // Removing B leaves only C selected; the top row would point past the end. + set_props(&mut doc, &list, &opts(&["C"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![0])); + assert_eq!(top_row(&doc, f.obj), Some(0), "the list scrolls back to its only row"); + // No selected value is offered any more. + set_props(&mut doc, &list, &opts(&["X", "Y"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), None); + let doc = reopen(&doc); + assert_eq!(selection_indices(&doc, &list), None); +} + +fn top_row(doc: &Document, obj: ObjRef) -> Option { + doc.get(obj).as_dict().and_then(|d| d.get(b"TI")).and_then(|o| o.as_int()) +} + +#[test] +fn shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] /V (F) /I [5] /Kids [7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 5 /Rect [50 700 250 720] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + let widget = ObjRef::new(7, 0); + set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["F".into(), "A".into()]), ..FieldProps::default() }).unwrap(); + assert_eq!(selection_indices(&doc, "pick"), Some(vec![0])); + // The widget's own /TI is read before the field's; row 5 no longer exists. + assert_eq!(top_row(&doc, widget), Some(0), "the widget scrolls to F, now row 0"); + let doc = reopen(&doc); + assert_eq!(top_row(&doc, widget), Some(0)); +} + +#[test] +fn each_widget_with_its_own_top_row_scrolls_by_itself() { + // Widget 7 shows from the field's top row; widget 8 keeps its own /TI 4. + let objs = |v: &str| -> Vec { + vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + format!("<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] {v} /Kids [7 0 R 8 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 740] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 4 /Rect [50 600 250 620] /P 3 0 R >>".into(), + ] + }; + let (first, other) = (ObjRef::new(7, 0), ObjRef::new(8, 0)); + // The options shrink: the first widget already shows A, the other's row 4 is gone. + let mut doc = document(&objs("/V (A) /I [0]")); + set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["A".into(), "B".into()]), ..FieldProps::default() }).unwrap(); + assert_eq!(top_row(&doc, first), None); + assert_eq!(top_row(&reopen(&doc), other), Some(0), "options: the other widget scrolls to A"); + // A new value the first widget already shows, below the other's view. + let mut doc = document(&objs("/V (B) /I [1]")); + set_value(&mut doc, "pick", &FieldValue::Choice(vec!["A".into()])).unwrap(); + assert_eq!(top_row(&doc, ObjRef::new(6, 0)), None, "the field's top row did not move"); + assert_eq!(top_row(&reopen(&doc), other), Some(0), "value: the other widget scrolls to A"); +} + +/// A list box's top row is chosen with the font size set in the same edit: at the larger size +/// fewer rows fit, so it scrolls further to keep the selection in view. +#[test] +fn a_list_boxs_top_row_follows_a_font_size_changed_with_its_items() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E)] /V (C) /I [2] /Type /Annot /Subtype /Widget /Rect [50 700 250 740] /P 3 0 R >>".into(), + ]); + let items = ["Z", "A", "B", "C", "D"].map(String::from).to_vec(); + set_props(&mut doc, "pick", &FieldProps { options: Some(items), font_size: Some(20.0), ..FieldProps::default() }).unwrap(); + let doc = reopen(&doc); + let g = field(&fields(&doc), "pick").clone(); + let w = &g.widgets[0]; + let rows = w.list.unwrap(); + let shown = rows.top..rows.top + rows.visible(w.rect[3] - w.rect[1]); + assert!(shown.contains(&3), "C is row 3; rows {shown:?} show"); +} + +#[test] +fn sorting_a_combo_boxs_items_repoints_its_selected_index() { + let mut doc = one_page(); + let combo = add_field( + &mut doc, + 0, + [50.0, 600.0, 250.0, 620.0], + &NewField::Combo { options: vec!["Pear".into(), "apple".into(), "Fig".into()], editable: false }, + None, + ) + .unwrap(); + set_value(&mut doc, &combo, &FieldValue::Choice(vec!["Pear".into()])).unwrap(); + assert_eq!(selection_indices(&doc, &combo), Some(vec![0])); + set_props(&mut doc, &combo, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap(); + // apple, Fig, Pear: the selection follows Pear to the end. + assert_eq!(selection_indices(&doc, &combo), Some(vec![2])); + assert_eq!(field(&fields(&doc), &combo).value, vec!["Pear".to_string()]); +} + +#[test] +fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() { + let mut doc = one_page(); + let list = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["a".into()], multi: false }, None).unwrap(); + let f = field(&fields(&doc), &list).clone(); + let item = |e: &str, shown: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(shown))]); + // Zeta and Alpha both export "x"; `/I` says Zeta is the one selected. + doc.update_dict(f.obj, |d| { + d.set(b"Opt".to_vec(), Object::Array(vec![item("x", "Zeta"), item("x", "Alpha"), item("y", "Mid")])); + d.set(b"V".to_vec(), PdfString::text("x")); + d.set(b"I".to_vec(), Object::Array(vec![Object::Int(0)])); + }) + .unwrap(); + set_props(&mut doc, &list, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap(); + // Alpha, Mid, Zeta: the selection follows Zeta, not the first item exporting "x". + assert_eq!(selection_indices(&doc, &list), Some(vec![2])); +} + +#[test] +fn clearing_a_kids_default_does_not_bring_back_its_parents() { + let mut doc = fixture(); + let all = fields(&doc); + let city = field(&all, "address.city").clone(); + let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"DV".to_vec(), PdfString::text("Main St"))).unwrap(); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(Some("Elm".into())), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").default, ["Elm"]); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert!(f.default.iter().all(String::is_empty), "no default, not the parent's: {:?}", f.default); + let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_string()).map(PdfString::to_text); + assert_eq!(own.as_deref(), Some(""), "the kid holds an empty default of its own"); + set_value(&mut doc, "address.city", &FieldValue::Text("Oak".into())).unwrap(); + reset(&mut doc, None).unwrap(); + assert!(field(&fields(&doc), "address.city").value.iter().all(String::is_empty), "reset clears it"); + // Without a parent default, clearing still removes the key. + set_props(&mut doc, "name", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let name = field(&fields(&doc), "name").clone(); + assert!(doc.get(name.obj).as_dict().unwrap().get(b"DV").is_none()); +} + +#[test] +fn clearing_a_kids_value_does_not_bring_back_its_parents() { + // A text kid, then a list box kid, under a parent with both a value and a default. + for (ft, set) in [("/FT /Tx", FieldValue::Text("Oak".into())), ("/FT /Ch /Opt [(Main St) (Oak)]", FieldValue::Choice(vec!["Oak".into()]))] { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /T (address) {ft} /V (Main St) /DV (Main St) /Kids [6 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /Rect [10 10 110 60] /P 3 0 R >>".into(), + ]); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + set_value(&mut doc, "address.city", &set).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").value, ["Oak"], "{ft}"); + reset(&mut doc, None).unwrap(); + let doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert!(f.value.iter().all(String::is_empty), "{ft}: reset leaves it empty, not the parent's: {:?}", f.value); + let mut doc = doc; + set_value(&mut doc, "address.city", &set).unwrap(); + let clear = if ft.contains("Ch") { FieldValue::Choice(Vec::new()) } else { FieldValue::Text(String::new()) }; + set_value(&mut doc, "address.city", &clear).unwrap(); + let f = field(&fields(&reopen(&doc)), "address.city").clone(); + if ft.contains("Ch") { + assert!(f.value.is_empty(), "{ft}: clearing leaves no choice selected: {:?}", f.value); + } else { + assert!(f.value.iter().all(String::is_empty), "{ft}: clearing leaves it empty: {:?}", f.value); + } + } +} + +#[test] +fn clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off() { + // A check box, then a radio button (flags 49152, inherited from the parent). + for flags in ["", "/Ff 49152"] { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /T (opts) /FT /Btn {flags} /DV /Yes /Kids [6 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /T (a) /Parent 5 0 R /V /Off /AS /Off /Rect [10 10 25 25] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>".into(), + "<< /Length 0 >> +stream + +endstream".into(), + ]); + let kind = field(&fields(&doc), "opts.a").kind; + assert_eq!(kind == FieldKind::Radio, !flags.is_empty()); + set_props(&mut doc, "opts.a", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let f = field(&fields(&doc), "opts.a").clone(); + assert_eq!(doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_name()), Some(&b"Off"[..]), "{kind:?}"); + let on = if kind == FieldKind::Radio { FieldValue::Radio(Some("Yes".into())) } else { FieldValue::Check(true) }; + set_value(&mut doc, "opts.a", &on).unwrap(); + assert_eq!(field(&fields(&doc), "opts.a").value, ["Yes"]); + reset(&mut doc, None).unwrap(); + let f = field(&fields(&doc), "opts.a").clone(); + assert!(f.value.iter().all(String::is_empty), "{kind:?}: reset leaves it off, not the parent's Yes: {:?}", f.value); + } +} + +#[test] +fn editing_button_export_values_preserves_selection_and_appearance() { + let mut doc = fixture(); + set_value(&mut doc, "size", &FieldValue::Radio(Some("S".into()))).unwrap(); + let before = field(&fields(&doc), "size").clone(); + let appearances: Vec<_> = before.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect(); + set_props(&mut doc, "size", &FieldProps { export_value: Some((0, "Small å".into())), ..Default::default() }).unwrap(); + let after = field(&fields(&doc), "size").clone(); + assert_eq!(after.value, before.value); + assert_eq!(after.default, before.default); + assert_eq!(after.widgets, before.widgets); + assert_eq!(after.export_of(0), Some("Small å")); + assert_eq!(after.export_of(1), before.export_of(1)); + assert_eq!(after.export_values(), vec!["Small å"]); + assert_eq!(appearances, after.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect::>()); + let mut saved = reopen(&doc); + assert_eq!(field(&fields(&saved), "size").export_of(0), Some("Small å")); + set_value(&mut saved, "size", &FieldValue::Radio(Some("Small å".into()))).unwrap(); + reset(&mut saved, None).unwrap(); + assert_eq!(field(&fields(&saved), "size").value, before.default); + + set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap(); + set_props(&mut doc, "agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap(); + let check = field(&fields(&doc), "agree").clone(); + assert_eq!(check.value, vec!["Yes"]); + assert_eq!(check.export_values(), vec!["Accepted"]); + assert_eq!(field(&fields(&reopen(&doc)), "agree").export_of(0), Some("Accepted")); +} + +#[test] +fn invalid_button_export_edits_leave_document_unchanged() { + let mut doc = fixture(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + for (field_name, index, value) in [("size", 99, "new"), ("size", 0, ""), ("size", 0, "Off"), ("size", 0, "L"), ("name", 0, "new")] { + assert!(set_props(&mut doc, field_name, &FieldProps { export_value: Some((index, value.into())), ..Default::default() }).is_err()); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + } +} + +#[test] +fn locked_button_export_edits_leave_the_document_unchanged() { + for name in ["agree", "size"] { + let mut doc = fixture(); + set_props(&mut doc, name, &FieldProps { locked: Some(true), ..Default::default() }).unwrap(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let error = set_props( + &mut doc, + name, + &FieldProps { export_value: Some((0, "Changed".into())), tooltip: Some("Must not change".into()), ..Default::default() }, + ) + .unwrap_err(); + assert!(error.to_string().contains("locked")); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + } +} diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs new file mode 100644 index 0000000000..e8615567b6 --- /dev/null +++ b/crates/forms/src/values.rs @@ -0,0 +1,659 @@ +use super::appearances::{declares_pdfa, need_appearances}; +use super::read::{acroform, field_flags, fields, page_refs}; +use super::{ + Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, Widget, af, appearance, + author, calc_order, flags, invalid, name_obj, scripting, +}; + +// ── writing ───────────────────────────────────────────────────────────────────────────────── + +/// Fill the field `name`. +pub fn set_value(doc: &mut Document, name: &str, value: &FieldValue) -> Result<(), FormError> { + set_value_with(doc, name, value, &mut NoScripts) +} + +/// [`set_value`], running the fields' JavaScript through `scripts`. +pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + let all = fields(doc); + if all.is_empty() { + return Err(FormError::NoForm); + } + let named: Vec = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect(); + fill(doc, &all, &named, name, value, scripts)?; + recalculate_with(doc, scripts)?; + Ok(()) +} + +/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and +/// recalculating once, after the last value. Filling fields one by one reads the whole form twice +/// per field, which takes minutes on a form with thousands of fields. +/// +/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after +/// which, as with [`set_value`], no value counts as filled (though each was written). +pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result>, FormError> { + let mut scripts = NoScripts; + let mut form = NamedFields::read(doc); + // The field objects this batch wrote since the form was last read. + let mut written = std::collections::HashSet::new(); + let mut results = Vec::with_capacity(values.len()); + let mut filled = false; + for (name, value) in values { + if form.all.is_empty() { + results.push(Err(FormError::NoForm)); + continue; + } + // A field written earlier in the batch (named twice, or a twin's name) is read again, + // so this write starts from what the earlier one left, as it would one by one. + if form.objs(name).any(|o| written.contains(&o)) { + form = NamedFields::read(doc); + written.clear(); + } + let named = form.named(name); + let result = fill(doc, &form.all, named, name, value, &mut scripts); + written.extend(form.objs(name)); + filled |= result.is_ok(); + results.push(result); + } + if filled { + recalculate_with(doc, &mut scripts)?; + } + Ok(results) +} + +/// The form's fields, with the positions of each fully qualified name in them (in field order). +struct NamedFields { + all: Vec, + by_name: std::collections::HashMap>, +} + +impl NamedFields { + fn read(doc: &Document) -> Self { + let all = fields(doc); + let mut by_name: std::collections::HashMap> = std::collections::HashMap::new(); + for (i, f) in all.iter().enumerate() { + by_name.entry(f.name.clone()).or_default().push(i); + } + NamedFields { all, by_name } + } + + fn named(&self, name: &str) -> &[usize] { + self.by_name.get(name).map_or(&[], Vec::as_slice) + } + + /// The field objects named `name`. + fn objs<'a>(&'a self, name: &str) -> impl Iterator + 'a { + self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj) + } +} + +/// Write `value` into the field `name`, without recalculating: `named` are the positions in +/// `all` (the form as read before this write) of the fields with that name, in field order. +fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?; + if f.read_only() { + return Err(FormError::ReadOnly(name.into())); + } + write_value(doc, f, value, scripts)?; + author::form_fonts_as_objects(doc, all)?; + // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a + // name as separate field dictionaries, instead of widgets under one parent, get the value + // the first one took after its scripts, without running them again. + let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect(); + if !twins.is_empty() { + let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); + for t in twins { + share_value(doc, f.obj, t, &taken, scripts)?; + } + } + Ok(()) +} + +/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own +/// widgets. +fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { + match twin.kind { + FieldKind::CheckBox => { + let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); + set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) + } + FieldKind::Radio => { + let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); + set_states(doc, twin, choice.map(String::as_str)) + } + FieldKind::Text | FieldKind::Combo | FieldKind::List => { + let src = doc.get(source).as_dict().cloned().unwrap_or_default(); + doc.update_dict(twin.obj, |d| { + for key in [&b"V"[..], b"I"] { + match src.get(key) { + Some(v) => d.set(key.to_vec(), v.clone()), + None => { + d.remove(key); + } + } + } + d.remove(b"RV"); + })?; + redraw(doc, twin, value, scripts) + } + FieldKind::PushButton | FieldKind::Signature => Ok(()), + } +} + +/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other +/// calculated fields), as Acrobat does after any value changes. Returns how many changed. +pub fn recalculate(doc: &mut Document) -> Result { + recalculate_with(doc, &mut NoScripts) +} + +/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. +pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { + let mut all = fields(doc); + if !all.iter().any(calc_order::is_calculated) { + return Ok(0); + } + // By object: two fields can share a name in a malformed form, and each calculates. + let order: Vec = calc_order::run_order(doc, &all).0.into_iter().filter_map(|(i, _)| all.get(i)).map(|f| f.obj).collect(); + let mut changed = 0; + for obj in order { + let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue }; + let lookup = |n: &str| -> Vec { + let prefix = format!("{n}."); + all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() + }; + let current = f.value.first().cloned().unwrap_or_default(); + let v = if let Some(js) = &f.actions.scripts.calculate { + let r = scripts.run(FieldEvent::Calculate, js, &f, ¤t, &all); + scripting::apply_changes(doc, &r.changes, &f.name)?; + if !r.changes.is_empty() { + all = fields(doc); + } + if !r.rc { + continue; + } + r.value + } else { + let Some(v) = af::calculate(&f.actions.calculate, &lookup) else { continue }; + v + }; + if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { + doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; + redraw(doc, &f, std::slice::from_ref(&v), scripts)?; + if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { + target.value = vec![v]; + } + changed += 1; + } + } + Ok(changed) +} + +/// The fields whose calculation differs between two readings of a form, matched by field object +/// and named as in `after`: the formulas an edit rewrote, such as a rename's. +pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { + let before: std::collections::HashMap = before.iter().map(|f| (f.obj, &f.actions)).collect(); + after + .iter() + .filter(|f| before.get(&f.obj).is_some_and(|b| b.calculate != f.actions.calculate || b.scripts.calculate != f.actions.scripts.calculate)) + .map(|f| f.name.clone()) + .collect() +} + +pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + match (f.kind, value) { + (FieldKind::Text, FieldValue::Text(t)) => { + if let Some(max) = f.max_len + && t.chars().count() > max + { + return invalid(format!("{:?} takes at most {max} characters", f.name)); + } + // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. + let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; + af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; + let t = &scripting::accept(doc, f, t, scripts)?; + let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); + doc.update_dict(f.obj, |d| { + if t.is_empty() && !shadow { + d.remove(b"V"); + } else { + d.set(b"V".to_vec(), PdfString::text(t)); + } + d.remove(b"RV"); + })?; + redraw(doc, f, std::slice::from_ref(t), scripts) + } + (FieldKind::CheckBox, v) => { + let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); + let on = match v { + FieldValue::Check(on) => *on, + // The on-state name or a yes/no word also work (agents, FDF-style data). + FieldValue::Text(t) | FieldValue::Radio(Some(t)) => { + let t = t.trim(); + if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) { + true + } else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) { + false + } else { + return invalid(format!("{:?} is a check box: use true or false", f.name)); + } + } + FieldValue::Radio(None) => false, + FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), + }; + set_states(doc, f, on.then_some(state.as_str())) + } + (FieldKind::Radio, v) => { + let choice = match v { + FieldValue::Radio(c) => c.clone(), + FieldValue::Text(t) if t.is_empty() => None, + FieldValue::Text(t) => Some(t.clone()), + FieldValue::Check(false) => None, + _ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)), + }; + // An export value from /Opt selects its widget's on state. + let choice = choice.map(|c| f.state_for(&c).map(str::to_owned).unwrap_or(c)); + if let Some(c) = &choice + && !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str())) + { + let opts: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect(); + return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", "))); + } + if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() { + return invalid(format!("{:?} must keep one option selected", f.name)); + } + set_states(doc, f, choice.as_deref()) + } + (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { + let vals: Vec = match value { + FieldValue::Choice(v) => v.clone(), + FieldValue::Text(t) if t.is_empty() => Vec::new(), + FieldValue::Text(t) => vec![t.clone()], + other => return invalid(format!("{:?} can't take {other:?}", f.name)), + }; + if vals.len() > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { + return invalid(format!("{:?} takes a single value", f.name)); + } + // Accept export values or display texts; free text only in editable combo boxes. + let mut exports = Vec::new(); + for v in &vals { + match f.options.iter().find(|(e, d)| e == v || d == v) { + Some((e, _)) => exports.push(e.clone()), + None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), + None if f.options.is_empty() => exports.push(v.clone()), + None => { + let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); + return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); + } + } + } + let mut indices: Vec = + exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect(); + // /I lists option indices in ascending order; /V keeps the caller's value order. + // Every entry above is an integer, so this sorts by its numeric option index. + indices.sort_unstable_by_key(Object::as_int); + // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as + // in other viewers: a pick below the last visible row would otherwise not show (#1524). + let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); + let top = list_scroll(f, &picked, f.options.len()); + // An empty value of its own keeps a parent field's /V from showing through. + let shadow = exports.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); + doc.update_dict(f.obj, |d| { + match exports.as_slice() { + [] if shadow => d.set(b"V".to_vec(), PdfString::text("")), + [] => { + d.remove(b"V"); + } + [one] => d.set(b"V".to_vec(), PdfString::text(one)), + many => d.set(b"V".to_vec(), Object::Array(many.iter().map(|e| Object::String(PdfString::text(e))).collect())), + } + if indices.is_empty() { + d.remove(b"I"); + } else { + d.set(b"I".to_vec(), Object::Array(indices)); + } + if let Some((old, new)) = top + && old != new + { + d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); + } + })?; + scroll_widgets(doc, f, &picked, f.options.len())?; + redraw(doc, f, &exports, scripts) + } + (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), + (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), + (kind, v) => invalid(format!("{:?} is a {kind:?} field and can't take {v:?}", f.name)), + } +} + +/// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of +/// its `count` options are among the rows it draws, as in other viewers: a pick below the last +/// visible row would otherwise not show (#1524). `None` for any other field. +pub(crate) fn list_scroll(f: &Field, picked: &[usize], count: usize) -> Option<(usize, usize)> { + f.widgets.first().filter(|_| f.kind == FieldKind::List).and_then(|w| widget_scroll(w, picked, count)) +} + +/// A list box widget's first row before and after scrolling so `picked` shows. +fn widget_scroll(w: &Widget, picked: &[usize], count: usize) -> Option<(usize, usize)> { + w.list.map(|rows| { + let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; + (rows.top, appearance::ListRows::top_showing(rows.top, picked, rows.visible(height), count)) + }) +} + +/// Scroll every widget of `f` with its own `/TI` (read before the field's) by its own rows and +/// size, so `picked` shows in each. +pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], count: usize) -> Result<(), FormError> { + for w in f.widgets.iter().filter(|w| w.obj != f.obj) { + if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) + && let Some((old, new)) = widget_scroll(w, picked, count) + && old != new + { + doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; + } + } + Ok(()) +} + +/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. +fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { + let v = on.unwrap_or("Off"); + doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?; + for w in &f.widgets { + let state = match (on, w.on_state.as_deref()) { + (Some(c), Some(s)) if c == s => s, + _ => "Off", + }; + // A widget without appearances for its states gets PdfCraft's own. + let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); + if !has_ap { + let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); + let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da); + doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; + } + doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; + } + Ok(()) +} + +/// Copy an existing appearance dictionary before replacing its normal appearance. +fn appearance_dict(doc: &Document, widget: ObjRef) -> Dict { + doc.get(widget).as_dict().and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default() +} + +/// `widget`'s appearance dictionary (a copy, so a shared one is left alone) with the freshly +/// drawn entries of `fresh` (its `/N`). Other entries are kept, but the old down and rollover +/// appearances (`/D`, `/R`) would show the old value, caption or colour on press or hover, so +/// they go — except, for check boxes and radio buttons (whose `/N` is a dictionary of states), +/// the states the new `/N` still draws under the same names. +pub(crate) fn merged_appearance(doc: &Document, widget: ObjRef, fresh: &Dict) -> Dict { + let mut apd = appearance_dict(doc, widget); + // Only a dictionary of states counts (`as_dict` would also see a stream's own dictionary). + let states_of = |o: &Object| match &*doc.resolve(o) { + Object::Dict(d) => Some(d.clone()), + _ => None, + }; + let states: Option>> = fresh.get(b"N").and_then(states_of).map(|d| d.iter().map(|(k, _)| k.clone()).collect()); + for key in [&b"D"[..], &b"R"[..]] { + if fresh.contains(key) { + continue; + } + let kept = states.as_ref().and_then(|names| { + let old = apd.get(key).and_then(states_of)?; + let mut d = Dict::new(); + for (k, v) in old.iter().filter(|(k, _)| names.contains(k)) { + d.set(k.clone(), v.clone()); + } + (!d.is_empty()).then_some(d) + }); + match kept { + Some(d) => apd.set(key.to_vec(), Object::Dict(d)), + None => { + apd.remove(key); + } + } + } + for (k, v) in fresh.iter() { + apd.set(k.clone(), v.clone()); + } + apd +} + +/// Regenerate the normal appearance of every widget of a text or choice field. +fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { + let shown = match values { + [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), + _ => None, + }; + let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); + let mut undrawn = false; + for w in &f.widgets { + let (stream, missing) = match &shown { + Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), + None => appearance::field_appearance_drawn(doc, f, w, values, true), + }; + undrawn |= missing.is_some(); + let existing_ap = in_place + .then(|| { + doc.get(w.obj) + .as_dict() + .and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))) + .and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| n.as_ref()) + .filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_))) + }) + .flatten(); + let ap = match existing_ap { + Some(r) => { + doc.set(r, Object::Stream(stream)); + r + } + None => doc.add(Object::Stream(stream)), + }; + let mut fresh = Dict::new(); + fresh.set(b"N".to_vec(), Object::Ref(ap)); + let apd = merged_appearance(doc, w.obj, &fresh); + doc.update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + d.remove(b"AS"); + pdfcraft_fonts::set_undrawn_mark(d, missing); + })?; + } + if undrawn { + ask_viewers_to_redraw(doc)?; + } + Ok(()) +} + +/// A value its widget's font can't show is stored as typed and drawn with `?` (as Acrobat keeps +/// such a value): `/NeedAppearances` then asks other viewers to draw the fields from their values +/// with fonts that have those characters (the interactive form dictionary, §12.7.3). Not in a +/// document that declares PDF/A, which forbids it: there the widget's mark +/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere. +pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> { + if declares_pdfa(doc) || need_appearances(doc) { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + let entry = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()); + match entry { + Some(Object::Ref(r)) => doc.update_dict(r, |d| d.set(b"NeedAppearances".to_vec(), Object::Bool(true)))?, + Some(Object::Dict(mut af)) => { + af.set(b"NeedAppearances".to_vec(), Object::Bool(true)); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + Ok(()) +} + +/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or +/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and +/// a save restores), or one of whose separate widgets carries a `/V`. +fn password_fields(doc: &Document) -> Vec { + if !has_password_flag(doc) { + return Vec::new(); + } + let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); + let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); + fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() +} + +/// Whether any field dictionary sets the Password flag (#3943 review). A field that inherits the +/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag +/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save +/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like +/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be +/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep. +pub(crate) fn has_password_flag(doc: &Document) -> bool { + let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); + let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); + let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); + let mut seen = std::collections::HashSet::new(); + while let Some((o, depth)) = stack.pop() { + let Object::Ref(r) = o else { continue }; + if depth > 64 || !seen.insert(r) { + continue; + } + if sets_it(r) { + return true; + } + let kids = doc.get(r).as_dict().and_then(|d| d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned())).unwrap_or_default(); + stack.extend(kids.into_iter().map(|k| (k, depth + 1))); + } + // Widgets on the pages, and the fields above them. + page_refs(doc).into_iter().any(|p| { + let annots = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) + }) +} + +/// Whether any text field with the Password flag holds a value in `doc`. +pub fn has_password_values(doc: &Document) -> bool { + !password_fields(doc).is_empty() +} + +/// The typed values of the text fields with the Password flag, as (name, text) pairs, for +/// [`restore_password_values`] after the document was rebased on a file written without them. +pub fn password_values(doc: &Document) -> Vec<(String, String)> { + password_fields(doc).into_iter().filter_map(|f| f.value.first().map(|v| (f.name.clone(), v.clone()))).collect() +} + +/// Put values from [`password_values`] back into the open document, in memory only. A field +/// that is gone or read-only keeps what it has. +pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) -> Result<(), FormError> { + for (name, v) in values { + match set_value(doc, name, &FieldValue::Text(v.clone())) { + Ok(()) | Err(FormError::NoSuchField(_) | FormError::ReadOnly(_)) => {} + Err(e) => return Err(e), + } + } + Ok(()) +} + +/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats. +fn field_chain(doc: &Document, r: ObjRef) -> Vec { + let mut chain = vec![r]; + let mut cur = r; + while chain.len() < 64 { + let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; + if chain.contains(&parent) { + break; + } + chain.push(parent); + cur = parent; + } + chain +} + +/// Remove the values of the text fields with the Password flag, for a copy of the document about +/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is +/// imperative that PDF processors never store the value of the text field in the PDF file if +/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with +/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without +/// is left empty, and its appearance is drawn again from that value, so the masking characters +/// no longer show how long the typed password was. `/DV` and the field's scripts stay. +/// +/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from +/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it +/// is such a password field. Where an ancestor's value is shared with other fields and stays, the +/// password field gets an empty value of its own so it no longer inherits it. Returns how many +/// fields were cleared. +pub fn clear_password_values(doc: &mut Document) -> Result { + let targets = password_fields(doc); + if targets.is_empty() { + return Ok(0); + } + let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); + // Each field's ancestors, found once: only values change below, never `/Parent` (#3943 + // review: walking them again for every ancestor of every target was quadratic). + let chains: Vec<(bool, Vec)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect(); + for f in &targets { + let chain = field_chain(doc, f.obj); + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + d.remove(b"V"); + })?; + } + } + doc.update_dict(f.obj, |d| { + d.remove(b"V"); + })?; + let mut shared_value_kept = false; + for a in chain.iter().skip(1) { + if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { + continue; + } + let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password); + if exclusive { + doc.update_dict(*a, |d| { + d.remove(b"V"); + })?; + } else { + shared_value_kept = true; + } + } + let restored: Vec = f.default.first().cloned().into_iter().collect(); + match restored.first() { + Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?, + None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?, + None => {} + } + redraw(doc, f, &restored, &mut NoScripts)?; + } + Ok(targets.len()) +} + +/// Acrobat's Clear form: every field (or the named ones) back to its default value. +pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result { + let all = fields(doc); + if all.is_empty() { + return Err(FormError::NoForm); + } + if let Some(n) = names + && let Some(missing) = n.iter().find(|n| !all.iter().any(|f| &f.name == *n)) + { + return Err(FormError::NoSuchField(missing.clone())); + } + let mut changed = 0; + for f in all.iter().filter(|f| names.is_none_or(|n| n.contains(&f.name))) { + if f.value == f.default { + continue; + } + let v = match f.kind { + FieldKind::Text => FieldValue::Text(f.default.first().cloned().unwrap_or_default()), + FieldKind::CheckBox => FieldValue::Check(!f.default.is_empty()), + FieldKind::Radio => FieldValue::Radio(f.default.first().cloned()), + FieldKind::Combo | FieldKind::List => FieldValue::Choice(f.default.clone()), + FieldKind::PushButton | FieldKind::Signature => continue, + }; + // A reset is not blocked by NoToggleToOff: go through the writer directly. + let mut tmp = f.clone(); + tmp.flags &= !flags::NO_TOGGLE_TO_OFF; + write_value(doc, &tmp, &v, &mut NoScripts)?; + changed += 1; + } + recalculate(doc)?; + Ok(changed) +} diff --git a/crates/forms/tests/af_date_parse.rs b/crates/forms/tests/af_date_parse.rs new file mode 100644 index 0000000000..802c40dc61 --- /dev/null +++ b/crates/forms/tests/af_date_parse.rs @@ -0,0 +1,10 @@ +use pdfcraft_forms::af::parse_date; + +#[test] +fn bare_a_suffix_is_am() { + assert_eq!(parse_date("12:05 a", "h:MM t").map(|d| d.hh), Some(0)); + assert_eq!(parse_date("12:30a", "h:MM t").map(|d| d.hh), Some(0)); + assert_eq!(parse_date("5:30 a", "h:MM t").map(|d| d.hh), Some(5)); + assert_eq!(parse_date("5:30p", "h:MM t").map(|d| d.hh), Some(17)); + assert_eq!(parse_date("12:05 p", "h:MM t").map(|d| d.hh), Some(12)); +} diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index ee09b8a8e9..c0df850575 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -12,6 +12,9 @@ workspace = true [dependencies] boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b"] } +# FormCalc Date/Num2Date calendar conversion goes through chrono (no hand-rolled calendar code); +# std-only, so it stays wasm-safe. +chrono = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b", "js"] } diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs new file mode 100644 index 0000000000..e977457e2b --- /dev/null +++ b/crates/js/src/document.rs @@ -0,0 +1,224 @@ +use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write}; +use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request}; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; + +macro_rules! getter { + ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ + fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult { + let $f = read($c, name)?; + Ok($body) + } + function($ctx, NativeFunction::from_copy_closure_with_captures(get, $name.clone())) + }}; +} + +pub(crate) fn field_object(ctx: &mut Context, name: &str) -> JsObject { + let n = JsString::from(name); + let value_get = getter!(ctx, n, |f, c| value_of(&f, c)); + let value_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let vals = strings(&arg(args, 0), c)?; + write(c, name, |f| set_value(f, vals))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let as_string = getter!(ctx, n, |f, _c| s(&match f.kind { + FieldType::CheckBox | FieldType::RadioButton => f.value.first().cloned().unwrap_or_else(|| "Off".into()), + _ => f.value.join(","), + })); + let default = getter!(ctx, n, |f, _c| s(&f.default.first().cloned().unwrap_or_default())); + let kind = getter!(ctx, n, |f, _c| s(f.kind.name())); + let fname = getter!(ctx, n, |f, _c| s(&f.name)); + let readonly_get = getter!(ctx, n, |f, _c| JsValue::from(f.readonly)); + let readonly_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.readonly = on)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let required_get = getter!(ctx, n, |f, _c| JsValue::from(f.required)); + let required_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.required = on)?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let display_get = getter!(ctx, n, |f, _c| JsValue::from(f.display)); + let display_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let d = arg(args, 0).to_number(c)? as i32; + write(c, name, |f| f.display = d.clamp(0, 3))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let hidden_get = getter!(ctx, n, |f, _c| JsValue::from(f.display == DISPLAY_HIDDEN)); + let hidden_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let on = arg(args, 0).to_boolean(); + write(c, name, |f| f.display = if on { DISPLAY_HIDDEN } else { DISPLAY_VISIBLE })?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let num_items = getter!(ctx, n, |f, _c| JsValue::from(f.options.len() as f64)); + let char_limit = getter!(ctx, n, |f, _c| JsValue::from(f.char_limit.unwrap_or(0) as f64)); + let export_values = getter!(ctx, n, |f, c| { + let vals: Vec = f.options.iter().map(|o| s(&o.0)).collect(); + JsArray::from_iter(vals, c).into() + }); + let indices = getter!(ctx, n, |f, c| { + let idx: Vec = f.options.iter().enumerate().filter(|(_, o)| f.value.contains(&o.0)).map(|(i, _)| JsValue::from(i as f64)).collect(); + if idx.len() == 1 { idx[0].clone() } else { JsArray::from_iter(idx, c).into() } + }); + let text_color_get = getter!(ctx, n, |f, c| colour(&f.text_color, c)); + let text_color_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let v = strings(&arg(args, 0), c)?; + write(c, name, |f| f.text_color = Some(v))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + let fill_color_get = getter!(ctx, n, |f, c| colour(&f.fill_color, c)); + let fill_color_set = { + fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let v = strings(&arg(args, 0), c)?; + write(c, name, |f| f.fill_color = Some(v))?; + Ok(JsValue::undefined()) + } + function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) + }; + + fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)?; + // bExportValue defaults to true. + let export = args.get(1).is_none_or(JsValue::to_boolean); + let i = if i < 0.0 { f.options.len().saturating_sub(1) } else { i as usize }; + let o = f.options.get(i).ok_or_else(|| error(format!("{} has no item {i}", f.name)))?; + Ok(s(if export { &o.0 } else { &o.1 })) + } + fn is_box_checked(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)? as usize; + let on = f.options.get(i).is_some_and(|o| f.value.first() == Some(&o.0)); + Ok(JsValue::from(on)) + } + fn check_this_box(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + let f = read(c, name)?; + let i = arg(args, 0).to_number(c)? as usize; + let on = args.get(1).is_none_or(JsValue::to_boolean); + let export = f.options.get(i).map(|o| o.0.clone()).ok_or_else(|| error(format!("{} has no widget {i}", f.name)))?; + write(c, name, |f| { + if on { + f.value = vec![export]; + } else if f.value.first() == Some(&export) { + f.value.clear(); + } + })?; + Ok(JsValue::undefined()) + } + fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { + host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped())); + Ok(JsValue::undefined()) + } + + let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; + let get_item_at = NativeFunction::from_copy_closure_with_captures(get_item_at, n.clone()); + let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone()); + let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone()); + let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone()); + ObjectInitializer::new(ctx) + .accessor(js_string!("value"), Some(value_get), Some(value_set), rw) + .accessor(js_string!("valueAsString"), Some(as_string), None, rw) + .accessor(js_string!("defaultValue"), Some(default), None, rw) + .accessor(js_string!("type"), Some(kind), None, rw) + .accessor(js_string!("name"), Some(fname), None, rw) + .accessor(js_string!("readonly"), Some(readonly_get), Some(readonly_set), rw) + .accessor(js_string!("required"), Some(required_get), Some(required_set), rw) + .accessor(js_string!("display"), Some(display_get), Some(display_set), rw) + .accessor(js_string!("hidden"), Some(hidden_get), Some(hidden_set), rw) + .accessor(js_string!("numItems"), Some(num_items), None, rw) + .accessor(js_string!("charLimit"), Some(char_limit), None, rw) + .accessor(js_string!("exportValues"), Some(export_values), None, rw) + .accessor(js_string!("currentValueIndices"), Some(indices), None, rw) + .accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw) + .accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw) + .function(get_item_at, js_string!("getItemAt"), 2) + .function(is_box_checked, js_string!("isBoxChecked"), 1) + .function(check_this_box, js_string!("checkThisBox"), 2) + .function(set_focus, js_string!("setFocus"), 0) + .build() +} + +// ── the document (global object) ──────────────────────────────────────────────────────────── + +pub(crate) fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let name = text(&arg(args, 0), ctx)?; + let exists = host(ctx)?.borrow().fields.iter().any(|f| f.name == name); + if exists { + return Ok(field_object(ctx, &name).into()); + } + // A parent name (e.g. "total" for "total.0"): its first kid stands in, as for groups. + let prefix = format!("{name}."); + let kid = host(ctx)?.borrow().fields.iter().find(|f| f.name.starts_with(&prefix)).map(|f| f.name.clone()); + Ok(kid.map_or(JsValue::null(), |k| field_object(ctx, &k).into())) +} + +pub(crate) fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let i = arg(args, 0).to_number(ctx)?; + let name = host(ctx)?.borrow().fields.get(i.max(0.0) as usize).map(|f| f.name.clone()); + Ok(name.map_or(JsValue::null(), |n| s(&n))) +} + +pub(crate) fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let names = strings(&arg(args, 0), ctx)?; + host(ctx)?.borrow_mut().request(Request::Reset(names)); + Ok(JsValue::undefined()) +} + +pub(crate) fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx)?.borrow_mut().request(Request::Print); + Ok(JsValue::undefined()) +} + +pub(crate) fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let a = arg(args, 0); + let url = match a.as_object() { + Some(o) if !o.is_array() => { + let u = o.get(js_string!("cURL"), ctx)?; + text(&u, ctx)? + } + _ => text(&a, ctx)?, + }; + host(ctx)?.borrow_mut().request(Request::Submit(url)); + Ok(JsValue::undefined()) +} + +pub(crate) fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) +} + +pub(crate) fn num_fields(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().fields.len() as f64)) +} + +pub(crate) fn page_num_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) +} + +pub(crate) fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let n = arg(args, 0).to_number(ctx)?; + let mut h = host(ctx)?.borrow_mut(); + let last = h.doc.num_pages.saturating_sub(1); + let p = (n.max(0.0) as usize).min(last); + h.doc.page = p; + h.request(Request::GoToPage(p)); + Ok(JsValue::undefined()) +} diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs new file mode 100644 index 0000000000..7f207b1916 --- /dev/null +++ b/crates/js/src/format.rs @@ -0,0 +1,620 @@ +use super::host::{arg, error, s, text}; +use super::run::{MAX_PRINTF_OUTPUT_BYTES, MAX_PRINTF_WIDTH_PRECISION}; +use super::{Context, JsDate, JsResult, JsString, JsValue, formcalc}; + +/// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups +/// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` +/// without grouping. +pub(crate) fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let fmt = text(&arg(args, 0), ctx)?; + let mut out = String::new(); + let check_output = |len: usize| -> JsResult<()> { + if len > MAX_PRINTF_OUTPUT_BYTES { + return Err(error(format!("util.printf output exceeds {MAX_PRINTF_OUTPUT_BYTES} bytes"))); + } + Ok(()) + }; + let mut next = 1; + let mut chars = fmt.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + check_output(out.len().saturating_add(c.len_utf8()))?; + out.push(c); + continue; + } + if chars.peek() == Some(&'%') { + chars.next(); + check_output(out.len().saturating_add(1))?; + out.push('%'); + continue; + } + let mut sep = 1u8; + if chars.peek() == Some(&',') { + chars.next(); + sep = chars.next().and_then(|d| d.to_digit(10)).unwrap_or(0) as u8; + } + let (mut plus, mut space, mut zero) = (false, false, false); + while let Some(&f) = chars.peek() { + match f { + '+' => plus = true, + ' ' => space = true, + '0' => zero = true, + '#' => {} + _ => break, + } + chars.next(); + } + let mut width = 0usize; + while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { + width = width.saturating_mul(10).saturating_add(d as usize); + chars.next(); + } + let mut prec = None; + if chars.peek() == Some(&'.') { + chars.next(); + let mut p = 0usize; + while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { + p = p.saturating_mul(10).saturating_add(d as usize); + chars.next(); + } + prec = Some(p); + } + if width > MAX_PRINTF_WIDTH_PRECISION || prec.is_some_and(|p| p > MAX_PRINTF_WIDTH_PRECISION) { + return Err(error(format!("util.printf width or precision exceeds {MAX_PRINTF_WIDTH_PRECISION}"))); + } + let conv = chars.next().unwrap_or('s'); + let v = arg(args, next); + next += 1; + let body = match conv { + 'd' | 'f' => { + let n = v.to_number(ctx)?; + let p = if conv == 'd' { 0 } else { prec.unwrap_or(6) }; + let n = if conv == 'd' { n.trunc() } else { n }; + let mut t = group(&format!("{:.*}", p, n.abs()), sep); + if n < 0.0 { + t.insert(0, '-'); + } else if plus { + t.insert(0, '+'); + } else if space { + t.insert(0, ' '); + } + t + } + 'x' => format!("{:X}", v.to_number(ctx)? as i64), + _ => { + let t = text(&v, ctx)?; + match prec { + Some(p) => t.chars().take(p).collect(), + None => t, + } + } + }; + let pad = width.saturating_sub(body.chars().count()); + check_output(out.len().saturating_add(pad).saturating_add(body.len()))?; + if zero && matches!(conv, 'd' | 'f') { + let (sign, digits) = if body.starts_with(['-', '+', ' ']) { body.split_at(1) } else { ("", body.as_str()) }; + out.push_str(sign); + out.push_str(&"0".repeat(pad)); + out.push_str(digits); + } else { + out.push_str(&" ".repeat(pad)); + out.push_str(&body); + } + } + Ok(s(&out)) +} + +/// Group the integer part of a plain decimal number per `util.printf`'s `,n` flag. +fn group(t: &str, sep: u8) -> String { + let (int, frac) = t.split_once('.').map_or((t, None), |(a, b)| (a, Some(b))); + let (thousands, point) = match sep { + 0 => (Some(','), '.'), + 2 => (Some('.'), ','), + 3 => (None, ','), + _ => (None, '.'), + }; + let mut g = String::new(); + for (i, ch) in int.chars().enumerate() { + if let Some(th) = thousands + && i > 0 + && (int.len() - i).is_multiple_of(3) + { + g.push(th); + } + g.push(ch); + } + if let Some(f) = frac { + g.push(point); + g.push_str(f); + } + g +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, +/// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). +pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let f = arg(args, 0); + let fmt = if f.is_number() { + match f.to_number(ctx)? as i32 { + 0 => "D:yyyymmddHHMMss".to_string(), + 1 => "yyyy.mm.dd HH:MM:ss".to_string(), + _ => "m/d/yy h:MM:ss tt".to_string(), + } + } else { + text(&f, ctx)? + }; + let d = arg(args, 1); + let Some(date) = d.as_object() else { return Err(error("util.printd needs a Date")) }; + let mut part = |m: &str| -> JsResult { + let func = date.get(JsString::from(m), ctx)?; + let func = func.as_callable().ok_or_else(|| error("util.printd needs a Date"))?; + let v = func.call(&d, &[], ctx)?.to_number(ctx)?; + if v.is_nan() { + return Err(error("invalid date")); + } + Ok(v as i64) + }; + let (y, mo, day, wd, h, mi, sec) = + (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); + let mut out = String::new(); + let b = fmt.as_bytes(); + let mut i = 0; + while i < b.len() { + let c = b[i]; + // Only date letters read as runs; a literal is copied one character at a time, so + // measuring its run would rescan a long literal stretch at every position. + let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 }; + #[cfg(test)] + PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed); + let push_num = |out: &mut String, v: i64, n: usize| { + if n >= 2 { + out.push_str(&format!("{v:02}")); + } else { + out.push_str(&v.to_string()); + } + }; + match c { + b'y' => { + if run >= 4 { + out.push_str(&format!("{y:04}")); + } else { + out.push_str(&format!("{:02}", y % 100)); + } + } + b'm' => match run { + r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), + 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), + n => push_num(&mut out, mo + 1, n), + }, + b'd' => match run { + r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), + 3 => out.push_str(&DAYS[wd as usize % 7][..3]), + n => push_num(&mut out, day, n), + }, + b'H' => push_num(&mut out, h, run), + b'h' => push_num(&mut out, if h % 12 == 0 { 12 } else { h % 12 }, run), + b'M' => push_num(&mut out, mi, run), + b's' => push_num(&mut out, sec, run), + b't' => { + let ampm = if h < 12 { "am" } else { "pm" }; + out.push_str(if run >= 2 { ampm } else { &m[..1] }); + } + b'\\' if i + 1 < b.len() => { + // The escaped character may be multi-byte; copy it whole. + let ch = fmt[i + 1..].chars().next().unwrap_or(' '); + out.push(ch); + i += 1 + ch.len_utf8(); + continue; + } + _ => { + // Copy the rest of a multi-byte character too. + let ch = fmt[i..].chars().next().unwrap_or(' '); + out.push(ch); + i += ch.len_utf8(); + continue; + } + } + i += run; + } + Ok(s(&out)) +} + +/// A field of a `util.scand` format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateUnit { + /// `yyyy`: four digits. + Year, + /// `yy`: two digits, read as 1950–2049. + ShortYear, + Month, + /// `mmm`, `mmmm`: a month's name or its three-letter abbreviation. + MonthName, + Day, + /// `ddd`, `dddd`: a weekday's name, read and not checked. + Weekday, + Hour, + /// `h`, `hh`: 0–12, with `tt` for the afternoon. + Hour12, + Minute, + Second, + /// `t`, `tt`: am or pm. + AmPm, +} + +impl DateUnit { + /// A year, month or day: a format needs one of these for `util.scand` to read anything. + fn is_date(self) -> bool { + matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) + } + + /// Read from a run of digits, not from letters. + fn is_numeric(self) -> bool { + !matches!(self, Self::MonthName | Self::Weekday | Self::AmPm) + } +} + +/// A piece of a `util.scand` format. +#[derive(Debug, PartialEq, Eq)] +enum ScanPart { + /// A field, the digits it takes when typed together with the next one (`width`), and whether + /// the next one follows it with nothing between them in the format (`joined`). + Field { unit: DateUnit, width: usize, joined: bool }, + /// Letters the format writes as they are, which the text must have there. + Word(String), +} + +/// The fields and literal words of a `util.scand` format, in order. A numeric field is marked +/// `joined` when another numeric field follows it directly; anything but letters and digits +/// outside a field is left out. +fn scan_parts(fmt: &str) -> Vec { + let chars: Vec = fmt.chars().collect(); + let mut parts: Vec = Vec::new(); + // The last part is a field written right before this point, or letters still being collected. + let (mut after_field, mut in_word) = (false, false); + let mut i = 0; + while let Some(&c) = chars.get(i) { + // Counted for field letters only, so a long run of anything else is read once. + let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 }; + let field = match (c, run) { + ('y', 4..) => Some((DateUnit::Year, 4)), + ('y', _) => Some((DateUnit::ShortYear, 2)), + ('m', 3..) => Some((DateUnit::MonthName, 0)), + ('m', n) => Some((DateUnit::Month, n)), + ('d', 3..) => Some((DateUnit::Weekday, 0)), + ('d', n) => Some((DateUnit::Day, n)), + ('H', n) => Some((DateUnit::Hour, n.min(2))), + ('h', n) => Some((DateUnit::Hour12, n.min(2))), + ('M', n) => Some((DateUnit::Minute, n.min(2))), + ('s', n) => Some((DateUnit::Second, n.min(2))), + ('t', _) => Some((DateUnit::AmPm, 0)), + _ => None, + }; + if let Some((unit, width)) = field { + if after_field + && unit.is_numeric() + && let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut() + && last.is_numeric() + { + *joined = true; + } + parts.push(ScanPart::Field { unit, width, joined: false }); + (after_field, in_word) = (true, false); + i += run; + continue; + } + // `\` writes the next character as it is. + let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) }; + i += step; + after_field = false; + match literal { + Some(l) if l.is_alphanumeric() => match parts.last_mut() { + Some(ScanPart::Word(w)) if in_word => w.push(l), + _ => { + parts.push(ScanPart::Word(l.to_string())); + in_word = true; + } + }, + _ => in_word = false, + } + } + parts +} + +/// A run of digits or of letters in the text `util.scand` reads. +enum ScanToken<'a> { + Digits(&'a str), + Letters(&'a str), +} + +/// The text's runs of digits and of letters, in order; anything else only separates them, and +/// may not begin or end the text. `None` when it does. +fn scan_tokens(text: &str) -> Option>> { + // Some(true) for a digit, Some(false) for a letter, None for a separator. + let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit()); + kind(text.chars().next()?)?; + kind(text.chars().next_back()?)?; + let mut out = Vec::new(); + let mut start: Option<(usize, bool)> = None; + for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { + let k = kind(c); + if let Some((from, digits)) = start + && k != Some(digits) + { + let piece = text.get(from..at)?; + out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) }); + start = None; + } + if let Some(digits) = k + && start.is_none() + { + start = Some((at, digits)); + } + } + Some(out) +} + +/// What `util.scand` reads. +#[derive(Debug, PartialEq, Eq)] +enum Scanned { + /// The text is empty: today, at noon. + Today, + /// A date and time: year, month (1–12), day, hours, minutes, seconds. + At(i32, u32, u32, u32, u32, u32), +} + +/// The fields `util.scand` has read so far. +#[derive(Default)] +struct ScanValues { + y: Option, + mo: Option, + d: Option, + h: Option, + mi: Option, + sec: Option, + pm: Option, + /// The hour was read from `h` or `hh`. + twelve: bool, +} + +impl ScanValues { + /// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour + /// marked as 12-hour. Month names, weekdays and am/pm are not read here. + fn set(&mut self, unit: DateUnit, v: u32) { + match unit { + DateUnit::Year => self.y = i32::try_from(v).ok(), + DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }), + DateUnit::Month => self.mo = Some(v), + DateUnit::Day => self.d = Some(v), + DateUnit::Hour => self.h = Some(v), + DateUnit::Hour12 => { + self.h = Some(v); + self.twelve = true; + } + DateUnit::Minute => self.mi = Some(v), + DateUnit::Second => self.sec = Some(v), + DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {} + } + } +} + +/// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box): +/// +/// - Fields may be separated by anything but digits and letters, whether or not the format +/// separates them, but nothing may come before the first or after the last. +/// - Digits typed together fill the format's fields written together with exactly as many +/// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may +/// have one or two, except a four-digit year. +/// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case. +/// - Impossible dates are refused, but a month or day of 0 is read as 1. +/// - A format with no year, month or day reads nothing; an empty text is today, at noon. +/// +/// Not observed: a year left out is `this_year`, and a month or day left out is 1. +fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { + let parts = scan_parts(fmt); + if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) { + return None; + } + if text.is_empty() { + return Some(Scanned::Today); + } + let tokens = scan_tokens(text)?; + let mut v = ScanValues::default(); + let (mut p, mut t) = (0, 0); + while let Some(part) = parts.get(p) { + let token = tokens.get(t)?; + t += 1; + match (part, token) { + (ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => { + if w.to_lowercase() != l.to_lowercase() { + return None; + } + p += 1; + } + (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { + v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); + p += 1; + } + (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { + named(&DAYS, l)?; + p += 1; + } + (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { + v.pm = Some(match l.to_lowercase().as_str() { + "am" | "a" => false, + "pm" | "p" => true, + _ => return None, + }); + p += 1; + } + (ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => { + // The fields written together from here, no more than the run's digits can fill, + // and the fewest of them the run fills. + let mut group = vec![(*unit, *width)]; + let (mut more, mut widths) = (*joined, *width); + while more + && widths < run.len() + && let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len()) + { + group.push((*unit, *width)); + widths = widths.saturating_add(*width); + more = *joined; + } + let mut sum = 0; + let fills = group.iter().position(|(_, w)| { + sum += w; + sum == run.len() + }); + if let Some(last) = fills { + let mut at = 0; + for (unit, w) in group.iter().take(last + 1) { + v.set(*unit, run.get(at..at + w)?.parse().ok()?); + at += w; + } + p += last + 1; + } else if *unit != DateUnit::Year && run.len() <= 2 { + v.set(*unit, run.parse().ok()?); + p += 1; + } else { + return None; + } + } + _ => return None, + } + } + if t != tokens.len() { + return None; + } + let y = v.y.unwrap_or(this_year); + let mo = v.mo.unwrap_or(1).max(1); + let d = v.d.unwrap_or(1).max(1); + if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) { + return None; + } + let mut h = v.h.unwrap_or(0); + if v.twelve && h > 12 { + return None; + } + match v.pm { + Some(true) if h < 12 => h += 12, + Some(false) if h == 12 => h = 0, + _ => {} + } + let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0)); + (h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec)) +} + +/// The index of the name `word` is, in full or as its first three letters, in any case. +fn named(names: &[&str], word: &str) -> Option { + let word = word.to_lowercase(); + let i = names.iter().position(|n| { + let n = n.to_lowercase(); + word == n || n.get(..3) == Some(word.as_str()) + })?; + u32::try_from(i).ok() +} + +/// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local +/// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read. +pub(crate) fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else { + return Ok(JsValue::null()); + }; + let fmt = text(&arg(args, 0), ctx)?; + let date = JsDate::new(ctx); + let this_year = date.get_full_year(ctx)?.to_number(ctx)?; + let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; + match scan_date(&fmt, &typed, this_year) { + None => return Ok(JsValue::null()), + Some(Scanned::Today) => { + date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?; + } + Some(Scanned::At(y, mo, d, h, mi, sec)) => { + // setFullYear, not the constructor, so years 0–99 stay as they are. + date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?; + date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?; + } + } + Ok(date.into()) +} + +/// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` +/// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. +pub(crate) fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let fmt: Vec = text(&arg(args, 0), ctx)?.chars().collect(); + let src: Vec = text(&arg(args, 1), ctx)?.chars().collect(); + let (mut out, mut j, mut case) = (String::new(), 0, 0); + let mut i = 0; + let push = |out: &mut String, c: char, case: i32| match case { + 1 => out.extend(c.to_uppercase()), + -1 => out.extend(c.to_lowercase()), + _ => out.push(c), + }; + while i < fmt.len() { + let f = fmt[i]; + i += 1; + let take = |j: &mut usize, ok: &dyn Fn(char) -> bool| -> Option { + while *j < src.len() { + let c = src[*j]; + *j += 1; + if ok(c) { + return Some(c); + } + } + None + }; + match f { + '?' => { + if let Some(c) = take(&mut j, &|_| true) { + push(&mut out, c, case) + } + } + 'X' => { + if let Some(c) = take(&mut j, &|c| c.is_alphanumeric()) { + push(&mut out, c, case) + } + } + 'A' => { + if let Some(c) = take(&mut j, &|c| c.is_alphabetic()) { + push(&mut out, c, case) + } + } + '9' => { + if let Some(c) = take(&mut j, &|c| c.is_ascii_digit()) { + push(&mut out, c, case) + } + } + '*' => { + for c in src[j.min(src.len())..].iter().copied() { + push(&mut out, c, case); + } + j = src.len(); + } + '\\' if i < fmt.len() => { + out.push(fmt[i]); + i += 1; + } + '>' => case = 1, + '<' => case = -1, + '=' => case = 0, + c => out.push(c), + } + } + Ok(s(&out)) +} + +// ── stack safety ──────────────────────────────────────────────────────────────────────────── +// +// boa's parser and compiler recurse once per nesting level and have no limit of their own: about +// a hundred nested parentheses overflow a 2 MiB stack, which aborts the whole app. Scripts come +// from documents, so `run` refuses nesting no real form needs and runs the engine on a thread +// with a large stack (address space, committed only as it is used). Together these bound the +// stack a script can use: brackets and prefix operators by the limits below, and every other +// construct (a few KiB per level, several bytes of source each) by the length limit. + +/// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work. +#[cfg(test)] +pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index dc32d457bf..3e68ce081d 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -12,11 +12,32 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use crate::Limits; use crate::xfa::{ HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances, - resolve_som, run_within, + reset_names, resolve_som, run_within, }; +use crate::{Limits, Message, MessageButtons, MessageIcon}; + +mod access; +mod ast; +mod builtins; +mod dates; +mod eval; +mod lexer; +mod parser; +mod value; +mod words; + +use ast::Stmt; +pub(crate) use dates::days_in; +#[cfg(test)] +use dates::{civil_from_days, days_from_civil, format_date, parse_date}; +use parser::parse; +use value::Val; +#[cfg(test)] +use value::{fixed, num_text, parse_num}; +#[cfg(test)] +use words::word_num; /// Deepest nesting the parser follows. const MAX_PARSE_DEPTH: usize = 64; @@ -47,720 +68,6 @@ fn is_limit(e: &str) -> bool { #[cfg(not(target_arch = "wasm32"))] const CLOCK_EVERY: u64 = 256; -// ── lexer ─────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Tok { - Num(f64), - Str(String), - Ident(String), - Op(&'static str), - Newline, - Eof, -} - -#[derive(Clone, Debug, PartialEq)] -struct Token { - tok: Tok, - line: usize, -} - -const OPS: [&str; 20] = ["<=", ">=", "==", "<>", "..", "(", ")", "[", "]", ".", ",", "=", "<", ">", "+", "-", "*", "/", "&", "|"]; - -fn lex(src: &str) -> Result, String> { - let chars: Vec = src.chars().collect(); - let mut out = Vec::new(); - let mut i = 0; - let mut line = 1; - let mut depth = 0usize; - while i < chars.len() { - let c = chars[i]; - match c { - '\n' => { - line += 1; - i += 1; - // Newlines inside brackets continue the expression. - if depth == 0 { - out.push(Token { tok: Tok::Newline, line }); - } - } - ' ' | '\t' | '\r' => i += 1, - ';' => { - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - } - '/' if chars.get(i + 1) == Some(&'/') => { - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - } - '"' => { - let mut s = String::new(); - i += 1; - loop { - match chars.get(i) { - None => return Err(format!("line {line}: unterminated string")), - Some('"') if chars.get(i + 1) == Some(&'"') => { - s.push('"'); - i += 2; - } - Some('"') => { - i += 1; - break; - } - Some(&ch) => { - if ch == '\n' { - line += 1; - } - s.push(ch); - i += 1; - } - } - if s.len() > MAX_STRING { - return Err(format!("line {line}: a string is too long")); - } - } - out.push(Token { tok: Tok::Str(s), line }); - } - c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())) => { - let start = i; - while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { - i += 1; - } - if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { - let mut j = i + 1; - if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { - j += 1; - } - if j < chars.len() && chars[j].is_ascii_digit() { - i = j; - while i < chars.len() && chars[i].is_ascii_digit() { - i += 1; - } - } - } - let text: String = chars[start..i].iter().collect(); - let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; - out.push(Token { tok: Tok::Num(n), line }); - } - '!' => { - out.push(Token { tok: Tok::Ident("!".into()), line }); - i += 1; - } - c if c.is_alphabetic() || c == '_' || c == '$' => { - let start = i; - i += 1; - while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '$') { - i += 1; - } - let text: String = chars[start..i].iter().collect(); - out.push(Token { tok: Tok::Ident(text), line }); - } - _ => { - let rest: String = chars[i..chars.len().min(i + 2)].iter().collect(); - let op = OPS.iter().find(|o| rest.starts_with(*o)).copied().ok_or_else(|| format!("line {line}: unexpected character {c:?}"))?; - match op { - "(" | "[" => depth += 1, - ")" | "]" => depth = depth.saturating_sub(1), - _ => {} - } - out.push(Token { tok: Tok::Op(op), line }); - i += op.len(); - } - } - } - out.push(Token { tok: Tok::Newline, line }); - out.push(Token { tok: Tok::Eof, line }); - Ok(out) -} - -// ── syntax ────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Expr { - Num(f64), - Str(String), - Null, - Ident(String), - /// `base.name` - Member(Box, String), - /// `base[n]`, `base[*]`, `base[-n]` - Index(Box, Idx), - /// `base..name` - Descend(Box, String), - Call(Box, Vec), - Neg(Box), - Not(Box), - Bin(&'static str, Box, Box), -} - -#[derive(Clone, Debug, PartialEq)] -enum Idx { - /// `[*]`: every instance. - All, - /// `[n]`: the n-th instance (from 0). - Abs(Box), - /// `[-n]` / `[+n]`: n instances before or after the one the script runs in. - Rel(i64), -} - -#[derive(Clone, Debug, PartialEq)] -enum Stmt { - Var(String, Option), - Assign(Expr, Expr), - Expr(Expr), - If(Vec<(Expr, Vec)>, Option>), - While(Expr, Vec), - For { var: String, from: Expr, to: Expr, up: bool, step: Option, body: Vec }, - Foreach { var: String, list: Vec, body: Vec }, - Func { name: String, f: Rc }, - Return(Option), - Break, - Continue, - Exit, - Throw(Expr), -} - -struct Parser { - toks: Vec, - at: usize, - depth: usize, - /// Operators and accessors chained so far in the expression being parsed. - chain: usize, -} - -fn kw(t: &Tok, word: &str) -> bool { - matches!(t, Tok::Ident(s) if s.eq_ignore_ascii_case(word)) -} - -impl Parser { - fn peek(&self) -> &Tok { - self.toks.get(self.at).map_or(&Tok::Eof, |t| &t.tok) - } - - fn line(&self) -> usize { - self.toks.get(self.at).map_or(0, |t| t.line) - } - - fn next(&mut self) -> Tok { - let t = self.peek().clone(); - self.at += 1; - t - } - - fn is_op(&self, op: &str) -> bool { - matches!(self.peek(), Tok::Op(o) if *o == op) - } - - fn eat_op(&mut self, op: &str) -> bool { - if self.is_op(op) { - self.at += 1; - true - } else { - false - } - } - - fn expect_op(&mut self, op: &str) -> Result<(), String> { - if self.eat_op(op) { Ok(()) } else { Err(format!("line {}: expected {op}", self.line())) } - } - - fn eat_kw(&mut self, word: &str) -> bool { - if kw(self.peek(), word) { - self.at += 1; - true - } else { - false - } - } - - fn expect_kw(&mut self, word: &str) -> Result<(), String> { - if self.eat_kw(word) { Ok(()) } else { Err(format!("line {}: expected {word}", self.line())) } - } - - fn skip_newlines(&mut self) { - while matches!(self.peek(), Tok::Newline) { - self.at += 1; - } - } - - fn enter(&mut self) -> Result<(), String> { - self.depth += 1; - if self.depth > MAX_PARSE_DEPTH { - return Err("the script is nested too deeply".into()); - } - Ok(()) - } - - fn leave(&mut self) { - self.depth = self.depth.saturating_sub(1); - } - - /// One more link in an operator or accessor chain. - fn link(&mut self) -> Result<(), String> { - self.chain += 1; - if self.chain > MAX_CHAIN { - return Err(format!("line {}: an expression is too long", self.line())); - } - Ok(()) - } - - /// Statements up to one of `enders` (not consumed). - fn block(&mut self, enders: &[&str]) -> Result, String> { - self.enter()?; - let mut out = Vec::new(); - loop { - self.skip_newlines(); - match self.peek() { - Tok::Eof => break, - Tok::Ident(w) if enders.iter().any(|e| w.eq_ignore_ascii_case(e)) => break, - _ => out.push(self.statement()?), - } - } - self.leave(); - Ok(out) - } - - fn statement(&mut self) -> Result { - let line = self.line(); - match self.peek().clone() { - Tok::Ident(w) if w.eq_ignore_ascii_case("var") => { - self.at += 1; - let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after var")) }; - let init = if self.eat_op("=") { Some(self.top_expr()?) } else { None }; - Ok(Stmt::Var(name, init)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("if") => { - self.at += 1; - let mut arms = Vec::new(); - let cond = self.top_expr()?; - self.expect_kw("then")?; - let body = self.block(&["elseif", "else", "endif"])?; - arms.push((cond, body)); - let mut otherwise = None; - loop { - if self.eat_kw("elseif") { - let c = self.top_expr()?; - self.expect_kw("then")?; - let b = self.block(&["elseif", "else", "endif"])?; - arms.push((c, b)); - } else if self.eat_kw("else") { - otherwise = Some(self.block(&["endif"])?); - } else { - self.expect_kw("endif")?; - break; - } - } - Ok(Stmt::If(arms, otherwise)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("while") => { - self.at += 1; - let cond = self.top_expr()?; - self.expect_kw("do")?; - let body = self.block(&["endwhile"])?; - self.expect_kw("endwhile")?; - Ok(Stmt::While(cond, body)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("for") => { - self.at += 1; - let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after for")) }; - self.expect_op("=")?; - let from = self.top_expr()?; - let up = if self.eat_kw("upto") { - true - } else if self.eat_kw("downto") { - false - } else { - return Err(format!("line {line}: expected upto or downto")); - }; - let to = self.top_expr()?; - let step = if self.eat_kw("step") { Some(self.top_expr()?) } else { None }; - self.expect_kw("do")?; - let body = self.block(&["endfor"])?; - self.expect_kw("endfor")?; - Ok(Stmt::For { var, from, to, up, step, body }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("foreach") => { - self.at += 1; - let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after foreach")) }; - self.expect_kw("in")?; - self.expect_op("(")?; - let mut list = Vec::new(); - if !self.is_op(")") { - loop { - list.push(self.top_expr()?); - if !self.eat_op(",") { - break; - } - } - } - self.expect_op(")")?; - self.expect_kw("do")?; - let body = self.block(&["endfor"])?; - self.expect_kw("endfor")?; - Ok(Stmt::Foreach { var, list, body }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("func") => { - self.at += 1; - let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after func")) }; - self.expect_op("(")?; - let mut params = Vec::new(); - if !self.is_op(")") { - loop { - let Tok::Ident(p) = self.next() else { return Err(format!("line {line}: expected a parameter name")) }; - params.push(p); - if !self.eat_op(",") { - break; - } - } - } - self.expect_op(")")?; - self.expect_kw("do")?; - let body = self.block(&["endfunc"])?; - self.expect_kw("endfunc")?; - Ok(Stmt::Func { name, f: Rc::new(Func { params, body }) }) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("return") => { - self.at += 1; - let v = if matches!(self.peek(), Tok::Newline | Tok::Eof) { None } else { Some(self.top_expr()?) }; - Ok(Stmt::Return(v)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("break") => { - self.at += 1; - Ok(Stmt::Break) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("continue") => { - self.at += 1; - Ok(Stmt::Continue) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("exit") => { - self.at += 1; - Ok(Stmt::Exit) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("throw") => { - self.at += 1; - Ok(Stmt::Throw(self.top_expr()?)) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("do") => { - // `do … end`: a block as one statement. - self.at += 1; - let body = self.block(&["end"])?; - self.expect_kw("end")?; - Ok(Stmt::If(vec![(Expr::Num(1.0), body)], None)) - } - _ => { - let e = self.top_expr()?; - if self.eat_op("=") { - let v = self.top_expr()?; - return Ok(Stmt::Assign(e, v)); - } - Ok(Stmt::Expr(e)) - } - } - } - - /// An expression that starts a tree of its own (a condition, a bound, a statement's - /// expression): its operator chain is counted from zero, whatever came before it. - fn top_expr(&mut self) -> Result { - self.chain = 0; - self.expr() - } - - fn expr(&mut self) -> Result { - self.enter()?; - let e = self.or_expr(); - self.leave(); - e - } - - fn or_expr(&mut self) -> Result { - let mut l = self.and_expr()?; - loop { - if self.eat_op("|") || self.eat_kw("or") { - self.link()?; - let r = self.and_expr()?; - l = Expr::Bin("|", Box::new(l), Box::new(r)); - } else { - return Ok(l); - } - } - } - - fn and_expr(&mut self) -> Result { - let mut l = self.cmp_expr()?; - loop { - if self.eat_op("&") || self.eat_kw("and") { - self.link()?; - let r = self.cmp_expr()?; - l = Expr::Bin("&", Box::new(l), Box::new(r)); - } else { - return Ok(l); - } - } - } - - fn cmp_expr(&mut self) -> Result { - let mut l = self.add_expr()?; - loop { - let op = match self.peek() { - Tok::Op("==") => "==", - Tok::Op("<>") => "<>", - Tok::Op("<=") => "<=", - Tok::Op(">=") => ">=", - Tok::Op("<") => "<", - Tok::Op(">") => ">", - Tok::Ident(w) if w.eq_ignore_ascii_case("eq") => "==", - Tok::Ident(w) if w.eq_ignore_ascii_case("ne") => "<>", - Tok::Ident(w) if w.eq_ignore_ascii_case("le") => "<=", - Tok::Ident(w) if w.eq_ignore_ascii_case("ge") => ">=", - Tok::Ident(w) if w.eq_ignore_ascii_case("lt") => "<", - Tok::Ident(w) if w.eq_ignore_ascii_case("gt") => ">", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.add_expr()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn add_expr(&mut self) -> Result { - let mut l = self.mul_expr()?; - loop { - let op = match self.peek() { - Tok::Op("+") => "+", - Tok::Op("-") => "-", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.mul_expr()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn mul_expr(&mut self) -> Result { - let mut l = self.unary()?; - loop { - let op = match self.peek() { - Tok::Op("*") => "*", - Tok::Op("/") => "/", - _ => return Ok(l), - }; - self.at += 1; - self.link()?; - let r = self.unary()?; - l = Expr::Bin(op, Box::new(l), Box::new(r)); - } - } - - fn unary(&mut self) -> Result { - self.enter()?; - let e = if self.eat_op("-") { - Ok(Expr::Neg(Box::new(self.unary()?))) - } else if self.eat_op("+") { - self.unary() - } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { - if matches!(self.peek(), Tok::Ident(w) if w == "!") { - self.at += 1; - } - Ok(Expr::Not(Box::new(self.unary()?))) - } else { - self.postfix() - }; - self.leave(); - e - } - - fn postfix(&mut self) -> Result { - let mut e = self.primary()?; - loop { - if matches!(self.peek(), Tok::Op("." | ".." | "[" | "(")) { - self.link()?; - } - if self.eat_op(".") { - let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after .", self.line())) }; - e = Expr::Member(Box::new(e), name); - } else if self.eat_op("..") { - let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after ..", self.line())) }; - e = Expr::Descend(Box::new(e), name); - } else if self.eat_op("[") { - let sign = match self.peek() { - Tok::Op("-") => Some(-1), - Tok::Op("+") => Some(1), - _ => None, - }; - let relative = sign.and_then(|sign| match (self.toks.get(self.at + 1).map(|t| &t.tok), self.toks.get(self.at + 2).map(|t| &t.tok)) { - (Some(Tok::Num(n)), Some(Tok::Op("]"))) if n.is_finite() && *n >= 0.0 && *n < 1e6 => Some(sign * (*n as i64)), - _ => None, - }); - let idx = if let Some(k) = relative { - self.at += 2; - Idx::Rel(k) - } else if self.eat_op("*") { - Idx::All - } else { - Idx::Abs(Box::new(self.expr()?)) - }; - self.expect_op("]")?; - e = Expr::Index(Box::new(e), idx); - } else if self.eat_op("(") { - let mut args = Vec::new(); - self.skip_newlines(); - if !self.is_op(")") { - loop { - args.push(self.expr()?); - self.skip_newlines(); - if !self.eat_op(",") { - break; - } - self.skip_newlines(); - } - } - self.expect_op(")")?; - e = Expr::Call(Box::new(e), args); - } else { - return Ok(e); - } - } - } - - fn primary(&mut self) -> Result { - let line = self.line(); - match self.next() { - Tok::Num(n) => Ok(Expr::Num(n)), - Tok::Str(s) => Ok(Expr::Str(s)), - Tok::Op("(") => { - self.skip_newlines(); - let e = self.expr()?; - self.skip_newlines(); - self.expect_op(")")?; - Ok(e) - } - Tok::Ident(w) if w.eq_ignore_ascii_case("null") => Ok(Expr::Null), - Tok::Ident(w) if w.eq_ignore_ascii_case("nan") => Ok(Expr::Num(f64::NAN)), - Tok::Ident(w) if w.eq_ignore_ascii_case("infinity") => Ok(Expr::Num(f64::INFINITY)), - Tok::Ident(w) if w.eq_ignore_ascii_case("true") => Ok(Expr::Num(1.0)), - Tok::Ident(w) if w.eq_ignore_ascii_case("false") => Ok(Expr::Num(0.0)), - Tok::Ident(w) => Ok(Expr::Ident(w)), - t => Err(format!("line {line}: unexpected {}", describe(&t))), - } - } -} - -fn describe(t: &Tok) -> String { - match t { - Tok::Num(n) => format!("number {n}"), - Tok::Str(s) => format!("string {s:?}"), - Tok::Ident(s) => s.clone(), - Tok::Op(o) => format!("{o:?}"), - Tok::Newline => "end of line".into(), - Tok::Eof => "end of script".into(), - } -} - -fn parse(src: &str) -> Result, String> { - let toks = lex(src)?; - let mut p = Parser { toks, at: 0, depth: 0, chain: 0 }; - let body = p.block(&[])?; - match p.peek() { - Tok::Eof => Ok(body), - t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), - } -} - -// ── values ────────────────────────────────────────────────────────────────────────────────── - -#[derive(Clone, Debug, PartialEq)] -enum Val { - Null, - Num(f64), - Str(String), - /// A form object, by index in the host. - Node(usize), - /// Several objects or values (`Row[*]`, `Row[*].Amount`). - List(Vec), - Host, - Layout, - Event, - /// `xfa` itself (`xfa.form`, `xfa.host`, …). - Xfa, - /// The instance manager of `name` under `parent`. - Im(usize, String), - /// A property object whose contents are not modelled (`$.border.fill…`): reads give - /// another sink, writes change nothing. - Sink, -} - -/// A number as FormCalc prints it: integers without a fraction, otherwise up to 15 digits. -fn num_text(n: f64) -> String { - if !n.is_finite() { - return if n.is_nan() { - "NaN".into() - } else if n > 0.0 { - "Infinity".into() - } else { - "-Infinity".into() - }; - } - if n.fract() == 0.0 && n.abs() < 1e15 { - return format!("{}", n as i64); - } - let s = format!("{n:.12}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } -} - -/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). -fn fixed(v: f64, decimals: usize) -> String { - let k = 10f64.powi(decimals.min(20) as i32); - let scaled = (v * k).round() / k; - format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) -} - -/// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. -fn parse_num(s: &str) -> Option { - let t = s.trim(); - if t.is_empty() { - return None; - } - let mut end = 0; - let b = t.as_bytes(); - let mut seen_digit = false; - let mut seen_dot = false; - let mut seen_e = false; - let mut i = 0; - if i < b.len() && (b[i] == b'-' || b[i] == b'+') { - i += 1; - } - while i < b.len() { - let c = b[i]; - if c.is_ascii_digit() { - seen_digit = true; - end = i + 1; - } else if c == b'.' && !seen_dot && !seen_e { - seen_dot = true; - } else if (c == b'e' || c == b'E') && seen_digit && !seen_e { - seen_e = true; - if i + 1 < b.len() && (b[i + 1] == b'-' || b[i + 1] == b'+') { - i += 1; - } - } else { - break; - } - i += 1; - } - if !seen_digit { - return None; - } - t.get(..end).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite()) -} - -fn is_numeric_text(s: &str) -> bool { - s.trim().parse::().is_ok_and(|v| v.is_finite()) -} - // ── the evaluator ─────────────────────────────────────────────────────────────────────────── enum Flow { @@ -797,1714 +104,6 @@ struct Interp<'h> { type R = Result; -impl Interp<'_> { - fn step(&mut self) -> R<()> { - self.charge(1) - } - - /// `n` units of work done (a step per statement or expression, more for work on long - /// strings and lists). - fn charge(&mut self, n: u64) -> R<()> { - let before = self.steps; - self.steps = self.steps.saturating_add(n); - if self.steps > MAX_STEPS { - return Err(RAN_TOO_LONG.into()); - } - #[cfg(not(target_arch = "wasm32"))] - if self.steps / CLOCK_EVERY != before / CLOCK_EVERY - && let Some(d) = self.deadline - && std::time::Instant::now() > d - { - return Err(RAN_TOO_LONG.into()); - } - #[cfg(target_arch = "wasm32")] - let _ = before; - Ok(()) - } - - /// `more` nodes into `out`, each once, never past [`MAX_LIST`]. - fn gather(&mut self, out: &mut Vec, seen: &mut HashSet, more: impl IntoIterator) -> R<()> { - for i in more { - if seen.insert(i) { - out.push(i); - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - } - } - self.charge(1) - } - - /// The nodes of `v`, each once. - fn nodes_list(&self, v: &Val) -> Vec { - let mut seen = HashSet::new(); - self.nodes_of(v).into_iter().filter(|i| seen.insert(*i)).collect() - } - - fn node(&self, i: usize) -> Option<&HNode> { - self.h.nodes.get(i).filter(|n| !n.gone) - } - - // ── coercions - - /// A node's value as a FormCalc value. - fn node_value(&self, i: usize) -> Val { - let Some(n) = self.node(i) else { return Val::Null }; - if n.value.is_empty() { - return Val::Null; - } - if n.numeric - && let Some(v) = parse_num(&n.value) - { - return Val::Num(v); - } - Val::Str(n.value.clone()) - } - - /// What copying `v` (or the values it stands for) costs, in steps: one per 256 bytes, so - /// copying long strings over and over runs out of steps even where there is no clock - /// (wasm). A form object counts its value; a list its items too. - fn cost(&self, v: &Val) -> u64 { - let mut bytes = 0usize; - let mut todo = vec![v]; - while let Some(v) = todo.pop() { - match v { - Val::Str(s) => bytes = bytes.saturating_add(s.len()), - Val::Node(i) => bytes = bytes.saturating_add(self.node(*i).map_or(0, |n| n.value.len())), - Val::List(items) => { - bytes = bytes.saturating_add(items.len().saturating_mul(16)); - todo.extend(items.iter()); - } - _ => {} - } - } - (bytes / 256) as u64 - } - - /// [`Self::deref`], charged for the copy. - fn take(&mut self, v: Val) -> R { - self.charge(self.cost(&v))?; - Ok(self.deref(v)) - } - - /// Objects become their values; lists of objects become lists of values. - fn deref(&self, v: Val) -> Val { - match v { - Val::Node(i) => self.node_value(i), - Val::List(items) => Val::List(items.into_iter().map(|x| self.deref(x)).collect()), - other => other, - } - } - - fn to_num(&self, v: &Val) -> f64 { - match v { - Val::Null => 0.0, - Val::Num(n) => *n, - Val::Str(s) => parse_num(s).unwrap_or(0.0), - Val::Node(i) => self.to_num(&self.node_value(*i)), - Val::List(items) => items.first().map_or(0.0, |x| self.to_num(x)), - _ => 0.0, - } - } - - fn to_str(&self, v: &Val) -> String { - match v { - Val::Null => String::new(), - Val::Num(n) => num_text(*n), - Val::Str(s) => s.clone(), - Val::Node(i) => self.to_str(&self.node_value(*i)), - Val::List(items) => items.first().map_or(String::new(), |x| self.to_str(x)), - Val::Host => "host".into(), - Val::Layout => "layout".into(), - Val::Event => "event".into(), - Val::Xfa => "xfa".into(), - Val::Im(..) => "instanceManager".into(), - Val::Sink => String::new(), - } - } - - fn truthy(&self, v: &Val) -> bool { - match v { - Val::Null => false, - Val::Num(n) => *n != 0.0 && !n.is_nan(), - Val::Str(s) => parse_num(s).is_some_and(|n| n != 0.0), - Val::Node(i) => { - let d = self.node_value(*i); - self.truthy(&d) - } - Val::List(items) => !items.is_empty(), - _ => true, - } - } - - fn is_null(&self, v: &Val) -> bool { - match v { - Val::Null => true, - Val::Node(i) => self.node(*i).is_none_or(|n| n.value.is_empty()), - Val::List(items) => items.is_empty(), - _ => false, - } - } - - // ── scopes - - fn lookup(&self, name: &str) -> Option { - self.scopes.iter().rev().find_map(|s| s.get(name).cloned()) - } - - fn set_var(&mut self, name: &str, v: Val) -> bool { - for s in self.scopes.iter_mut().rev() { - if let Some(slot) = s.get_mut(name) { - *slot = v; - return true; - } - } - false - } - - fn declare(&mut self, name: &str, v: Val) { - if let Some(s) = self.scopes.last_mut() { - s.insert(name.to_string(), v); - } - } - - // ── accessors - - /// The objects an identifier names from the current node: `$`, the special objects, an - /// instance manager (`_name`), a variable, or a form object by name (children first, then - /// the containing subforms). - fn ident(&self, name: &str) -> R { - if let Some(v) = self.lookup(name) { - return Ok(v); - } - let lower = name.to_ascii_lowercase(); - Ok(match lower.as_str() { - "$" | "this" => Val::Node(self.h.current), - "$host" => Val::Host, - "$layout" => Val::Layout, - "$event" => Val::Event, - "$form" | "$data" | "$dataset" | "$datasets" => Val::Node(0), - "$record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), - "$template" | "$xfa" => Val::Xfa, - "xfa" => Val::Xfa, - _ => { - if let Some(child) = name.strip_prefix('_') { - // The instance manager of `child`, looked for like a name. - let found = resolve_som(self.h, self.h.current, child); - let Some(&first) = found.first() else { return Err(format!("unknown instance manager _{child}")) }; - let parent = self.node(first).and_then(|n| n.parent).ok_or_else(|| format!("unknown instance manager _{child}"))?; - return Ok(Val::Im(parent, child.to_string())); - } - let found = resolve_som(self.h, self.h.current, name); - match found.as_slice() { - [] => return Err(format!("unknown name {name}")), - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|i| Val::Node(*i)).collect()), - } - } - }) - } - - /// Every object a value stands for. - fn nodes_of(&self, v: &Val) -> Vec { - match v { - Val::Node(i) => vec![*i], - Val::List(items) => items.iter().flat_map(|x| self.nodes_of(x)).collect(), - _ => Vec::new(), - } - } - - fn member(&mut self, base: Val, name: &str) -> R { - match base { - Val::Node(i) => self.node_member(i, name), - Val::List(items) => { - let mut out = Vec::new(); - let mut seen = HashSet::new(); - for it in items { - match self.member(it, name)? { - Val::List(more) => { - for m in more { - self.list_push(&mut out, &mut seen, m)?; - } - } - Val::Null => {} - v => self.list_push(&mut out, &mut seen, v)?, - } - } - Ok(match out.len() { - 0 => Val::Null, - 1 => out.remove(0), - _ => Val::List(out), - }) - } - Val::Host => Ok(match name.to_ascii_lowercase().as_str() { - "numpages" => Val::Num(self.h.doc.page_count as f64), - "currentpage" => Val::Num(self.h.doc.page as f64), - "name" => Val::Str("Acrobat".into()), - "apptype" => Val::Str("Exchange-Pro".into()), - "version" => Val::Str("11".into()), - "variation" => Val::Str("Full".into()), - "language" => Val::Str("en".into()), - "platform" => Val::Str(crate::platform().into()), - "title" => Val::Str(self.h.doc.file_name.clone()), - "calculationsenabled" | "validationsenabled" => Val::Num(1.0), - _ => Val::Sink, - }), - Val::Layout => Ok(Val::Sink), - Val::Event => Ok(match name.to_ascii_lowercase().as_str() { - "name" => Val::Str(self.event.activity.clone()), - "newtext" | "fulltext" => Val::Str(self.event.new_text.clone()), - "prevtext" => Val::Str(self.event.prev_text.clone()), - "change" => Val::Str(String::new()), - "target" => Val::Node(self.h.current), - "selstart" | "selend" | "cancelaction" | "reenter" | "shift" | "modifier" | "keydown" | "commitkey" => Val::Num(0.0), - _ => Val::Sink, - }), - Val::Xfa => Ok(match name.to_ascii_lowercase().as_str() { - "form" | "datasets" | "data" => Val::Node(0), - "record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), - "host" => Val::Host, - "layout" => Val::Layout, - "event" => Val::Event, - _ => Val::Sink, - }), - Val::Im(parent, child) => Ok(match name.to_ascii_lowercase().as_str() { - "count" => Val::Num(instances(self.h, parent, &child).len() as f64), - "min" => Val::Num(instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).map_or(0.0, |n| n.occur_min as f64)), - "max" => Val::Num( - instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).and_then(|n| n.occur_max).map_or(-1.0, |m| m as f64), - ), - "classname" => Val::Str("instanceManager".into()), - _ => Val::Sink, - }), - Val::Sink => Ok(Val::Sink), - Val::Null => Err(format!("no object to take {name} from")), - Val::Num(_) | Val::Str(_) => Err(format!("{} has no property {name}", self.to_str(&base))), - } - } - - /// `v` onto `out`: a node only once, never past [`MAX_LIST`]. - fn list_push(&mut self, out: &mut Vec, seen: &mut HashSet, v: Val) -> R<()> { - if let Val::Node(i) = v - && !seen.insert(i) - { - return Ok(()); - } - out.push(v); - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - self.charge(1) - } - - fn node_member(&mut self, i: usize, name: &str) -> R { - let Some(n) = self.node(i) else { return Ok(Val::Null) }; - Ok(match name { - "rawValue" | "value" | "formattedValue" => self.node_value(i), - "name" => Val::Str(n.name.clone()), - "somExpression" => Val::Str(n.som.clone()), - "className" => Val::Str(n.kind.class_name().into()), - "index" | "instanceIndex" => Val::Num(n.index as f64), - "presence" => Val::Str(n.presence.clone()), - "access" => Val::Str(n.access.clone()), - "isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }), - "mandatory" => Val::Str("disabled".into()), - "parent" => n.parent.map_or(Val::Null, Val::Node), - "nodes" => Val::List(n.children.iter().copied().filter(|c| self.node(*c).is_some()).map(Val::Node).collect()), - "all" => match n.parent { - Some(p) => Val::List(instances(self.h, p, &n.name).into_iter().map(Val::Node).collect()), - None => Val::Node(i), - }, - "instanceManager" => match n.parent { - Some(p) => Val::Im(p, n.name.clone()), - None => Val::Null, - }, - "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" - | "format" | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" - | "oneOfChild" | "locale" | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" - | "calculate" | "traversal" | "extras" | "desc" => Val::Sink, - other => { - if let Some(child) = other.strip_prefix('_') { - return Ok(if child_named(self.h, i, child).is_some() { Val::Im(i, child.to_string()) } else { Val::Null }); - } - let found = instances(self.h, i, other); - match found.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), - } - } - }) - } - - /// `base[n]` / `base[*]`: all the instances the last step named, then one or all. - fn index(&mut self, base: &Expr, idx: &Idx) -> R { - let all = self.all_of(base)?; - match idx { - Idx::All => Ok(Val::List(all.into_iter().map(Val::Node).collect())), - Idx::Abs(e) => { - let v = self.eval(e)?; - let n = self.to_num(&v); - if !n.is_finite() || n < 0.0 { - return Ok(Val::Null); - } - Ok(all.get(n as usize).map_or(Val::Null, |i| Val::Node(*i))) - } - Idx::Rel(k) => { - // From the instance the script runs in (`row[-1]` in a row is the row above), - // else from the first. - let here = all.iter().position(|&i| self.encloses(i, self.h.current)).unwrap_or(0); - let at = (here as i64).saturating_add(*k); - Ok(usize::try_from(at).ok().and_then(|at| all.get(at)).map_or(Val::Null, |i| Val::Node(*i))) - } - } - } - - /// Whether `node` is `inner` or one of its containers. - fn encloses(&self, node: usize, inner: usize) -> bool { - let mut at = Some(inner); - let mut guard = 0; - while let Some(i) = at - && guard < MAX_PARSE_DEPTH - { - if i == node { - return true; - } - at = self.h.nodes.get(i).and_then(|n| n.parent); - guard += 1; - } - false - } - - /// Every instance an accessor names (`Row` → all rows, not just the first), each once. - fn all_of(&mut self, e: &Expr) -> R> { - let mut out = Vec::new(); - let mut seen = HashSet::new(); - match e { - Expr::Ident(name) => { - if self.lookup(name).is_some() || name.starts_with('$') || name.eq_ignore_ascii_case("xfa") { - let v = self.eval(e)?; - return Ok(self.nodes_list(&v)); - } - // The name resolves to its first instance; `row[*]` means every sibling of it. - for first in resolve_som(self.h, self.h.current, name) { - match self.node(first).and_then(|n| n.parent.map(|p| (p, n.name.clone()))) { - Some((p, n)) => self.gather(&mut out, &mut seen, instances(self.h, p, &n))?, - None => self.gather(&mut out, &mut seen, [first])?, - } - } - } - Expr::Member(base, name) => { - let b = self.eval(base)?; - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, instances(self.h, i, name))?; - } - if out.is_empty() { - let v = self.member(b, name)?; - out = self.nodes_list(&v); - } - } - Expr::Descend(base, name) => { - let b = self.eval(base)?; - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; - } - } - other => { - let v = self.eval(other)?; - out = self.nodes_list(&v); - } - } - Ok(out) - } - - // ── expressions - - fn eval(&mut self, e: &Expr) -> R { - self.step()?; - self.depth += 1; - if self.depth > self.max_depth { - self.depth -= 1; - return Err(TOO_DEEP.into()); - } - let r = self.eval_inner(e); - self.depth -= 1; - r - } - - fn eval_inner(&mut self, e: &Expr) -> R { - Ok(match e { - Expr::Num(n) => Val::Num(*n), - Expr::Str(s) => { - self.charge((s.len() / 256) as u64)?; - Val::Str(s.clone()) - } - Expr::Null => Val::Null, - Expr::Ident(name) => { - let v = self.ident(name)?; - self.charge(self.cost(&v))?; - v - } - Expr::Member(base, name) => { - let b = self.eval(base)?; - self.member(b, name)? - } - Expr::Index(base, idx) => self.index(base, idx)?, - Expr::Descend(base, name) => { - let b = self.eval(base)?; - let mut out = Vec::new(); - let mut seen = HashSet::new(); - for i in self.nodes_list(&b) { - self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; - } - match out.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), - } - } - Expr::Call(callee, args) => self.call(callee, args)?, - Expr::Neg(x) => { - let v = self.eval(x)?; - if self.is_null(&v) { Val::Null } else { Val::Num(-self.to_num(&v)) } - } - Expr::Not(x) => { - let v = self.eval(x)?; - Val::Num(if self.truthy(&v) { 0.0 } else { 1.0 }) - } - Expr::Bin(..) => { - // The parser leans left (`a + b + c` is `(a + b) + c`), so a chain is walked - // from its leftmost operand and folded without recursing per operator. - let mut links = Vec::new(); - let mut leftmost = e; - while let Expr::Bin(op, l, r) = leftmost { - links.push((*op, r.as_ref())); - leftmost = l; - } - let mut acc = self.eval(leftmost)?; - for (op, r) in links.into_iter().rev() { - self.step()?; - acc = match op { - "&" => { - if !self.truthy(&acc) { - // Not evaluated: the result is settled. - Val::Num(0.0) - } else { - let b = self.eval(r)?; - Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) - } - } - "|" => { - if self.truthy(&acc) { - Val::Num(1.0) - } else { - let b = self.eval(r)?; - Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) - } - } - _ => { - let b = self.eval(r)?; - // Comparing (and dereferencing) long strings is work too. - self.charge(self.cost(&acc).saturating_add(self.cost(&b)))?; - self.binary(op, acc, b)? - } - }; - } - acc - } - }) - } - - fn binary(&self, op: &str, a: Val, b: Val) -> R { - let (a, b) = (self.deref(a), self.deref(b)); - // Null with an arithmetic operator is null only when both sides are null. - match op { - "+" | "-" | "*" | "/" => { - if self.is_null(&a) && self.is_null(&b) { - return Ok(Val::Null); - } - let (x, y) = (self.to_num(&a), self.to_num(&b)); - let v = match op { - "+" => x + y, - "-" => x - y, - "*" => x * y, - _ => { - if y == 0.0 { - return Err("division by zero".into()); - } - x / y - } - }; - Ok(Val::Num(v)) - } - _ => { - let ord = match (&a, &b) { - (Val::Null, Val::Null) => Some(std::cmp::Ordering::Equal), - (Val::Null, _) | (_, Val::Null) => None, - (Val::Str(s), Val::Str(t)) if !(is_numeric_text(s) && is_numeric_text(t)) => Some(s.cmp(t)), - (Val::Str(s), Val::Num(_)) if !is_numeric_text(s) => Some(s.as_str().cmp(num_text(self.to_num(&b)).as_str())), - (Val::Num(_), Val::Str(t)) if !is_numeric_text(t) => Some(num_text(self.to_num(&a)).as_str().cmp(t.as_str())), - _ => self.to_num(&a).partial_cmp(&self.to_num(&b)), - }; - let r = match (op, ord) { - ("==", Some(o)) => o.is_eq(), - ("<>", Some(o)) => !o.is_eq(), - ("<>", None) => true, - ("<", Some(o)) => o.is_lt(), - ("<=", Some(o)) => o.is_le(), - (">", Some(o)) => o.is_gt(), - (">=", Some(o)) => o.is_ge(), - _ => false, - }; - Ok(Val::Num(if r { 1.0 } else { 0.0 })) - } - } - } - - // ── calls - - fn call(&mut self, callee: &Expr, args: &[Expr]) -> R { - match callee { - Expr::Ident(name) => { - if let Some(f) = self.funcs.get(&name.to_ascii_lowercase()).cloned() { - let mut vals = Vec::new(); - for a in args { - let v = self.eval(a)?; - vals.push(self.take(v)?); - } - return self.call_user(&f, vals); - } - self.builtin(name, args) - } - Expr::Member(base, method) => { - let b = self.eval(base)?; - self.method(b, method, args) - } - _ => Err("not a function".into()), - } - } - - fn call_user(&mut self, f: &Func, vals: Vec) -> R { - if self.scopes.len() > self.max_depth { - return Err(CALLS_TOO_DEEP.into()); - } - let mut scope = HashMap::new(); - for (i, p) in f.params.iter().enumerate() { - scope.insert(p.clone(), vals.get(i).cloned().unwrap_or(Val::Null)); - } - self.scopes.push(scope); - let r = self.block(&f.body); - self.scopes.pop(); - match r? { - (Flow::Return(v), _) => Ok(v), - (Flow::Exit, last) => { - self.exited = true; - Ok(last) - } - (_, last) => Ok(last), - } - } - - fn arg(&mut self, args: &[Expr], i: usize) -> R { - match args.get(i) { - Some(e) => { - let v = self.eval(e)?; - self.take(v) - } - None => Ok(Val::Null), - } - } - - fn arg_num(&mut self, args: &[Expr], i: usize) -> R { - let v = self.arg(args, i)?; - Ok(self.to_num(&v)) - } - - fn arg_str(&mut self, args: &[Expr], i: usize) -> R { - let v = self.arg(args, i)?; - Ok(self.to_str(&v)) - } - - /// Every value the arguments hold, lists flattened, nulls left out. - fn arg_values(&mut self, args: &[Expr]) -> R> { - self.arg_values_keeping(args, false) - } - - /// [`Self::arg_values`]; `objects` keeps form objects (fields, subforms) as themselves, so - /// `Count(row[*])` counts rows rather than filled-in values. - fn arg_values_keeping(&mut self, args: &[Expr], objects: bool) -> R> { - let mut out = Vec::new(); - for a in args { - let v = self.eval(a)?; - let v = if objects { v } else { self.take(v)? }; - match v { - Val::List(items) => out.extend(items.into_iter().filter(|x| !matches!(x, Val::Null))), - Val::Null => {} - other => out.push(other), - } - if out.len() > MAX_LIST { - return Err(LIST_TOO_LONG.into()); - } - } - Ok(out) - } - - fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R { - let m = method.to_ascii_lowercase(); - match base { - Val::Host => match m.as_str() { - "messagebox" => { - let t = self.arg_str(args, 0)?; - self.h.push(XfaEffect::MessageBox(t)); - Ok(Val::Num(1.0)) - } - "resetdata" => { - let list = self.arg_str(args, 0)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - self.h.push(XfaEffect::ResetData(names)); - Ok(Val::Null) - } - "print" => { - self.h.push(XfaEffect::Print); - Ok(Val::Null) - } - "beep" => { - self.h.push(XfaEffect::Beep); - Ok(Val::Null) - } - "gotourl" => { - let u = self.arg_str(args, 0)?; - self.h.push(XfaEffect::LaunchUrl(u)); - Ok(Val::Null) - } - "setfocus" => { - let target = match args.first() { - Some(e) => { - let raw = self.eval(e)?; - match self.nodes_of(&raw).first() { - Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(), - None => { - let s = self.to_str(&raw); - let found = resolve_som(self.h, self.h.current, &s); - found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s) - } - } - } - None => String::new(), - }; - self.h.push(XfaEffect::SetFocus(target)); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Layout => match m.as_str() { - "page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })), - "pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)), - "relayout" | "relayoutpagearea" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Num(0.0)), - }, - Val::Xfa => match m.as_str() { - "resolvenode" | "resolvenodes" => { - let s = self.arg_str(args, 0)?; - let found = resolve_som(self.h, self.h.current, &s); - Ok(self.set_val(found, m == "resolvenodes")) - } - "recalculate" => { - self.h.push(XfaEffect::Recalculate); - Ok(Val::Null) - } - "remerge" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Node(i) => match m.as_str() { - "resolvenode" | "resolvenodes" => { - let s = self.arg_str(args, 0)?; - let found = resolve_som(self.h, i, &s); - Ok(self.set_val(found, m == "resolvenodes")) - } - "recalculate" => { - self.h.push(XfaEffect::Recalculate); - Ok(Val::Null) - } - "remerge" => { - self.h.push(XfaEffect::Relayout); - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Im(parent, name) => match m.as_str() { - "addinstance" | "insertinstance" => Ok(self.h.add_instance(parent, &name).map_or(Val::Null, Val::Node)), - "removeinstance" => { - let i = self.arg_num(args, 0)?; - if i.is_finite() && i >= 0.0 { - self.h.remove_instance(parent, &name, i as usize); - } - Ok(Val::Null) - } - "setinstances" => { - let want = self.arg_num(args, 0)?; - if want.is_finite() && want >= 0.0 { - self.h.set_instances(parent, &name, want as usize); - } - Ok(Val::Null) - } - _ => Ok(Val::Null), - }, - Val::Event | Val::Sink | Val::List(_) => Ok(Val::Null), - Val::Null => Err(format!("no object to call {method} on")), - Val::Num(_) | Val::Str(_) => Err(format!("{} has no method {method}", self.to_str(&base))), - } - } - - fn set_val(&self, found: Vec, many: bool) -> Val { - if many { - return Val::List(found.into_iter().map(Val::Node).collect()); - } - match found.as_slice() { - [] => Val::Null, - [one] => Val::Node(*one), - all => Val::List(all.iter().map(|i| Val::Node(*i)).collect()), - } - } - - // ── assignment - - fn assign(&mut self, target: &Expr, v: Val) -> R<()> { - let v = self.deref(v); - match target { - Expr::Ident(name) => { - if self.set_var(name, v.clone()) { - return Ok(()); - } - let t = self.ident(name)?; - self.assign_to(t, "rawValue", v) - } - Expr::Member(base, prop) => { - let b = self.eval(base)?; - self.assign_to(b, prop, v) - } - Expr::Index(..) | Expr::Descend(..) => { - let t = self.eval(target)?; - self.assign_to(t, "rawValue", v) - } - _ => Err("cannot assign to that".into()), - } - } - - fn assign_to(&mut self, target: Val, prop: &str, v: Val) -> R<()> { - match target { - Val::Node(i) => { - let Some(n) = self.node(i) else { return Ok(()) }; - let som = n.som.clone(); - match prop { - "rawValue" | "value" | "formattedValue" => { - let text = self.to_str(&v); - if !self.h.set_value(i, text) { - return Err(format!("{} is not a field", if som.is_empty() { "the form" } else { som.as_str() })); - } - } - "presence" => { - let p = self.to_str(&v); - if matches!(p.as_str(), "visible" | "invisible" | "hidden" | "inactive") { - if let Some(n) = self.h.nodes.get_mut(i) { - n.presence = p.clone(); - } - self.h.push(XfaEffect::SetPresence { som, presence: p }); - } - } - "access" => { - let a = self.to_str(&v); - if matches!(a.as_str(), "open" | "readOnly" | "protected" | "nonInteractive") { - if let Some(n) = self.h.nodes.get_mut(i) { - n.access = a.clone(); - } - self.h.push(XfaEffect::SetAccess { som, access: a }); - } - } - other => { - // `page1.qty = 7`: the child object named by the last step takes the value. - if let Some(child) = other.strip_prefix('_') { - if child_named(self.h, i, child).is_some() { - return self.assign_to(Val::Im(i, child.to_string()), "count", v); - } - return Ok(()); - } - let kids = instances(self.h, i, other); - if kids.is_empty() { - return Err(format!("{som} has no {other} to assign to")); - } - for k in kids { - self.assign_to(Val::Node(k), "rawValue", v.clone())?; - } - } - } - Ok(()) - } - Val::List(items) => { - for it in items { - self.assign_to(it, prop, v.clone())?; - } - Ok(()) - } - Val::Im(parent, name) if prop.eq_ignore_ascii_case("count") => { - let want = self.to_num(&v); - if want.is_finite() && want >= 0.0 { - self.h.set_instances(parent, &name, want as usize); - } - Ok(()) - } - _ => Ok(()), - } - } - - // ── statements - - /// Run a block; returns how it ended and the value of its last expression statement. - fn block(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { - // Nesting of statements counts like nesting of expressions: both are stack. - self.depth += 1; - if self.depth > self.max_depth { - self.depth -= 1; - return Err(TOO_DEEP.into()); - } - let r = self.block_inner(body); - self.depth -= 1; - r - } - - fn block_inner(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { - let mut last = Val::Null; - for s in body { - self.step()?; - match self.exec(s)? { - (Flow::Next, v) => { - if let Some(v) = v { - last = v; - } - if self.exited { - return Ok((Flow::Exit, last)); - } - } - (flow, _) => return Ok((flow, last)), - } - } - Ok((Flow::Next, last)) - } - - fn exec(&mut self, s: &Stmt) -> R<(Flow, Option)> { - match s { - Stmt::Var(name, init) => { - let v = match init { - Some(e) => { - let v = self.eval(e)?; - self.take(v)? - } - None => Val::Null, - }; - self.declare(name, v.clone()); - Ok((Flow::Next, Some(v))) - } - Stmt::Assign(target, e) => { - let v = self.eval(e)?; - let v = self.take(v)?; - self.assign(target, v.clone())?; - Ok((Flow::Next, Some(v))) - } - Stmt::Expr(e) => { - let v = self.eval(e)?; - let v = self.take(v)?; - Ok((Flow::Next, Some(v))) - } - Stmt::If(arms, otherwise) => { - for (cond, body) in arms { - let c = self.eval(cond)?; - if self.truthy(&c) { - let (f, v) = self.block(body)?; - return Ok((f, Some(v))); - } - } - if let Some(body) = otherwise { - let (f, v) = self.block(body)?; - return Ok((f, Some(v))); - } - Ok((Flow::Next, None)) - } - Stmt::While(cond, body) => { - loop { - let c = self.eval(cond)?; - if !self.truthy(&c) { - break; - } - self.iterate()?; - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - } - Ok((Flow::Next, None)) - } - Stmt::For { var, from, to, up, step, body } => { - let start = { - let v = self.eval(from)?; - self.to_num(&v) - }; - let end = { - let v = self.eval(to)?; - self.to_num(&v) - }; - let by = match step { - Some(e) => { - let v = self.eval(e)?; - self.to_num(&v).abs() - } - None => 1.0, - }; - if by.is_nan() || by <= 0.0 || !start.is_finite() || !end.is_finite() { - return Err("bad for loop bounds".into()); - } - self.declare(var, Val::Num(start)); - let mut i = start; - while if *up { i <= end } else { i >= end } { - self.iterate()?; - self.set_var(var, Val::Num(i)); - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - i = if *up { i + by } else { i - by }; - } - Ok((Flow::Next, None)) - } - Stmt::Foreach { var, list, body } => { - let items = self.arg_values_keeping(list, true)?; - self.declare(var, Val::Null); - for it in items { - self.iterate()?; - self.set_var(var, it); - match self.block(body)? { - (Flow::Break, _) => break, - (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), - (Flow::Exit, _) => return Ok((Flow::Exit, None)), - _ => {} - } - } - Ok((Flow::Next, None)) - } - Stmt::Func { name, f } => { - self.funcs.insert(name.to_ascii_lowercase(), Rc::clone(f)); - Ok((Flow::Next, None)) - } - Stmt::Return(e) => { - let v = match e { - Some(e) => { - let v = self.eval(e)?; - self.take(v)? - } - None => Val::Null, - }; - Ok((Flow::Return(v), None)) - } - Stmt::Break => Ok((Flow::Break, None)), - Stmt::Continue => Ok((Flow::Continue, None)), - Stmt::Exit => Ok((Flow::Exit, None)), - Stmt::Throw(e) => { - let v = self.eval(e)?; - self.charge(self.cost(&v))?; - // The thrown value becomes the error message the caller keeps: cut it as - // messages are. - Err(clip(self.to_str(&v))) - } - } - } - - fn iterate(&mut self) -> R<()> { - if self.loops_left == 0 { - return Err(LOOPED_TOO_MUCH.into()); - } - self.loops_left -= 1; - Ok(()) - } -} - -// ── built-in functions ────────────────────────────────────────────────────────────────────── - -/// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. -fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - // Howard Hinnant's algorithm, epoch 1970-01-01, shifted to FormCalc's 1900-01-01 = 1. - let y = if m <= 2 { y - 1 } else { y }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = y - era * 400; - let mp = (i64::from(m) + 9) % 12; - let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let unix = era * 146_097 + doe - 719_468; - unix + 25_568 -} - -fn civil_from_days(n: i64) -> (i64, u32, u32) { - let z = n - 25_568 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - (if m <= 2 { y + 1 } else { y }, m, d) -} - -/// Day numbers (and years) beyond this are refused rather than computed. -const MAX_DAYS: f64 = 1e8; - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; - -/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). -fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { - let pic: Vec = picture.chars().collect(); - let txt: Vec = text.trim().chars().collect(); - let (mut y, mut m, mut d) = (None, None, None); - let (mut i, mut j) = (0, 0); - while i < pic.len() { - let c = pic[i].to_ascii_uppercase(); - if c == 'Y' || c == 'M' || c == 'D' { - let mut run = 1; - while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { - run += 1; - } - i += run; - if c == 'M' && run >= 3 { - // Month name. - let rest: String = txt.get(j..).unwrap_or(&[]).iter().collect(); - let lower = rest.to_ascii_lowercase(); - let found = MONTHS - .iter() - .position(|name| lower.starts_with(&name.to_ascii_lowercase()) || lower.starts_with(&name[..3].to_ascii_lowercase())); - let k = found?; - m = Some(k as u32 + 1); - let full = MONTHS[k].to_ascii_lowercase(); - j += if lower.starts_with(&full) { full.chars().count() } else { 3 }; - continue; - } - let width = if run == 1 { 2 } else { run }; - let mut digits = String::new(); - while j < txt.len() && txt[j].is_ascii_digit() && digits.len() < width { - digits.push(txt[j]); - j += 1; - } - if digits.is_empty() { - return None; - } - let v: i64 = digits.parse().ok()?; - match c { - 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), - 'M' => m = Some(v as u32), - _ => d = Some(v as u32), - } - } else { - if txt.get(j) != Some(&pic[i]) { - return None; - } - i += 1; - j += 1; - } - } - let (y, m, d) = (y?, m.unwrap_or(1), d.unwrap_or(1)); - ((-200_000..=200_000).contains(&y) && (1..=12).contains(&m) && (1..=days_in(y, m)).contains(&d)).then_some((y, m, d)) -} - -/// Number of days in month `m` (1-12) of year `y`, leap years included. -fn days_in(y: i64, m: u32) -> u32 { - match m { - 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - } -} - -const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { - let pic: Vec = picture.chars().collect(); - let mut out = String::new(); - let mut i = 0; - while i < pic.len() { - let c = pic[i].to_ascii_uppercase(); - if c == 'Y' || c == 'M' || c == 'D' { - let mut run = 1; - while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { - run += 1; - } - i += run; - match (c, run) { - ('Y', 1 | 2) => out.push_str(&format!("{:02}", y.rem_euclid(100))), - ('Y', _) => out.push_str(&format!("{y:04}")), - ('M', 1) => out.push_str(&m.to_string()), - ('M', 2) => out.push_str(&format!("{m:02}")), - ('M', 3) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).map_or("", |n| &n[..3])), - ('M', _) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).copied().unwrap_or("")), - ('D', 1) => out.push_str(&d.to_string()), - (_, _) => out.push_str(&format!("{d:02}")), - } - } else if pic[i] == 'E' { - let mut run = 1; - while i + run < pic.len() && pic[i + run] == 'E' { - run += 1; - } - i += run; - // Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday. - let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize; - match run { - 1 | 2 => out.push_str(&(weekday + 1).to_string()), - 3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])), - _ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")), - } - } else { - out.push(pic[i]); - i += 1; - } - } - out -} - -const ONES: [&str; 20] = [ - "Zero", - "One", - "Two", - "Three", - "Four", - "Five", - "Six", - "Seven", - "Eight", - "Nine", - "Ten", - "Eleven", - "Twelve", - "Thirteen", - "Fourteen", - "Fifteen", - "Sixteen", - "Seventeen", - "Eighteen", - "Nineteen", -]; -const TENS: [&str; 10] = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]; - -fn words_below_1000(n: u64) -> String { - let mut parts = Vec::new(); - if n >= 100 { - parts.push(format!("{} Hundred", ONES[(n / 100) as usize])); - } - let r = n % 100; - if r >= 20 { - parts.push(if r.is_multiple_of(10) { - TENS[(r / 10) as usize].to_string() - } else { - format!("{}-{}", TENS[(r / 10) as usize], ONES[(r % 10) as usize]) - }); - } else if r > 0 || n == 0 { - parts.push(ONES[r as usize].to_string()); - } - parts.join(" ") -} - -/// `WordNum`: an integer in English words (up to the billions). -fn word_num(n: f64, cents: bool) -> String { - if !n.is_finite() || !(0.0..1e12).contains(&n) { - return String::new(); - } - let cents_total = (n * 100.0).round() as u64; - let whole = if cents { cents_total / 100 } else { n.trunc() as u64 }; - let mut parts = Vec::new(); - let groups = [(1_000_000_000u64, "Billion"), (1_000_000, "Million"), (1_000, "Thousand")]; - let mut rest = whole; - for (unit, name) in groups { - if rest >= unit { - parts.push(format!("{} {name}", words_below_1000(rest / unit))); - rest %= unit; - } - } - if rest > 0 || parts.is_empty() { - parts.push(words_below_1000(rest)); - } - let mut out = parts.join(" "); - if cents { - let c = cents_total % 100; - let dollars = if whole == 1 { "Dollar" } else { "Dollars" }; - let cent = if c == 1 { "Cent" } else { "Cents" }; - out.push_str(&format!(" {dollars} And {c:02} {cent}")); - } - out -} - -impl Interp<'_> { - /// A built-in function: its result is never a string past [`MAX_STRING`], and work on - /// long strings is charged to the step budget. - fn builtin(&mut self, name: &str, args: &[Expr]) -> R { - let v = self.builtin_inner(name, args)?; - if let Val::Str(s) = &v { - if s.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - self.charge((s.len() / 256) as u64)?; - } - Ok(v) - } - - fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { - let lower = name.to_ascii_lowercase(); - let n = |v: f64| Ok(Val::Num(v)); - Ok(match lower.as_str() { - // ── arithmetic - "abs" => return n(self.arg_num(args, 0)?.abs()), - "ceil" => return n(self.arg_num(args, 0)?.ceil()), - "floor" => return n(self.arg_num(args, 0)?.floor()), - "mod" => { - let (a, b) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?); - if b == 0.0 { - return Err("division by zero".into()); - } - Val::Num(a - b * (a / b).trunc()) - } - "round" => { - let a = self.arg_num(args, 0)?; - let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 }; - let k = 10f64.powi(places); - Val::Num((a * k).round() / k) - } - "count" => { - // Non-null values and objects: an empty field doesn't count, a subform does. - let vals = self.arg_values_keeping(args, true)?; - let counts = |v: &Val| match v { - Val::Node(i) => self.node(*i).is_some_and(|n| !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) || !n.value.is_empty()), - other => !self.is_null(other), - }; - Val::Num(vals.iter().filter(|v| counts(v)).count() as f64) - } - "sum" | "avg" | "max" | "min" => { - let vals = self.arg_values(args)?; - let nums: Vec = vals.iter().map(|v| self.to_num(v)).collect(); - match lower.as_str() { - "sum" => Val::Num(nums.iter().sum()), - "avg" => { - if nums.is_empty() { - Val::Null - } else { - Val::Num(nums.iter().sum::() / nums.len() as f64) - } - } - "max" => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.max(x)))).map_or(Val::Null, Val::Num), - _ => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.min(x)))).map_or(Val::Null, Val::Num), - } - } - // ── logical - "choose" => { - let i = self.arg_num(args, 0)?; - if !i.is_finite() || i < 1.0 { - return Ok(Val::Null); - } - self.arg(args, i as usize)? - } - "exists" => { - let Some(e) = args.first() else { return Ok(Val::Num(0.0)) }; - // A name that resolves to nothing is the answer "no"; a limit the lookup ran - // into (time, steps, depth, size) still stops the script. - match self.all_of(e) { - Ok(v) => Val::Num(if v.is_empty() { 0.0 } else { 1.0 }), - Err(e) if is_limit(&e) => return Err(e), - Err(_) => Val::Num(0.0), - } - } - "hasvalue" => { - let v = self.arg(args, 0)?; - Val::Num(if self.is_null(&v) || matches!(&v, Val::Str(s) if s.trim().is_empty()) { 0.0 } else { 1.0 }) - } - "oneof" => { - let v = self.arg(args, 0)?; - let rest = self.arg_values(args.get(1..).unwrap_or_default())?; - let mut hit = false; - for x in &rest { - // Each comparison copies and compares `v`: charged per item. - self.charge(self.cost(&v).saturating_add(self.cost(x)).saturating_add(1))?; - if self.binary("==", v.clone(), x.clone()).is_ok_and(|r| self.truthy(&r)) { - hit = true; - break; - } - } - Val::Num(if hit { 1.0 } else { 0.0 }) - } - "within" => { - let v = self.arg(args, 0)?; - let (lo, hi) = (self.arg(args, 1)?, self.arg(args, 2)?); - let ok = self.binary(">=", v.clone(), lo).is_ok_and(|r| self.truthy(&r)) && self.binary("<=", v, hi).is_ok_and(|r| self.truthy(&r)); - Val::Num(if ok { 1.0 } else { 0.0 }) - } - // ── strings - "at" => { - let (s, sub) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); - Val::Num(if sub.is_empty() { 0.0 } else { s.find(&sub).map_or(0.0, |b| s[..b].chars().count() as f64 + 1.0) }) - } - "concat" => { - let mut out = String::new(); - for i in 0..args.len() { - out.push_str(&self.arg_str(args, i)?); - if out.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - } - Val::Str(out) - } - "left" => { - let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); - Val::Str(s.chars().take(k).collect()) - } - "right" => { - let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); - let len = s.chars().count(); - Val::Str(s.chars().skip(len.saturating_sub(k)).collect()) - } - "len" => Val::Num(self.arg_str(args, 0)?.chars().count() as f64), - "lower" => Val::Str(self.arg_str(args, 0)?.to_lowercase()), - "upper" => Val::Str(self.arg_str(args, 0)?.to_uppercase()), - "ltrim" => Val::Str(self.arg_str(args, 0)?.trim_start().to_string()), - "rtrim" => Val::Str(self.arg_str(args, 0)?.trim_end().to_string()), - "space" => { - let k = self.arg_num(args, 0)?.clamp(0.0, MAX_STRING as f64) as usize; - Val::Str(" ".repeat(k)) - } - "str" => { - let v = self.arg_num(args, 0)?; - let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; - let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; - let text = fixed(v, prec); - Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) - } - "substr" => { - let s = self.arg_str(args, 0)?; - let start = self.arg_num(args, 1)?.max(1.0) as usize; - let len = self.arg_num(args, 2)?.max(0.0) as usize; - Val::Str(s.chars().skip(start - 1).take(len).collect()) - } - "stuff" => { - let s = self.arg_str(args, 0)?; - let start = self.arg_num(args, 1)?.max(1.0) as usize; - let del = self.arg_num(args, 2)?.max(0.0) as usize; - let ins = if args.len() > 3 { self.arg_str(args, 3)? } else { String::new() }; - let chars: Vec = s.chars().collect(); - let a = start.saturating_sub(1).min(chars.len()); - let b = a.saturating_add(del).min(chars.len()); - let out: String = chars[..a].iter().chain(ins.chars().collect::>().iter()).chain(chars[b..].iter()).collect(); - if out.len() > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - Val::Str(out) - } - "replace" => { - let (s, old) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); - let new = if args.len() > 2 { self.arg_str(args, 2)? } else { String::new() }; - if old.is_empty() { - return Ok(Val::Str(s)); - } - let grown = s.matches(old.as_str()).count().saturating_mul(new.len()).saturating_add(s.len()); - if grown > MAX_STRING { - return Err(STRING_TOO_LONG.into()); - } - Val::Str(s.replace(&old, &new)) - } - "wordnum" => { - let v = self.arg_num(args, 0)?; - let kind = if args.len() > 1 { self.arg_num(args, 1)? } else { 0.0 }; - Val::Str(word_num(v, kind == 2.0)) - } - "uuid" => Val::Str(format!("{:032x}", (self.steps as u128).wrapping_mul(0x9E37_79B9_7F4A_7C15_F39C_C060_5CED_C835))), - "encode" | "decode" => { - let s = self.arg_str(args, 0)?; - let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; - Val::Str(match (lower.as_str(), kind.as_str()) { - ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), - ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), - ("encode", _) => s - .bytes() - .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) - .collect(), - (_, _) => { - let mut out = Vec::new(); - let b = s.as_bytes(); - let mut i = 0; - while i < b.len() { - if b[i] == b'%' - && let Some(h) = - b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) - { - out.push(h); - i += 3; - } else { - out.push(b[i]); - i += 1; - } - } - String::from_utf8_lossy(&out).into_owned() - } - }) - } - "format" => { - // Number pictures: `9` digit, `z` optional digit, `,` separator, `.` point, `$`. - let pic = self.arg_str(args, 0)?; - let v = self.arg(args, 1)?; - if pic.to_ascii_uppercase().contains("YY") || pic.to_ascii_uppercase().contains("DD") { - let text = self.to_str(&v); - let days = parse_num(&text).filter(|d| is_numeric_text(&text) && d.abs() < MAX_DAYS).map(|d| d as i64); - return Ok(match days { - Some(d) => { - let (y, m, dd) = civil_from_days(d); - Val::Str(format_date(y, m, dd, &pic)) - } - None => parse_date(&text, "YYYY-MM-DD").map_or(Val::Str(text), |(y, m, d)| Val::Str(format_date(y, m, d, &pic))), - }); - } - let num = self.to_num(&v); - let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); - let grouped = pic.contains(','); - let mut text = fixed(num.abs(), decimals); - if grouped { - let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); - let mut g = String::new(); - for (k, c) in int.chars().enumerate() { - if k > 0 && (int.len() - k) % 3 == 0 { - g.push(','); - } - g.push(c); - } - text = if frac.is_empty() { g } else { format!("{g}.{frac}") }; - } - if pic.contains('$') { - text = format!("${text}"); - } - if num < 0.0 { - text = format!("-{text}"); - } - Val::Str(text) - } - "parse" => { - let pic = self.arg_str(args, 0)?; - let s = self.arg_str(args, 1)?; - if pic.to_ascii_uppercase().contains("YY") { - // The canonical form of a date is ISO. - return Ok(parse_date(&s, &pic).map_or(Val::Null, |(y, m, d)| Val::Str(format_date(y, m, d, "YYYY-MM-DD")))); - } - let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); - parse_num(&cleaned).map_or(Val::Null, Val::Num) - } - // ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight) - "date" => { - #[cfg(not(target_arch = "wasm32"))] - { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); - Val::Num((secs.div_euclid(86_400) + 25_568) as f64) - } - #[cfg(target_arch = "wasm32")] - { - Val::Num(25_568.0) - } - } - "time" => { - #[cfg(not(target_arch = "wasm32"))] - { - let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); - Val::Num(ms.rem_euclid(86_400_000) as f64) - } - #[cfg(target_arch = "wasm32")] - { - Val::Num(0.0) - } - } - "date2num" | "isodate2num" => { - let s = self.arg_str(args, 0)?; - let pic = if lower == "isodate2num" || args.len() < 2 { "YYYY-MM-DD".to_string() } else { self.arg_str(args, 1)? }; - let parsed = parse_date(&s, &pic).or_else(|| parse_date(&s, "YYYYMMDD")).or_else(|| parse_date(&s, "YYYY-MM-DD")); - parsed.map_or(Val::Num(0.0), |(y, m, d)| Val::Num(days_from_civil(y, m, d) as f64)) - } - "num2date" => { - let days = self.arg_num(args, 0)?; - let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; - if !days.is_finite() || days.abs() >= MAX_DAYS { - return Ok(Val::Null); - } - let (y, m, d) = civil_from_days(days as i64); - Val::Str(format_date(y, m, d, &pic)) - } - "datefmt" | "localdatefmt" => { - let style = if args.is_empty() { 0.0 } else { self.arg_num(args, 0)? }; - Val::Str( - match style as i32 { - 1 => "M/D/YY", - 2 => "MMM D, YYYY", - 3 => "MMMM D, YYYY", - 4 => "EEEE, MMMM D, YYYY", - _ => "MMM D, YYYY", - } - .into(), - ) - } - "timefmt" | "localtimefmt" => Val::Str("HH:MM:SS".into()), - "num2time" | "num2gmtime" => { - let ms = self.arg_num(args, 0)?; - if !ms.is_finite() || ms < 0.0 { - return Ok(Val::Null); - } - let s = (ms / 1000.0) as u64; - Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) - } - "time2num" | "isotime2num" => { - let s = self.arg_str(args, 0)?; - let parts: Vec = s.split(':').filter_map(|p| p.trim().parse::().ok()).map(|v| v.min(1_000_000)).collect(); - let (h, m, sec) = (parts.first().copied().unwrap_or(0), parts.get(1).copied().unwrap_or(0), parts.get(2).copied().unwrap_or(0)); - Val::Num(((h * 3600 + m * 60 + sec) * 1000) as f64) - } - // ── financial - "fv" => { - let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) - } - "pv" => { - let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - Val::Num(if r == 0.0 { p * k } else { p * ((1.0 - (1.0 + r).powf(-k)) / r) }) - } - "pmt" => { - let (pv, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if k <= 0.0 { - return Err("Pmt needs a positive number of periods".into()); - } - Val::Num(if r == 0.0 { pv / k } else { pv * r / (1.0 - (1.0 + r).powf(-k)) }) - } - "term" => { - let (p, r, fv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if p <= 0.0 || r <= 0.0 || fv <= 0.0 { - return Err("Term needs positive arguments".into()); - } - Val::Num(((fv * r / p) + 1.0).ln() / (1.0 + r).ln()) - } - "cterm" => { - let (r, fv, pv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if r <= 0.0 || fv <= 0.0 || pv <= 0.0 { - return Err("CTerm needs positive arguments".into()); - } - Val::Num((fv / pv).ln() / (1.0 + r).ln()) - } - "rate" => { - let (fv, pv, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if fv <= 0.0 || pv <= 0.0 || k <= 0.0 { - return Err("Rate needs positive arguments".into()); - } - Val::Num((fv / pv).powf(1.0 / k) - 1.0) - } - "npv" => { - let r = self.arg_num(args, 0)?; - let flows = self.arg_values(args.get(1..).unwrap_or_default())?; - Val::Num(flows.iter().enumerate().map(|(i, v)| self.to_num(v) / (1.0 + r).powi(i as i32 + 1)).sum()) - } - "ipmt" | "ppmt" => { - let (pv, r, pmt, first, count) = ( - self.arg_num(args, 0)?, - self.arg_num(args, 1)?, - self.arg_num(args, 2)?, - self.arg_num(args, 3)?.max(1.0), - self.arg_num(args, 4)?.max(1.0), - ); - let (mut balance, mut interest, mut principal) = (pv, 0.0, 0.0); - for period in 1..=((first + count - 1.0).min(10_000.0) as u32) { - self.step()?; - let i = balance * r; - let p = (pmt - i).min(balance); - if period >= first as u32 { - interest += i; - principal += p; - } - balance -= p; - if balance <= 0.0 { - break; - } - } - Val::Num(if lower == "ipmt" { interest } else { principal }) - } - "apr" => { - let (principal, payment, periods) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); - if principal <= 0.0 || payment <= 0.0 || periods <= 0.0 { - return Err("Apr needs positive arguments".into()); - } - // Bisection on the monthly rate. - let (mut lo, mut hi) = (0.0f64, 1.0f64); - for _ in 0..200 { - let mid = (lo + hi) / 2.0; - let pmt = if mid == 0.0 { principal / periods } else { principal * mid / (1.0 - (1.0 + mid).powf(-periods)) }; - if pmt > payment { - hi = mid; - } else { - lo = mid; - } - } - Val::Num((lo + hi) / 2.0 * 12.0) - } - // ── units and references - "unittype" => { - let s = self.arg_str(args, 0)?; - let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); - Val::Str( - match unit.trim().to_ascii_lowercase().as_str() { - "in" | "inch" | "inches" => "in", - "mm" | "millimeter" | "millimeters" => "mm", - "cm" | "centimeter" | "centimeters" => "cm", - "pt" | "point" | "points" => "pt", - "pc" | "pica" => "pc", - _ => "in", - } - .into(), - ) - } - "unitvalue" => { - let s = self.arg_str(args, 0)?; - let to = if args.len() > 1 { self.arg_str(args, 1)? } else { String::new() }; - let v = parse_num(&s).unwrap_or(0.0); - let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); - let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() { - "mm" => 25.4, - "cm" => 2.54, - "pt" => 72.0, - "pc" => 6.0, - _ => 1.0, - }; - let target = if to.trim().is_empty() { unit.clone() } else { to }; - Val::Num(v / per_in(&unit) * per_in(&target)) - } - "ref" => { - let Some(e) = args.first() else { return Ok(Val::Null) }; - self.eval(e)? - } - "eval" => { - let src = self.arg_str(args, 0)?; - self.charge((src.len() / 16) as u64)?; - let body = parse(&src)?; - let (_, v) = self.block(&body)?; - v - } - "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), - _ => return Err(format!("unknown function {name}")), - }) - } -} - // ── entry points ──────────────────────────────────────────────────────────────────────────── /// Run a FormCalc `script` for `event` on the form `root`, as [`crate::xfa::run_xfa`] runs a @@ -2586,16 +185,42 @@ pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: X mod tests { use super::*; + #[test] + fn fixed_does_not_print_negative_zero() { + assert_eq!(fixed(-0.4, 0), "0"); + assert_eq!(fixed(-0.004, 2), "0.00"); + assert_eq!(fixed(-0.0, 0), "0"); + assert_eq!(fixed(-1.5, 0), "-2"); + } + #[test] fn dates_round_trip_through_formcalc_day_numbers() { assert_eq!(days_from_civil(1900, 1, 1), 1); assert_eq!(days_from_civil(1970, 1, 1), 25_568); assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29)); + // Past chrono's ±262143 the 400-year cycles carry the year; values from the pre-chrono + // formula, both directions, both sides of the epoch. + for (n, date) in [ + (99_000_000, (272_952, 10, 16)), + (99_999_999, (275_690, 9, 11)), + (-99_000_000, (-269_153, 3, 16)), + (-99_999_999, (-271_891, 4, 20)), + (72_354_905, (200_000, 12, 31)), + (-73_742_460, (-200_000, 1, 1)), + ] { + assert_eq!(civil_from_days(n), date, "{n}"); + assert_eq!(days_from_civil(date.0, date.1, date.2), n, "{date:?}"); + } + assert_eq!(days_in(272_952, 2), 29, "272952 is a leap year"); + assert_eq!(days_in(-269_153, 2), 28); + assert_eq!(days_in(i64::MAX, 12), 31); + let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1)); assert_eq!(parse_date("2001-02-03", "YYYY-MM-DD"), Some((2001, 2, 3))); assert_eq!(parse_date("Feb 3, 2001", "MMM D, YYYY"), Some((2001, 2, 3))); assert_eq!(parse_date("2/3/01", "M/D/YY"), Some((2001, 2, 3))); assert_eq!(format_date(2001, 2, 3, "MMMM D, YYYY"), "February 3, 2001"); assert_eq!(word_num(1234.5, true), "One Thousand Two Hundred Thirty-Four Dollars And 50 Cents"); + assert_eq!(word_num(999_999_999_999.999, true), ""); assert_eq!(num_text(3.0), "3"); assert_eq!(num_text(2.5), "2.5"); assert_eq!(num_text(0.1 + 0.2), "0.3"); diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs new file mode 100644 index 0000000000..bea902c752 --- /dev/null +++ b/crates/js/src/formcalc/access.rs @@ -0,0 +1,436 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::CLOCK_EVERY; +use super::ast::{Expr, Idx}; +use super::value::{Val, num_text, parse_num}; +use super::{ + HNode, HashSet, Interp, LIST_TOO_LONG, MAX_LIST, MAX_PARSE_DEPTH, MAX_STEPS, R, RAN_TOO_LONG, child_named, descendants_named, instances, + resolve_som, +}; + +impl Interp<'_> { + pub(super) fn step(&mut self) -> R<()> { + self.charge(1) + } + + /// `n` units of work done (a step per statement or expression, more for work on long + /// strings and lists). + pub(super) fn charge(&mut self, n: u64) -> R<()> { + let before = self.steps; + self.steps = self.steps.saturating_add(n); + if self.steps > MAX_STEPS { + return Err(RAN_TOO_LONG.into()); + } + #[cfg(not(target_arch = "wasm32"))] + if self.steps / CLOCK_EVERY != before / CLOCK_EVERY + && let Some(d) = self.deadline + && std::time::Instant::now() > d + { + return Err(RAN_TOO_LONG.into()); + } + #[cfg(target_arch = "wasm32")] + let _ = before; + Ok(()) + } + + /// `more` nodes into `out`, each once, never past [`MAX_LIST`]. + pub(super) fn gather(&mut self, out: &mut Vec, seen: &mut HashSet, more: impl IntoIterator) -> R<()> { + for i in more { + if seen.insert(i) { + out.push(i); + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + } + } + self.charge(1) + } + + /// The nodes of `v`, each once. + pub(super) fn nodes_list(&self, v: &Val) -> Vec { + let mut seen = HashSet::new(); + self.nodes_of(v).into_iter().filter(|i| seen.insert(*i)).collect() + } + + pub(super) fn node(&self, i: usize) -> Option<&HNode> { + self.h.nodes.get(i).filter(|n| !n.gone) + } + + // ── coercions + + /// A node's value as a FormCalc value. + fn node_value(&self, i: usize) -> Val { + let Some(n) = self.node(i) else { return Val::Null }; + if n.value.is_empty() { + return Val::Null; + } + if n.numeric + && let Some(v) = parse_num(&n.value) + { + return Val::Num(v); + } + Val::Str(n.value.clone()) + } + + /// What copying `v` (or the values it stands for) costs, in steps: one per 256 bytes, so + /// copying long strings over and over runs out of steps even where there is no clock + /// (wasm). A form object counts its value; a list its items too. + pub(super) fn cost(&self, v: &Val) -> u64 { + let mut bytes = 0usize; + let mut todo = vec![v]; + while let Some(v) = todo.pop() { + match v { + Val::Str(s) => bytes = bytes.saturating_add(s.len()), + Val::Node(i) => bytes = bytes.saturating_add(self.node(*i).map_or(0, |n| n.value.len())), + Val::List(items) => { + bytes = bytes.saturating_add(items.len().saturating_mul(16)); + todo.extend(items.iter()); + } + _ => {} + } + } + (bytes / 256) as u64 + } + + /// [`Self::deref`], charged for the copy. + pub(super) fn take(&mut self, v: Val) -> R { + self.charge(self.cost(&v))?; + Ok(self.deref(v)) + } + + /// Objects become their values; lists of objects become lists of values. + pub(super) fn deref(&self, v: Val) -> Val { + match v { + Val::Node(i) => self.node_value(i), + Val::List(items) => Val::List(items.into_iter().map(|x| self.deref(x)).collect()), + other => other, + } + } + + pub(super) fn to_num(&self, v: &Val) -> f64 { + match v { + Val::Null => 0.0, + Val::Num(n) => *n, + Val::Str(s) => parse_num(s).unwrap_or(0.0), + Val::Node(i) => self.to_num(&self.node_value(*i)), + Val::List(items) => items.first().map_or(0.0, |x| self.to_num(x)), + _ => 0.0, + } + } + + pub(super) fn to_str(&self, v: &Val) -> String { + match v { + Val::Null => String::new(), + Val::Num(n) => num_text(*n), + Val::Str(s) => s.clone(), + Val::Node(i) => self.to_str(&self.node_value(*i)), + Val::List(items) => items.first().map_or(String::new(), |x| self.to_str(x)), + Val::Host => "host".into(), + Val::Layout => "layout".into(), + Val::Event => "event".into(), + Val::Xfa => "xfa".into(), + Val::Im(..) => "instanceManager".into(), + Val::Sink => String::new(), + } + } + + pub(super) fn truthy(&self, v: &Val) -> bool { + match v { + Val::Null => false, + Val::Num(n) => *n != 0.0 && !n.is_nan(), + Val::Str(s) => parse_num(s).is_some_and(|n| n != 0.0), + Val::Node(i) => { + let d = self.node_value(*i); + self.truthy(&d) + } + Val::List(items) => !items.is_empty(), + _ => true, + } + } + + pub(super) fn is_null(&self, v: &Val) -> bool { + match v { + Val::Null => true, + Val::Node(i) => self.node(*i).is_none_or(|n| n.value.is_empty()), + Val::List(items) => items.is_empty(), + _ => false, + } + } + + // ── scopes + + fn lookup(&self, name: &str) -> Option { + self.scopes.iter().rev().find_map(|s| s.get(name).cloned()) + } + + pub(super) fn set_var(&mut self, name: &str, v: Val) -> bool { + for s in self.scopes.iter_mut().rev() { + if let Some(slot) = s.get_mut(name) { + *slot = v; + return true; + } + } + false + } + + pub(super) fn declare(&mut self, name: &str, v: Val) { + if let Some(s) = self.scopes.last_mut() { + s.insert(name.to_string(), v); + } + } + + // ── accessors + + /// The objects an identifier names from the current node: `$`, the special objects, an + /// instance manager (`_name`), a variable, or a form object by name (children first, then + /// the containing subforms). + pub(super) fn ident(&self, name: &str) -> R { + if let Some(v) = self.lookup(name) { + return Ok(v); + } + let lower = name.to_ascii_lowercase(); + Ok(match lower.as_str() { + "$" | "this" => Val::Node(self.h.current), + "$host" => Val::Host, + "$layout" => Val::Layout, + "$event" => Val::Event, + "$form" | "$data" | "$dataset" | "$datasets" => Val::Node(0), + "$record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), + "$template" | "$xfa" => Val::Xfa, + "xfa" => Val::Xfa, + _ => { + if let Some(child) = name.strip_prefix('_') { + // The instance manager of `child`, looked for like a name. + let found = resolve_som(self.h, self.h.current, child); + let Some(&first) = found.first() else { return Err(format!("unknown instance manager _{child}")) }; + let parent = self.node(first).and_then(|n| n.parent).ok_or_else(|| format!("unknown instance manager _{child}"))?; + return Ok(Val::Im(parent, child.to_string())); + } + let found = resolve_som(self.h, self.h.current, name); + match found.as_slice() { + [] => return Err(format!("unknown name {name}")), + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|i| Val::Node(*i)).collect()), + } + } + }) + } + + /// Every object a value stands for. + pub(super) fn nodes_of(&self, v: &Val) -> Vec { + match v { + Val::Node(i) => vec![*i], + Val::List(items) => items.iter().flat_map(|x| self.nodes_of(x)).collect(), + _ => Vec::new(), + } + } + + pub(super) fn member(&mut self, base: Val, name: &str) -> R { + match base { + Val::Node(i) => self.node_member(i, name), + Val::List(items) => { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for it in items { + match self.member(it, name)? { + Val::List(more) => { + for m in more { + self.list_push(&mut out, &mut seen, m)?; + } + } + Val::Null => {} + v => self.list_push(&mut out, &mut seen, v)?, + } + } + Ok(match out.len() { + 0 => Val::Null, + 1 => out.remove(0), + _ => Val::List(out), + }) + } + Val::Host => Ok(match name.to_ascii_lowercase().as_str() { + "numpages" => Val::Num(self.h.doc.page_count as f64), + "currentpage" => Val::Num(self.h.doc.page as f64), + "name" => Val::Str("Acrobat".into()), + "apptype" => Val::Str("Exchange-Pro".into()), + "version" => Val::Str("11".into()), + "variation" => Val::Str("Full".into()), + "language" => Val::Str("en".into()), + "platform" => Val::Str(crate::platform().into()), + "title" => Val::Str(self.h.doc.file_name.clone()), + "calculationsenabled" | "validationsenabled" => Val::Num(1.0), + _ => Val::Sink, + }), + Val::Layout => Ok(Val::Sink), + Val::Event => Ok(match name.to_ascii_lowercase().as_str() { + "name" => Val::Str(self.event.activity.clone()), + "newtext" | "fulltext" => Val::Str(self.event.new_text.clone()), + "prevtext" => Val::Str(self.event.prev_text.clone()), + "change" => Val::Str(String::new()), + "target" => Val::Node(self.h.current), + "selstart" | "selend" | "cancelaction" | "reenter" | "shift" | "modifier" | "keydown" | "commitkey" => Val::Num(0.0), + _ => Val::Sink, + }), + Val::Xfa => Ok(match name.to_ascii_lowercase().as_str() { + "form" | "datasets" | "data" => Val::Node(0), + "record" => Val::Node(if self.node(1).is_some() { 1 } else { 0 }), + "host" => Val::Host, + "layout" => Val::Layout, + "event" => Val::Event, + _ => Val::Sink, + }), + Val::Im(parent, child) => Ok(match name.to_ascii_lowercase().as_str() { + "count" => Val::Num(instances(self.h, parent, &child).len() as f64), + "min" => Val::Num(instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).map_or(0.0, |n| n.occur_min as f64)), + "max" => Val::Num( + instances(self.h, parent, &child).first().and_then(|i| self.node(*i)).and_then(|n| n.occur_max).map_or(-1.0, |m| m as f64), + ), + "classname" => Val::Str("instanceManager".into()), + _ => Val::Sink, + }), + Val::Sink => Ok(Val::Sink), + Val::Null => Err(format!("no object to take {name} from")), + Val::Num(_) | Val::Str(_) => Err(format!("{} has no property {name}", self.to_str(&base))), + } + } + + /// `v` onto `out`: a node only once, never past [`MAX_LIST`]. + fn list_push(&mut self, out: &mut Vec, seen: &mut HashSet, v: Val) -> R<()> { + if let Val::Node(i) = v + && !seen.insert(i) + { + return Ok(()); + } + out.push(v); + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + self.charge(1) + } + + /// Property `name` of form object `i` as FormCalc reads it: its value, attributes, + /// relatives and children by name. + fn node_member(&mut self, i: usize, name: &str) -> R { + let Some(n) = self.node(i) else { return Ok(Val::Null) }; + Ok(match name { + "formattedValue" => match &n.formatted { + Some(f) => Val::Str(f.clone()), + None => self.node_value(i), + }, + "rawValue" | "value" => self.node_value(i), + "name" => Val::Str(n.name.clone()), + "somExpression" => Val::Str(n.som.clone()), + "className" => Val::Str(n.kind.class_name().into()), + "index" | "instanceIndex" => Val::Num(n.index as f64), + "presence" => Val::Str(n.presence.clone()), + "access" => Val::Str(n.access.clone()), + "isNull" => Val::Num(if n.value.is_empty() { 1.0 } else { 0.0 }), + "mandatory" => Val::Str("disabled".into()), + "parent" => n.parent.map_or(Val::Null, Val::Node), + "nodes" => Val::List(n.children.iter().copied().filter(|c| self.node(*c).is_some()).map(Val::Node).collect()), + "all" => match n.parent { + Some(p) => Val::List(instances(self.h, p, &n.name).into_iter().map(Val::Node).collect()), + None => Val::Node(i), + }, + "instanceManager" => match n.parent { + Some(p) => Val::Im(p, n.name.clone()), + None => Val::Null, + }, + "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" + | "format" | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" + | "oneOfChild" | "locale" | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" + | "calculate" | "traversal" | "extras" | "desc" => Val::Sink, + other => { + if let Some(child) = other.strip_prefix('_') { + return Ok(if child_named(self.h, i, child).is_some() { Val::Im(i, child.to_string()) } else { Val::Null }); + } + let found = instances(self.h, i, other); + match found.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), + } + } + }) + } + + /// `base[n]` / `base[*]`: all the instances the last step named, then one or all. + pub(super) fn index(&mut self, base: &Expr, idx: &Idx) -> R { + let all = self.all_of(base)?; + match idx { + Idx::All => Ok(Val::List(all.into_iter().map(Val::Node).collect())), + Idx::Abs(e) => { + let v = self.eval(e)?; + let n = self.to_num(&v); + if !n.is_finite() || n < 0.0 { + return Ok(Val::Null); + } + Ok(all.get(n as usize).map_or(Val::Null, |i| Val::Node(*i))) + } + Idx::Rel(k) => { + // From the instance the script runs in (`row[-1]` in a row is the row above), + // else from the first. + let here = all.iter().position(|&i| self.encloses(i, self.h.current)).unwrap_or(0); + let at = (here as i64).saturating_add(*k); + Ok(usize::try_from(at).ok().and_then(|at| all.get(at)).map_or(Val::Null, |i| Val::Node(*i))) + } + } + } + + /// Whether `node` is `inner` or one of its containers. + fn encloses(&self, node: usize, inner: usize) -> bool { + let mut at = Some(inner); + let mut guard = 0; + while let Some(i) = at + && guard < MAX_PARSE_DEPTH + { + if i == node { + return true; + } + at = self.h.nodes.get(i).and_then(|n| n.parent); + guard += 1; + } + false + } + + /// Every instance an accessor names (`Row` → all rows, not just the first), each once. + pub(super) fn all_of(&mut self, e: &Expr) -> R> { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + match e { + Expr::Ident(name) => { + if self.lookup(name).is_some() || name.starts_with('$') || name.eq_ignore_ascii_case("xfa") { + let v = self.eval(e)?; + return Ok(self.nodes_list(&v)); + } + // The name resolves to its first instance; `row[*]` means every sibling of it. + for first in resolve_som(self.h, self.h.current, name) { + match self.node(first).and_then(|n| n.parent.map(|p| (p, n.name.clone()))) { + Some((p, n)) => self.gather(&mut out, &mut seen, instances(self.h, p, &n))?, + None => self.gather(&mut out, &mut seen, [first])?, + } + } + } + Expr::Member(base, name) => { + let b = self.eval(base)?; + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, instances(self.h, i, name))?; + } + if out.is_empty() { + let v = self.member(b, name)?; + out = self.nodes_list(&v); + } + } + Expr::Descend(base, name) => { + let b = self.eval(base)?; + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; + } + } + other => { + let v = self.eval(other)?; + out = self.nodes_list(&v); + } + } + Ok(out) + } +} diff --git a/crates/js/src/formcalc/ast.rs b/crates/js/src/formcalc/ast.rs new file mode 100644 index 0000000000..ab453a8061 --- /dev/null +++ b/crates/js/src/formcalc/ast.rs @@ -0,0 +1,48 @@ +use super::{Func, Rc}; + +// ── syntax ────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Expr { + Num(f64), + Str(String), + Null, + Ident(String), + /// `base.name` + Member(Box, String), + /// `base[n]`, `base[*]`, `base[-n]` + Index(Box, Idx), + /// `base..name` + Descend(Box, String), + Call(Box, Vec), + Neg(Box), + Not(Box), + Bin(&'static str, Box, Box), +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Idx { + /// `[*]`: every instance. + All, + /// `[n]`: the n-th instance (from 0). + Abs(Box), + /// `[-n]` / `[+n]`: n instances before or after the one the script runs in. + Rel(i64), +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Stmt { + Var(String, Option), + Assign(Expr, Expr), + Expr(Expr), + If(Vec<(Expr, Vec)>, Option>), + While(Expr, Vec), + For { var: String, from: Expr, to: Expr, up: bool, step: Option, body: Vec }, + Foreach { var: String, list: Vec, body: Vec }, + Func { name: String, f: Rc }, + Return(Option), + Break, + Continue, + Exit, + Throw(Expr), +} diff --git a/crates/js/src/formcalc/builtins.rs b/crates/js/src/formcalc/builtins.rs new file mode 100644 index 0000000000..c3b7c7f1f2 --- /dev/null +++ b/crates/js/src/formcalc/builtins.rs @@ -0,0 +1,504 @@ +use super::ast::Expr; +use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, parse_date}; +use super::parser::parse; +use super::value::{Val, fixed, is_numeric_text, parse_num}; +use super::words::word_num; +use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit}; + +impl Interp<'_> { + /// A built-in function: its result is never a string past [`MAX_STRING`], and work on + /// long strings is charged to the step budget. + pub(super) fn builtin(&mut self, name: &str, args: &[Expr]) -> R { + let v = self.builtin_inner(name, args)?; + if let Val::Str(s) = &v { + if s.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + self.charge((s.len() / 256) as u64)?; + } + Ok(v) + } + + fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { + let lower = name.to_ascii_lowercase(); + let n = |v: f64| Ok(Val::Num(v)); + if let Some(value) = self.date_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.financial_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.unit_builtin(&lower, args)? { + return Ok(value); + } + Ok(match lower.as_str() { + // ── arithmetic + "abs" => return n(self.arg_num(args, 0)?.abs()), + "ceil" => return n(self.arg_num(args, 0)?.ceil()), + "floor" => return n(self.arg_num(args, 0)?.floor()), + "mod" => { + let (a, b) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?); + if b == 0.0 { + return Err("division by zero".into()); + } + Val::Num(a - b * (a / b).trunc()) + } + "round" => { + let a = self.arg_num(args, 0)?; + let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 }; + let k = 10f64.powi(places); + let scaled = a * k; + if scaled.is_finite() { Val::Num(scaled.round() / k) } else { Val::Num(a) } + } + "count" => { + // Non-null values and objects: an empty field doesn't count, a subform does. + let vals = self.arg_values_keeping(args, true)?; + let counts = |v: &Val| match v { + Val::Node(i) => self.node(*i).is_some_and(|n| !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) || !n.value.is_empty()), + other => !self.is_null(other), + }; + Val::Num(vals.iter().filter(|v| counts(v)).count() as f64) + } + "sum" | "avg" | "max" | "min" => { + let vals = self.arg_values(args)?; + let nums: Vec = vals.iter().map(|v| self.to_num(v)).collect(); + match lower.as_str() { + "sum" => Val::Num(nums.iter().sum()), + "avg" => { + if nums.is_empty() { + Val::Null + } else { + Val::Num(nums.iter().sum::() / nums.len() as f64) + } + } + "max" => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.max(x)))).map_or(Val::Null, Val::Num), + _ => nums.iter().copied().fold(None, |m: Option, x| Some(m.map_or(x, |m| m.min(x)))).map_or(Val::Null, Val::Num), + } + } + // ── logical + "choose" => { + let i = self.arg_num(args, 0)?; + if !i.is_finite() || i < 1.0 { + return Ok(Val::Null); + } + self.arg(args, i as usize)? + } + "exists" => { + let Some(e) = args.first() else { return Ok(Val::Num(0.0)) }; + // A name that resolves to nothing is the answer "no"; a limit the lookup ran + // into (time, steps, depth, size) still stops the script. + match self.all_of(e) { + Ok(v) => Val::Num(if v.is_empty() { 0.0 } else { 1.0 }), + Err(e) if is_limit(&e) => return Err(e), + Err(_) => Val::Num(0.0), + } + } + "hasvalue" => { + let v = self.arg(args, 0)?; + Val::Num(if self.is_null(&v) || matches!(&v, Val::Str(s) if s.trim().is_empty()) { 0.0 } else { 1.0 }) + } + "oneof" => { + let v = self.arg(args, 0)?; + let rest = self.arg_values(args.get(1..).unwrap_or_default())?; + let mut hit = false; + for x in &rest { + // Each comparison copies and compares `v`: charged per item. + self.charge(self.cost(&v).saturating_add(self.cost(x)).saturating_add(1))?; + if self.binary("==", v.clone(), x.clone()).is_ok_and(|r| self.truthy(&r)) { + hit = true; + break; + } + } + Val::Num(if hit { 1.0 } else { 0.0 }) + } + "within" => { + let v = self.arg(args, 0)?; + let (lo, hi) = (self.arg(args, 1)?, self.arg(args, 2)?); + let ok = self.binary(">=", v.clone(), lo).is_ok_and(|r| self.truthy(&r)) && self.binary("<=", v, hi).is_ok_and(|r| self.truthy(&r)); + Val::Num(if ok { 1.0 } else { 0.0 }) + } + // ── strings + "at" => { + let (s, sub) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); + Val::Num(if sub.is_empty() { 0.0 } else { s.find(&sub).map_or(0.0, |b| s[..b].chars().count() as f64 + 1.0) }) + } + "concat" => { + let mut out = String::new(); + for i in 0..args.len() { + out.push_str(&self.arg_str(args, i)?); + if out.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + } + Val::Str(out) + } + "left" => { + let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); + Val::Str(s.chars().take(k).collect()) + } + "right" => { + let (s, k) = (self.arg_str(args, 0)?, self.arg_num(args, 1)?.max(0.0) as usize); + let len = s.chars().count(); + Val::Str(s.chars().skip(len.saturating_sub(k)).collect()) + } + "len" => Val::Num(self.arg_str(args, 0)?.chars().count() as f64), + "lower" => Val::Str(self.arg_str(args, 0)?.to_lowercase()), + "upper" => Val::Str(self.arg_str(args, 0)?.to_uppercase()), + "ltrim" => Val::Str(self.arg_str(args, 0)?.trim_start().to_string()), + "rtrim" => Val::Str(self.arg_str(args, 0)?.trim_end().to_string()), + "space" => { + let k = self.arg_num(args, 0)?.clamp(0.0, MAX_STRING as f64) as usize; + Val::Str(" ".repeat(k)) + } + "str" => { + let v = self.arg_num(args, 0)?; + let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; + let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; + let text = fixed(v, prec); + Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) + } + "substr" => { + let s = self.arg_str(args, 0)?; + let start = self.arg_num(args, 1)?.max(1.0) as usize; + let len = self.arg_num(args, 2)?.max(0.0) as usize; + Val::Str(s.chars().skip(start - 1).take(len).collect()) + } + "stuff" => { + let s = self.arg_str(args, 0)?; + let start = self.arg_num(args, 1)?.max(1.0) as usize; + let del = self.arg_num(args, 2)?.max(0.0) as usize; + let ins = if args.len() > 3 { self.arg_str(args, 3)? } else { String::new() }; + let chars: Vec = s.chars().collect(); + let a = start.saturating_sub(1).min(chars.len()); + let b = a.saturating_add(del).min(chars.len()); + let out: String = chars[..a].iter().chain(ins.chars().collect::>().iter()).chain(chars[b..].iter()).collect(); + if out.len() > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + Val::Str(out) + } + "replace" => { + let (s, old) = (self.arg_str(args, 0)?, self.arg_str(args, 1)?); + let new = if args.len() > 2 { self.arg_str(args, 2)? } else { String::new() }; + if old.is_empty() { + return Ok(Val::Str(s)); + } + let grown = s.matches(old.as_str()).count().saturating_mul(new.len()).saturating_add(s.len()); + if grown > MAX_STRING { + return Err(STRING_TOO_LONG.into()); + } + Val::Str(s.replace(&old, &new)) + } + "wordnum" => { + let v = self.arg_num(args, 0)?; + let kind = if args.len() > 1 { self.arg_num(args, 1)? } else { 0.0 }; + Val::Str(word_num(v, kind == 2.0)) + } + "uuid" => Val::Str(format!("{:032x}", (self.steps as u128).wrapping_mul(0x9E37_79B9_7F4A_7C15_F39C_C060_5CED_C835))), + "encode" | "decode" => { + let s = self.arg_str(args, 0)?; + let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; + Val::Str(match (lower.as_str(), kind.as_str()) { + ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), + ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), + ("encode", _) => s + .bytes() + .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) + .collect(), + (_, _) => { + let mut out = Vec::new(); + let b = s.as_bytes(); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' + && let Some(h) = b + .get(i + 1..i + 3) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()) + { + out.push(h); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() + } + }) + } + "format" => { + // Number pictures: `9` digit, `z` optional digit, `,` separator, `.` point, `$`. + let pic = self.arg_str(args, 0)?; + let v = self.arg(args, 1)?; + if pic.to_ascii_uppercase().contains("YY") || pic.to_ascii_uppercase().contains("DD") { + let text = self.to_str(&v); + let days = parse_num(&text).filter(|d| is_numeric_text(&text) && d.abs() < MAX_DAYS).map(|d| d as i64); + return Ok(match days { + Some(d) => { + let (y, m, dd) = civil_from_days(d); + Val::Str(format_date(y, m, dd, &pic)) + } + None => parse_date(&text, "YYYY-MM-DD").map_or(Val::Str(text), |(y, m, d)| Val::Str(format_date(y, m, d, &pic))), + }); + } + let num = self.to_num(&v); + let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20); + // `9` in the integer part forces a digit; `z` does not. + let min_int = pic.split('.').next().map_or(0, |i| i.chars().filter(|c| *c == '9').count()).min(20); + let width = min_int + if decimals > 0 { decimals + 1 } else { 0 }; + let grouped = pic.contains(','); + let mut text = format!("{:0>width$}", fixed(num.abs(), decimals)); + if grouped { + let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b)); + let mut g = String::new(); + for (k, c) in int.chars().enumerate() { + if k > 0 && (int.len() - k) % 3 == 0 { + g.push(','); + } + g.push(c); + } + text = if frac.is_empty() { g } else { format!("{g}.{frac}") }; + } + let nonzero = text.chars().any(|c| ('1'..='9').contains(&c)); + if pic.contains('$') { + text = format!("${text}"); + } + if num < 0.0 && nonzero { + text = format!("-{text}"); + } + Val::Str(text) + } + "parse" => { + let pic = self.arg_str(args, 0)?; + let s = self.arg_str(args, 1)?; + if pic.to_ascii_uppercase().contains("YY") { + // The canonical form of a date is ISO. + return Ok(parse_date(&s, &pic).map_or(Val::Null, |(y, m, d)| Val::Str(format_date(y, m, d, "YYYY-MM-DD")))); + } + let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); + parse_num(&cleaned).map_or(Val::Null, Val::Num) + } + "ref" => { + let Some(e) = args.first() else { return Ok(Val::Null) }; + self.eval(e)? + } + "eval" => { + let src = self.arg_str(args, 0)?; + self.charge((src.len() / 16) as u64)?; + let body = parse(&src)?; + let (_, v) = self.block(&body)?; + v + } + "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), + _ => return Err(format!("unknown function {name}")), + }) + } + + fn date_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { + "date" => { + #[cfg(not(target_arch = "wasm32"))] + { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); + Val::Num((secs.div_euclid(86_400) + 25_568) as f64) + } + #[cfg(target_arch = "wasm32")] + { + Val::Num(25_568.0) + } + } + "time" => { + #[cfg(not(target_arch = "wasm32"))] + { + let ms = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0); + Val::Num(ms.rem_euclid(86_400_000) as f64) + } + #[cfg(target_arch = "wasm32")] + { + Val::Num(0.0) + } + } + "date2num" | "isodate2num" => { + let s = self.arg_str(args, 0)?; + let pic = if lower == "isodate2num" || args.len() < 2 { "YYYY-MM-DD".to_string() } else { self.arg_str(args, 1)? }; + let parsed = parse_date(&s, &pic).or_else(|| parse_date(&s, "YYYYMMDD")).or_else(|| parse_date(&s, "YYYY-MM-DD")); + parsed.map_or(Val::Num(0.0), |(y, m, d)| Val::Num(days_from_civil(y, m, d) as f64)) + } + "num2date" => { + let days = self.arg_num(args, 0)?; + let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; + if !days.is_finite() || days.abs() >= MAX_DAYS { + return Ok(Some(Val::Null)); + } + let (y, m, d) = civil_from_days(days as i64); + Val::Str(format_date(y, m, d, &pic)) + } + "datefmt" | "localdatefmt" => { + let style = if args.is_empty() { 0.0 } else { self.arg_num(args, 0)? }; + Val::Str( + match style as i32 { + 1 => "M/D/YY", + 2 => "MMM D, YYYY", + 3 => "MMMM D, YYYY", + 4 => "EEEE, MMMM D, YYYY", + _ => "MMM D, YYYY", + } + .into(), + ) + } + "timefmt" | "localtimefmt" => Val::Str("HH:MM:SS".into()), + "num2time" | "num2gmtime" => { + let ms = self.arg_num(args, 0)?; + if !ms.is_finite() || ms < 0.0 { + return Ok(Some(Val::Null)); + } + let s = (ms / 1000.0) as u64; + Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) + } + "time2num" | "isotime2num" => { + let s = self.arg_str(args, 0)?; + // ISO 8601 allows a leading "T", a trailing "Z" and fractional seconds. + let t = s.trim(); + let t = t.strip_prefix(['T', 't']).unwrap_or(t); + let t = t.strip_suffix(['Z', 'z']).unwrap_or(t); + let mut parts = t.split(':'); + let mut whole = || parts.next().and_then(|p| p.trim().parse::().ok()).unwrap_or(0).min(1_000_000); + let (h, m) = (whole(), whole()); + let sec = + parts.next().and_then(|p| p.trim().parse::().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0); + Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round()) + } + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn financial_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { + "fv" => { + let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) + } + "pv" => { + let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + Val::Num(if r == 0.0 { p * k } else { p * ((1.0 - (1.0 + r).powf(-k)) / r) }) + } + "pmt" => { + let (pv, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if k <= 0.0 { + return Err("Pmt needs a positive number of periods".into()); + } + Val::Num(if r == 0.0 { pv / k } else { pv * r / (1.0 - (1.0 + r).powf(-k)) }) + } + "term" => { + let (p, r, fv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if p <= 0.0 || r <= 0.0 || fv <= 0.0 { + return Err("Term needs positive arguments".into()); + } + Val::Num(((fv * r / p) + 1.0).ln() / (1.0 + r).ln()) + } + "cterm" => { + let (r, fv, pv) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if r <= 0.0 || fv <= 0.0 || pv <= 0.0 { + return Err("CTerm needs positive arguments".into()); + } + Val::Num((fv / pv).ln() / (1.0 + r).ln()) + } + "rate" => { + let (fv, pv, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if fv <= 0.0 || pv <= 0.0 || k <= 0.0 { + return Err("Rate needs positive arguments".into()); + } + Val::Num((fv / pv).powf(1.0 / k) - 1.0) + } + "npv" => { + let r = self.arg_num(args, 0)?; + let flows = self.arg_values(args.get(1..).unwrap_or_default())?; + Val::Num(flows.iter().enumerate().map(|(i, v)| self.to_num(v) / (1.0 + r).powi(i as i32 + 1)).sum()) + } + "ipmt" | "ppmt" => { + let (pv, r, pmt, first, count) = ( + self.arg_num(args, 0)?, + self.arg_num(args, 1)?, + self.arg_num(args, 2)?, + self.arg_num(args, 3)?.max(1.0), + self.arg_num(args, 4)?.max(1.0), + ); + let (mut balance, mut interest, mut principal) = (pv, 0.0, 0.0); + for period in 1..=((first + count - 1.0).min(10_000.0) as u32) { + self.step()?; + let i = balance * r; + let p = (pmt - i).min(balance); + if period >= first as u32 { + interest += i; + principal += p; + } + balance -= p; + if balance <= 0.0 { + break; + } + } + Val::Num(if lower == "ipmt" { interest } else { principal }) + } + "apr" => { + let (principal, payment, periods) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); + if principal <= 0.0 || payment <= 0.0 || periods <= 0.0 { + return Err("Apr needs positive arguments".into()); + } + // Bisection on the monthly rate. + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..200 { + let mid = (lo + hi) / 2.0; + let pmt = if mid == 0.0 { principal / periods } else { principal * mid / (1.0 - (1.0 + mid).powf(-periods)) }; + if pmt > payment { + hi = mid; + } else { + lo = mid; + } + } + Val::Num((lo + hi) / 2.0 * 12.0) + } + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn unit_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { + "unittype" => { + let s = self.arg_str(args, 0)?; + let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); + Val::Str( + match unit.trim().to_ascii_lowercase().as_str() { + "in" | "inch" | "inches" => "in", + "mm" | "millimeter" | "millimeters" => "mm", + "cm" | "centimeter" | "centimeters" => "cm", + "pt" | "point" | "points" => "pt", + "pc" | "pica" => "pc", + _ => "in", + } + .into(), + ) + } + "unitvalue" => { + let s = self.arg_str(args, 0)?; + let to = if args.len() > 1 { self.arg_str(args, 1)? } else { String::new() }; + let v = parse_num(&s).unwrap_or(0.0); + let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); + let per_in = |u: &str| match u.trim().to_ascii_lowercase().as_str() { + "mm" | "millimeter" | "millimeters" => 25.4, + "cm" | "centimeter" | "centimeters" => 2.54, + "pt" | "point" | "points" => 72.0, + "pc" | "pica" => 6.0, + _ => 1.0, + }; + let target = if to.trim().is_empty() { unit.clone() } else { to }; + Val::Num(v / per_in(&unit) * per_in(&target)) + } + _ => return Ok(None), + }; + Ok(Some(value)) + } +} diff --git a/crates/js/src/formcalc/dates.rs b/crates/js/src/formcalc/dates.rs new file mode 100644 index 0000000000..bfb6383cdd --- /dev/null +++ b/crates/js/src/formcalc/dates.rs @@ -0,0 +1,166 @@ +// ── built-in functions ────────────────────────────────────────────────────────────────────── + +/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so +/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568). +const FORMCALC_EPOCH_FROM_CE: i64 = 693_595; + +/// Days in one 400-year Gregorian cycle; the calendar repeats after it. +const DAYS_PER_400_YEARS: i64 = 146_097; + +/// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. +/// +/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS` +/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date +/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian +/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated; +/// anything else reads as 0. +pub(super) fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + use chrono::Datelike; + let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) + .and_then(|date| { + let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS); + i64::try_from(days).ok() + }) + .unwrap_or(0) +} + +/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any +/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years. +pub(super) fn civil_from_days(n: i64) -> (i64, u32, u32) { + use chrono::Datelike; + // Days since CE day 0 (0000-12-31), in i128 so no `n` overflows. + let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE); + let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); + // `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31). + let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + match date { + Some(date) => { + let y = i128::from(date.year()) + cycles * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) + } + // Unreachable: every `rest` in 0..146_097 is a valid CE day. + None => (1900, 1, 1), + } +} + +/// Day numbers (and years) beyond this are refused rather than computed. +pub(super) const MAX_DAYS: f64 = 1e8; + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +/// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). +pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { + let pic: Vec = picture.chars().collect(); + let txt: Vec = text.trim().chars().collect(); + let (mut y, mut m, mut d) = (None, None, None); + let (mut i, mut j) = (0, 0); + while i < pic.len() { + let c = pic[i].to_ascii_uppercase(); + if c == 'Y' || c == 'M' || c == 'D' { + let mut run = 1; + while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { + run += 1; + } + i += run; + if c == 'M' && run >= 3 { + // Month name. + let rest: String = txt.get(j..).unwrap_or(&[]).iter().collect(); + let lower = rest.to_ascii_lowercase(); + let found = MONTHS + .iter() + .position(|name| lower.starts_with(&name.to_ascii_lowercase()) || lower.starts_with(&name[..3].to_ascii_lowercase())); + let k = found?; + m = Some(k as u32 + 1); + let full = MONTHS[k].to_ascii_lowercase(); + j += if lower.starts_with(&full) { full.chars().count() } else { 3 }; + continue; + } + let width = if run == 1 { 2 } else { run }; + let mut digits = String::new(); + while j < txt.len() && txt[j].is_ascii_digit() && digits.len() < width { + digits.push(txt[j]); + j += 1; + } + if digits.is_empty() { + return None; + } + let v: i64 = digits.parse().ok()?; + match c { + 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), + 'M' => m = Some(v as u32), + _ => d = Some(v as u32), + } + } else { + if txt.get(j) != Some(&pic[i]) { + return None; + } + i += 1; + j += 1; + } + } + let (y, m, d) = (y?, m.unwrap_or(1), d.unwrap_or(1)); + ((-200_000..=200_000).contains(&y) && (1..=12).contains(&m) && (1..=days_in(y, m)).contains(&d)).then_some((y, m, d)) +} + +/// Number of days in month `m` (1-12) of year `y`, leap years included. +pub(crate) fn days_in(y: i64, m: u32) -> u32 { + // The length of the month from chrono (the first of the next month, less the first of this + // one). An invalid month keeps the old 31-day default. + // Month lengths repeat every 400 years, so the year within its cycle is enough (and always + // in chrono's range, whatever `y` is). + let y = y.rem_euclid(400); + let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1)); + let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }; + match (first(y, m), next) { + (Some(a), Some(b)) => (b - a).num_days() as u32, + _ => 31, + } +} + +const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +pub(super) fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { + let pic: Vec = picture.chars().collect(); + let mut out = String::new(); + let mut i = 0; + while i < pic.len() { + let c = pic[i].to_ascii_uppercase(); + if c == 'Y' || c == 'M' || c == 'D' { + let mut run = 1; + while i + run < pic.len() && pic[i + run].to_ascii_uppercase() == c { + run += 1; + } + i += run; + match (c, run) { + ('Y', 1 | 2) => out.push_str(&format!("{:02}", y.rem_euclid(100))), + ('Y', _) => out.push_str(&format!("{y:04}")), + ('M', 1) => out.push_str(&m.to_string()), + ('M', 2) => out.push_str(&format!("{m:02}")), + ('M', 3) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).map_or("", |n| &n[..3])), + ('M', _) => out.push_str(MONTHS.get((m as usize).saturating_sub(1)).copied().unwrap_or("")), + ('D', 1) => out.push_str(&d.to_string()), + (_, _) => out.push_str(&format!("{d:02}")), + } + } else if pic[i] == 'E' { + let mut run = 1; + while i + run < pic.len() && pic[i + run] == 'E' { + run += 1; + } + i += run; + // Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday. + let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize; + match run { + 1 | 2 => out.push_str(&(weekday + 1).to_string()), + 3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])), + _ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")), + } + } else { + out.push(pic[i]); + i += 1; + } + } + out +} diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs new file mode 100644 index 0000000000..0b2dfe4b94 --- /dev/null +++ b/crates/js/src/formcalc/eval.rs @@ -0,0 +1,629 @@ +use super::ast::{Expr, Stmt}; +use super::value::{Val, is_numeric_text, num_text}; +use super::{ + CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, Message, MessageButtons, MessageIcon, R, Rc, + TOO_DEEP, XfaEffect, child_named, clip, descendants_named, instances, reset_names, resolve_som, +}; + +impl Interp<'_> { + // ── expressions + + pub(super) fn eval(&mut self, e: &Expr) -> R { + self.step()?; + self.depth += 1; + if self.depth > self.max_depth { + self.depth -= 1; + return Err(TOO_DEEP.into()); + } + let r = self.eval_inner(e); + self.depth -= 1; + r + } + + fn eval_inner(&mut self, e: &Expr) -> R { + Ok(match e { + Expr::Num(n) => Val::Num(*n), + Expr::Str(s) => { + self.charge((s.len() / 256) as u64)?; + Val::Str(s.clone()) + } + Expr::Null => Val::Null, + Expr::Ident(name) => { + let v = self.ident(name)?; + self.charge(self.cost(&v))?; + v + } + Expr::Member(base, name) => { + let b = self.eval(base)?; + self.member(b, name)? + } + Expr::Index(base, idx) => self.index(base, idx)?, + Expr::Descend(base, name) => { + let b = self.eval(base)?; + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for i in self.nodes_list(&b) { + self.gather(&mut out, &mut seen, descendants_named(self.h, i, name, 0))?; + } + match out.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + many => Val::List(many.iter().map(|k| Val::Node(*k)).collect()), + } + } + Expr::Call(callee, args) => self.call(callee, args)?, + Expr::Neg(x) => { + let v = self.eval(x)?; + if self.is_null(&v) { Val::Null } else { Val::Num(-self.to_num(&v)) } + } + Expr::Not(x) => { + let v = self.eval(x)?; + Val::Num(if self.truthy(&v) { 0.0 } else { 1.0 }) + } + Expr::Bin(..) => { + // The parser leans left (`a + b + c` is `(a + b) + c`), so a chain is walked + // from its leftmost operand and folded without recursing per operator. + let mut links = Vec::new(); + let mut leftmost = e; + while let Expr::Bin(op, l, r) = leftmost { + links.push((*op, r.as_ref())); + leftmost = l; + } + let mut acc = self.eval(leftmost)?; + for (op, r) in links.into_iter().rev() { + self.step()?; + acc = match op { + "&" => { + if !self.truthy(&acc) { + // Not evaluated: the result is settled. + Val::Num(0.0) + } else { + let b = self.eval(r)?; + Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) + } + } + "|" => { + if self.truthy(&acc) { + Val::Num(1.0) + } else { + let b = self.eval(r)?; + Val::Num(if self.truthy(&b) { 1.0 } else { 0.0 }) + } + } + _ => { + let b = self.eval(r)?; + // Comparing (and dereferencing) long strings is work too. + self.charge(self.cost(&acc).saturating_add(self.cost(&b)))?; + self.binary(op, acc, b)? + } + }; + } + acc + } + }) + } + + pub(super) fn binary(&self, op: &str, a: Val, b: Val) -> R { + let (a, b) = (self.deref(a), self.deref(b)); + // Null with an arithmetic operator is null only when both sides are null. + match op { + "+" | "-" | "*" | "/" => { + if self.is_null(&a) && self.is_null(&b) { + return Ok(Val::Null); + } + let (x, y) = (self.to_num(&a), self.to_num(&b)); + let v = match op { + "+" => x + y, + "-" => x - y, + "*" => x * y, + _ => { + if y == 0.0 { + return Err("division by zero".into()); + } + x / y + } + }; + Ok(Val::Num(v)) + } + _ => { + let ord = match (&a, &b) { + (Val::Null, Val::Null) => Some(std::cmp::Ordering::Equal), + (Val::Null, _) | (_, Val::Null) => None, + (Val::Str(s), Val::Str(t)) if !(is_numeric_text(s) && is_numeric_text(t)) => Some(s.cmp(t)), + (Val::Str(s), Val::Num(_)) if !is_numeric_text(s) => Some(s.as_str().cmp(num_text(self.to_num(&b)).as_str())), + (Val::Num(_), Val::Str(t)) if !is_numeric_text(t) => Some(num_text(self.to_num(&a)).as_str().cmp(t.as_str())), + _ => self.to_num(&a).partial_cmp(&self.to_num(&b)), + }; + let r = match (op, ord) { + ("==", Some(o)) => o.is_eq(), + ("<>", Some(o)) => !o.is_eq(), + ("<>", None) => true, + ("<", Some(o)) => o.is_lt(), + ("<=", Some(o)) => o.is_le(), + (">", Some(o)) => o.is_gt(), + (">=", Some(o)) => o.is_ge(), + _ => false, + }; + Ok(Val::Num(if r { 1.0 } else { 0.0 })) + } + } + } + + // ── calls + + fn call(&mut self, callee: &Expr, args: &[Expr]) -> R { + match callee { + Expr::Ident(name) => { + if let Some(f) = self.funcs.get(&name.to_ascii_lowercase()).cloned() { + let mut vals = Vec::new(); + for a in args { + let v = self.eval(a)?; + vals.push(self.take(v)?); + } + return self.call_user(&f, vals); + } + self.builtin(name, args) + } + Expr::Member(base, method) => { + let b = self.eval(base)?; + self.method(b, method, args) + } + _ => Err("not a function".into()), + } + } + + fn call_user(&mut self, f: &Func, vals: Vec) -> R { + if self.scopes.len() > self.max_depth { + return Err(CALLS_TOO_DEEP.into()); + } + let mut scope = HashMap::new(); + for (i, p) in f.params.iter().enumerate() { + scope.insert(p.clone(), vals.get(i).cloned().unwrap_or(Val::Null)); + } + self.scopes.push(scope); + let r = self.block(&f.body); + self.scopes.pop(); + match r? { + (Flow::Return(v), _) => Ok(v), + (Flow::Exit, last) => { + self.exited = true; + Ok(last) + } + (_, last) => Ok(last), + } + } + + pub(super) fn arg(&mut self, args: &[Expr], i: usize) -> R { + match args.get(i) { + Some(e) => { + let v = self.eval(e)?; + self.take(v) + } + None => Ok(Val::Null), + } + } + + pub(super) fn arg_num(&mut self, args: &[Expr], i: usize) -> R { + let v = self.arg(args, i)?; + Ok(self.to_num(&v)) + } + + pub(super) fn arg_str(&mut self, args: &[Expr], i: usize) -> R { + let v = self.arg(args, i)?; + Ok(self.to_str(&v)) + } + + /// Every value the arguments hold, lists flattened, nulls left out. + pub(super) fn arg_values(&mut self, args: &[Expr]) -> R> { + self.arg_values_keeping(args, false) + } + + /// [`Self::arg_values`]; `objects` keeps form objects (fields, subforms) as themselves, so + /// `Count(row[*])` counts rows rather than filled-in values. + pub(super) fn arg_values_keeping(&mut self, args: &[Expr], objects: bool) -> R> { + let mut out = Vec::new(); + for a in args { + let v = self.eval(a)?; + let v = if objects { v } else { self.take(v)? }; + match v { + Val::List(items) => out.extend(items.into_iter().filter(|x| !matches!(x, Val::Null))), + Val::Null => {} + other => out.push(other), + } + if out.len() > MAX_LIST { + return Err(LIST_TOO_LONG.into()); + } + } + Ok(out) + } + + /// Call `method` on `base` (`$host`, a form object, an instance manager…) with `args`. + fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R { + let m = method.to_ascii_lowercase(); + match base { + Val::Host => match m.as_str() { + "messagebox" => { + // `$host.messageBox(message, title, icon, buttons)`; a missing number is 0, + // the default icon and buttons. + let text = self.arg_str(args, 0)?; + let title = Some(self.arg_str(args, 1)?).filter(|t| !t.is_empty()); + let icon = MessageIcon::from_code(Some(self.arg_num(args, 2)?)); + let buttons = MessageButtons::from_code(Some(self.arg_num(args, 3)?)); + self.h.push(XfaEffect::MessageBox(Message { text, title, icon, buttons })); + // Always OK for now: the viewer can't ask the user and run the script on + // with the answer yet (#4291). + Ok(Val::Num(1.0)) + } + "resetdata" => { + let list = self.arg_str(args, 0)?; + self.h.push(XfaEffect::ResetData(reset_names(&list))); + Ok(Val::Null) + } + "print" => { + self.h.push(XfaEffect::Print); + Ok(Val::Null) + } + "beep" => { + self.h.push(XfaEffect::Beep); + Ok(Val::Null) + } + "gotourl" => { + let u = self.arg_str(args, 0)?; + self.h.push(XfaEffect::LaunchUrl(u)); + Ok(Val::Null) + } + "setfocus" => { + let target = match args.first() { + Some(e) => { + let raw = self.eval(e)?; + match self.nodes_of(&raw).first() { + Some(i) => self.node(*i).map(|n| n.som.clone()).unwrap_or_default(), + None => { + let s = self.to_str(&raw); + let found = resolve_som(self.h, self.h.current, &s); + found.first().and_then(|i| self.node(*i)).map(|n| n.som.clone()).unwrap_or(s) + } + } + } + None => String::new(), + }; + self.h.push(XfaEffect::SetFocus(target)); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Layout => match m.as_str() { + "page" | "abspage" => Ok(Val::Num(self.h.doc.page as f64 + if m == "page" { 1.0 } else { 0.0 })), + "pagecount" | "abspagecount" => Ok(Val::Num(self.h.doc.page_count as f64)), + "relayout" | "relayoutpagearea" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Num(0.0)), + }, + Val::Xfa => match m.as_str() { + "resolvenode" | "resolvenodes" => { + let s = self.arg_str(args, 0)?; + let found = resolve_som(self.h, self.h.current, &s); + Ok(self.set_val(found, m == "resolvenodes")) + } + "recalculate" => { + self.h.push(XfaEffect::Recalculate); + Ok(Val::Null) + } + "remerge" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Node(i) => match m.as_str() { + "resolvenode" | "resolvenodes" => { + let s = self.arg_str(args, 0)?; + let found = resolve_som(self.h, i, &s); + Ok(self.set_val(found, m == "resolvenodes")) + } + "recalculate" => { + self.h.push(XfaEffect::Recalculate); + Ok(Val::Null) + } + "remerge" => { + self.h.push(XfaEffect::Relayout); + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Im(parent, name) => match m.as_str() { + "addinstance" | "insertinstance" => Ok(self.h.add_instance(parent, &name).map_or(Val::Null, Val::Node)), + "removeinstance" => { + let i = self.arg_num(args, 0)?; + if i.is_finite() && i >= 0.0 { + self.h.remove_instance(parent, &name, i as usize); + } + Ok(Val::Null) + } + "setinstances" => { + let want = self.arg_num(args, 0)?; + if want.is_finite() && want >= 0.0 { + self.h.set_instances(parent, &name, want as usize); + } + Ok(Val::Null) + } + _ => Ok(Val::Null), + }, + Val::Event | Val::Sink | Val::List(_) => Ok(Val::Null), + Val::Null => Err(format!("no object to call {method} on")), + Val::Num(_) | Val::Str(_) => Err(format!("{} has no method {method}", self.to_str(&base))), + } + } + + fn set_val(&self, found: Vec, many: bool) -> Val { + if many { + return Val::List(found.into_iter().map(Val::Node).collect()); + } + match found.as_slice() { + [] => Val::Null, + [one] => Val::Node(*one), + all => Val::List(all.iter().map(|i| Val::Node(*i)).collect()), + } + } + + // ── assignment + + fn assign(&mut self, target: &Expr, v: Val) -> R<()> { + let v = self.deref(v); + match target { + Expr::Ident(name) => { + if self.set_var(name, v.clone()) { + return Ok(()); + } + let t = self.ident(name)?; + self.assign_to(t, "rawValue", v) + } + Expr::Member(base, prop) => { + let b = self.eval(base)?; + self.assign_to(b, prop, v) + } + Expr::Index(..) | Expr::Descend(..) => { + let t = self.eval(target)?; + self.assign_to(t, "rawValue", v) + } + _ => Err("cannot assign to that".into()), + } + } + + fn assign_to(&mut self, target: Val, prop: &str, v: Val) -> R<()> { + match target { + Val::Node(i) => { + let Some(n) = self.node(i) else { return Ok(()) }; + let som = n.som.clone(); + match prop { + "rawValue" | "value" | "formattedValue" => { + let text = self.to_str(&v); + if !self.h.set_value(i, text) { + return Err(format!("{} is not a field", if som.is_empty() { "the form" } else { som.as_str() })); + } + } + "presence" => { + let p = self.to_str(&v); + if matches!(p.as_str(), "visible" | "invisible" | "hidden" | "inactive") { + self.h.set_state(i, XfaEffect::SetPresence { som, presence: p }); + } + } + "access" => { + let a = self.to_str(&v); + if matches!(a.as_str(), "open" | "readOnly" | "protected" | "nonInteractive") { + self.h.set_state(i, XfaEffect::SetAccess { som, access: a }); + } + } + other => { + // `page1.qty = 7`: the child object named by the last step takes the value. + if let Some(child) = other.strip_prefix('_') { + if child_named(self.h, i, child).is_some() { + return self.assign_to(Val::Im(i, child.to_string()), "count", v); + } + return Ok(()); + } + let kids = instances(self.h, i, other); + if kids.is_empty() { + return Err(format!("{som} has no {other} to assign to")); + } + for k in kids { + // Every destination copies the value: charge each copy. + self.charge(self.cost(&v))?; + self.assign_to(Val::Node(k), "rawValue", v.clone())?; + } + } + } + Ok(()) + } + Val::List(items) => { + for it in items { + self.charge(self.cost(&v))?; + self.assign_to(it, prop, v.clone())?; + } + Ok(()) + } + Val::Im(parent, name) if prop.eq_ignore_ascii_case("count") => { + let want = self.to_num(&v); + if want.is_finite() && want >= 0.0 { + self.h.set_instances(parent, &name, want as usize); + } + Ok(()) + } + _ => Ok(()), + } + } + + // ── statements + + /// Run a block; returns how it ended and the value of its last expression statement. + pub(super) fn block(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { + // Nesting of statements counts like nesting of expressions: both are stack. + self.depth += 1; + if self.depth > self.max_depth { + self.depth -= 1; + return Err(TOO_DEEP.into()); + } + let r = self.block_inner(body); + self.depth -= 1; + r + } + + fn block_inner(&mut self, body: &[Stmt]) -> R<(Flow, Val)> { + let mut last = Val::Null; + for s in body { + self.step()?; + match self.exec(s)? { + (Flow::Next, v) => { + if let Some(v) = v { + last = v; + } + if self.exited { + return Ok((Flow::Exit, last)); + } + } + (flow, _) => return Ok((flow, last)), + } + } + Ok((Flow::Next, last)) + } + + fn exec(&mut self, s: &Stmt) -> R<(Flow, Option)> { + match s { + Stmt::Var(name, init) => { + let v = match init { + Some(e) => { + let v = self.eval(e)?; + self.take(v)? + } + None => Val::Null, + }; + self.declare(name, v.clone()); + Ok((Flow::Next, Some(v))) + } + Stmt::Assign(target, e) => { + let v = self.eval(e)?; + let v = self.take(v)?; + self.assign(target, v.clone())?; + Ok((Flow::Next, Some(v))) + } + Stmt::Expr(e) => { + let v = self.eval(e)?; + let v = self.take(v)?; + Ok((Flow::Next, Some(v))) + } + Stmt::If(arms, otherwise) => { + for (cond, body) in arms { + let c = self.eval(cond)?; + if self.truthy(&c) { + let (f, v) = self.block(body)?; + return Ok((f, Some(v))); + } + } + if let Some(body) = otherwise { + let (f, v) = self.block(body)?; + return Ok((f, Some(v))); + } + Ok((Flow::Next, None)) + } + Stmt::While(cond, body) => { + loop { + let c = self.eval(cond)?; + if !self.truthy(&c) { + break; + } + self.iterate()?; + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + } + Ok((Flow::Next, None)) + } + Stmt::For { var, from, to, up, step, body } => { + let start = { + let v = self.eval(from)?; + self.to_num(&v) + }; + let end = { + let v = self.eval(to)?; + self.to_num(&v) + }; + let by = match step { + Some(e) => { + let v = self.eval(e)?; + self.to_num(&v).abs() + } + None => 1.0, + }; + if by.is_nan() || by <= 0.0 || !start.is_finite() || !end.is_finite() { + return Err("bad for loop bounds".into()); + } + self.declare(var, Val::Num(start)); + let mut i = start; + while if *up { i <= end } else { i >= end } { + self.iterate()?; + self.set_var(var, Val::Num(i)); + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + i = if *up { i + by } else { i - by }; + } + Ok((Flow::Next, None)) + } + Stmt::Foreach { var, list, body } => { + let items = self.arg_values_keeping(list, true)?; + self.declare(var, Val::Null); + for it in items { + self.iterate()?; + self.set_var(var, it); + match self.block(body)? { + (Flow::Break, _) => break, + (Flow::Return(v), _) => return Ok((Flow::Return(v), None)), + (Flow::Exit, _) => return Ok((Flow::Exit, None)), + _ => {} + } + } + Ok((Flow::Next, None)) + } + Stmt::Func { name, f } => { + self.funcs.insert(name.to_ascii_lowercase(), Rc::clone(f)); + Ok((Flow::Next, None)) + } + Stmt::Return(e) => { + let v = match e { + Some(e) => { + let v = self.eval(e)?; + self.take(v)? + } + None => Val::Null, + }; + Ok((Flow::Return(v), None)) + } + Stmt::Break => Ok((Flow::Break, None)), + Stmt::Continue => Ok((Flow::Continue, None)), + Stmt::Exit => Ok((Flow::Exit, None)), + Stmt::Throw(e) => { + let v = self.eval(e)?; + self.charge(self.cost(&v))?; + // The thrown value becomes the error message the caller keeps: cut it as + // messages are. + Err(clip(self.to_str(&v))) + } + } + } + + fn iterate(&mut self) -> R<()> { + if self.loops_left == 0 { + return Err(LOOPED_TOO_MUCH.into()); + } + self.loops_left -= 1; + Ok(()) + } +} diff --git a/crates/js/src/formcalc/lexer.rs b/crates/js/src/formcalc/lexer.rs new file mode 100644 index 0000000000..46af19f761 --- /dev/null +++ b/crates/js/src/formcalc/lexer.rs @@ -0,0 +1,129 @@ +use super::MAX_STRING; + +// ── lexer ─────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Tok { + Num(f64), + Str(String), + Ident(String), + Op(&'static str), + Newline, + Eof, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct Token { + pub(super) tok: Tok, + pub(super) line: usize, +} + +const OPS: [&str; 20] = ["<=", ">=", "==", "<>", "..", "(", ")", "[", "]", ".", ",", "=", "<", ">", "+", "-", "*", "/", "&", "|"]; + +pub(super) fn lex(src: &str) -> Result, String> { + let chars: Vec = src.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + let mut line = 1; + let mut depth = 0usize; + while i < chars.len() { + let c = chars[i]; + match c { + '\n' => { + line += 1; + i += 1; + // Newlines inside brackets continue the expression. + if depth == 0 { + out.push(Token { tok: Tok::Newline, line }); + } + } + ' ' | '\t' | '\r' => i += 1, + ';' => { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } + '/' if chars.get(i + 1) == Some(&'/') => { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } + '"' => { + let mut s = String::new(); + i += 1; + loop { + match chars.get(i) { + None => return Err(format!("line {line}: unterminated string")), + Some('"') if chars.get(i + 1) == Some(&'"') => { + s.push('"'); + i += 2; + } + Some('"') => { + i += 1; + break; + } + Some(&ch) => { + if ch == '\n' { + line += 1; + } + s.push(ch); + i += 1; + } + } + if s.len() > MAX_STRING { + return Err(format!("line {line}: a string is too long")); + } + } + out.push(Token { tok: Tok::Str(s), line }); + } + c if c.is_ascii_digit() || (c == '.' && chars.get(i + 1).is_some_and(char::is_ascii_digit)) => { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || (chars[i] == '.' && chars.get(i + 1) != Some(&'.'))) { + i += 1; + } + if i < chars.len() && (chars[i] == 'e' || chars[i] == 'E') { + let mut j = i + 1; + if j < chars.len() && (chars[j] == '+' || chars[j] == '-') { + j += 1; + } + if j < chars.len() && chars[j].is_ascii_digit() { + i = j; + while i < chars.len() && chars[i].is_ascii_digit() { + i += 1; + } + } + } + let text: String = chars[start..i].iter().collect(); + let n: f64 = text.parse().map_err(|_| format!("line {line}: bad number {text}"))?; + out.push(Token { tok: Tok::Num(n), line }); + } + '!' => { + out.push(Token { tok: Tok::Ident("!".into()), line }); + i += 1; + } + c if c.is_alphabetic() || c == '_' || c == '$' => { + let start = i; + i += 1; + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '$') { + i += 1; + } + let text: String = chars[start..i].iter().collect(); + out.push(Token { tok: Tok::Ident(text), line }); + } + _ => { + let rest: String = chars[i..chars.len().min(i + 2)].iter().collect(); + let op = OPS.iter().find(|o| rest.starts_with(*o)).copied().ok_or_else(|| format!("line {line}: unexpected character {c:?}"))?; + match op { + "(" | "[" => depth += 1, + ")" | "]" => depth = depth.saturating_sub(1), + _ => {} + } + out.push(Token { tok: Tok::Op(op), line }); + i += op.len(); + } + } + } + out.push(Token { tok: Tok::Newline, line }); + out.push(Token { tok: Tok::Eof, line }); + Ok(out) +} diff --git a/crates/js/src/formcalc/parser.rs b/crates/js/src/formcalc/parser.rs new file mode 100644 index 0000000000..fceedadf5f --- /dev/null +++ b/crates/js/src/formcalc/parser.rs @@ -0,0 +1,451 @@ +use super::ast::{Expr, Idx, Stmt}; +use super::lexer::{Tok, Token, lex}; +use super::{Func, MAX_CHAIN, MAX_PARSE_DEPTH, Rc}; + +struct Parser { + toks: Vec, + at: usize, + depth: usize, + /// Operators and accessors chained so far in the expression being parsed. + chain: usize, +} + +fn kw(t: &Tok, word: &str) -> bool { + matches!(t, Tok::Ident(s) if s.eq_ignore_ascii_case(word)) +} + +impl Parser { + fn peek(&self) -> &Tok { + self.toks.get(self.at).map_or(&Tok::Eof, |t| &t.tok) + } + + fn line(&self) -> usize { + self.toks.get(self.at).map_or(0, |t| t.line) + } + + fn next(&mut self) -> Tok { + let t = self.peek().clone(); + self.at += 1; + t + } + + fn is_op(&self, op: &str) -> bool { + matches!(self.peek(), Tok::Op(o) if *o == op) + } + + fn eat_op(&mut self, op: &str) -> bool { + if self.is_op(op) { + self.at += 1; + true + } else { + false + } + } + + fn expect_op(&mut self, op: &str) -> Result<(), String> { + if self.eat_op(op) { Ok(()) } else { Err(format!("line {}: expected {op}", self.line())) } + } + + fn eat_kw(&mut self, word: &str) -> bool { + if kw(self.peek(), word) { + self.at += 1; + true + } else { + false + } + } + + fn expect_kw(&mut self, word: &str) -> Result<(), String> { + if self.eat_kw(word) { Ok(()) } else { Err(format!("line {}: expected {word}", self.line())) } + } + + fn skip_newlines(&mut self) { + while matches!(self.peek(), Tok::Newline) { + self.at += 1; + } + } + + fn enter(&mut self) -> Result<(), String> { + self.depth += 1; + if self.depth > MAX_PARSE_DEPTH { + return Err("the script is nested too deeply".into()); + } + Ok(()) + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + /// One more link in an operator or accessor chain. + fn link(&mut self) -> Result<(), String> { + self.chain += 1; + if self.chain > MAX_CHAIN { + return Err(format!("line {}: an expression is too long", self.line())); + } + Ok(()) + } + + /// Statements up to one of `enders` (not consumed). + fn block(&mut self, enders: &[&str]) -> Result, String> { + self.enter()?; + let mut out = Vec::new(); + loop { + self.skip_newlines(); + match self.peek() { + Tok::Eof => break, + Tok::Ident(w) if enders.iter().any(|e| w.eq_ignore_ascii_case(e)) => break, + _ => out.push(self.statement()?), + } + } + self.leave(); + Ok(out) + } + + fn statement(&mut self) -> Result { + let line = self.line(); + match self.peek().clone() { + Tok::Ident(w) if w.eq_ignore_ascii_case("var") => { + self.at += 1; + let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after var")) }; + let init = if self.eat_op("=") { Some(self.top_expr()?) } else { None }; + Ok(Stmt::Var(name, init)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("if") => { + self.at += 1; + let mut arms = Vec::new(); + let cond = self.top_expr()?; + self.expect_kw("then")?; + let body = self.block(&["elseif", "else", "endif"])?; + arms.push((cond, body)); + let mut otherwise = None; + loop { + if self.eat_kw("elseif") { + let c = self.top_expr()?; + self.expect_kw("then")?; + let b = self.block(&["elseif", "else", "endif"])?; + arms.push((c, b)); + } else if self.eat_kw("else") { + otherwise = Some(self.block(&["endif"])?); + } else { + self.expect_kw("endif")?; + break; + } + } + Ok(Stmt::If(arms, otherwise)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("while") => { + self.at += 1; + let cond = self.top_expr()?; + self.expect_kw("do")?; + let body = self.block(&["endwhile"])?; + self.expect_kw("endwhile")?; + Ok(Stmt::While(cond, body)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("for") => { + self.at += 1; + let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after for")) }; + self.expect_op("=")?; + let from = self.top_expr()?; + let up = if self.eat_kw("upto") { + true + } else if self.eat_kw("downto") { + false + } else { + return Err(format!("line {line}: expected upto or downto")); + }; + let to = self.top_expr()?; + let step = if self.eat_kw("step") { Some(self.top_expr()?) } else { None }; + self.expect_kw("do")?; + let body = self.block(&["endfor"])?; + self.expect_kw("endfor")?; + Ok(Stmt::For { var, from, to, up, step, body }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("foreach") => { + self.at += 1; + let Tok::Ident(var) = self.next() else { return Err(format!("line {line}: expected a variable after foreach")) }; + self.expect_kw("in")?; + self.expect_op("(")?; + let mut list = Vec::new(); + if !self.is_op(")") { + loop { + list.push(self.top_expr()?); + if !self.eat_op(",") { + break; + } + } + } + self.expect_op(")")?; + self.expect_kw("do")?; + let body = self.block(&["endfor"])?; + self.expect_kw("endfor")?; + Ok(Stmt::Foreach { var, list, body }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("func") => { + self.at += 1; + let Tok::Ident(name) = self.next() else { return Err(format!("line {line}: expected a name after func")) }; + self.expect_op("(")?; + let mut params = Vec::new(); + if !self.is_op(")") { + loop { + let Tok::Ident(p) = self.next() else { return Err(format!("line {line}: expected a parameter name")) }; + params.push(p); + if !self.eat_op(",") { + break; + } + } + } + self.expect_op(")")?; + self.expect_kw("do")?; + let body = self.block(&["endfunc"])?; + self.expect_kw("endfunc")?; + Ok(Stmt::Func { name, f: Rc::new(Func { params, body }) }) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("return") => { + self.at += 1; + let v = if matches!(self.peek(), Tok::Newline | Tok::Eof) { None } else { Some(self.top_expr()?) }; + Ok(Stmt::Return(v)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("break") => { + self.at += 1; + Ok(Stmt::Break) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("continue") => { + self.at += 1; + Ok(Stmt::Continue) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("exit") => { + self.at += 1; + Ok(Stmt::Exit) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("throw") => { + self.at += 1; + Ok(Stmt::Throw(self.top_expr()?)) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("do") => { + // `do … end`: a block as one statement. + self.at += 1; + let body = self.block(&["end"])?; + self.expect_kw("end")?; + Ok(Stmt::If(vec![(Expr::Num(1.0), body)], None)) + } + _ => { + let e = self.top_expr()?; + if self.eat_op("=") { + let v = self.top_expr()?; + return Ok(Stmt::Assign(e, v)); + } + Ok(Stmt::Expr(e)) + } + } + } + + /// An expression that starts a tree of its own (a condition, a bound, a statement's + /// expression): its operator chain is counted from zero, whatever came before it. + fn top_expr(&mut self) -> Result { + self.chain = 0; + self.expr() + } + + fn expr(&mut self) -> Result { + self.enter()?; + let e = self.or_expr(); + self.leave(); + e + } + + fn or_expr(&mut self) -> Result { + let mut l = self.and_expr()?; + loop { + if self.eat_op("|") || self.eat_kw("or") { + self.link()?; + let r = self.and_expr()?; + l = Expr::Bin("|", Box::new(l), Box::new(r)); + } else { + return Ok(l); + } + } + } + + fn and_expr(&mut self) -> Result { + let mut l = self.cmp_expr()?; + loop { + if self.eat_op("&") || self.eat_kw("and") { + self.link()?; + let r = self.cmp_expr()?; + l = Expr::Bin("&", Box::new(l), Box::new(r)); + } else { + return Ok(l); + } + } + } + + fn cmp_expr(&mut self) -> Result { + let mut l = self.add_expr()?; + loop { + let op = match self.peek() { + Tok::Op("==") => "==", + Tok::Op("<>") => "<>", + Tok::Op("<=") => "<=", + Tok::Op(">=") => ">=", + Tok::Op("<") => "<", + Tok::Op(">") => ">", + Tok::Ident(w) if w.eq_ignore_ascii_case("eq") => "==", + Tok::Ident(w) if w.eq_ignore_ascii_case("ne") => "<>", + Tok::Ident(w) if w.eq_ignore_ascii_case("le") => "<=", + Tok::Ident(w) if w.eq_ignore_ascii_case("ge") => ">=", + Tok::Ident(w) if w.eq_ignore_ascii_case("lt") => "<", + Tok::Ident(w) if w.eq_ignore_ascii_case("gt") => ">", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.add_expr()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn add_expr(&mut self) -> Result { + let mut l = self.mul_expr()?; + loop { + let op = match self.peek() { + Tok::Op("+") => "+", + Tok::Op("-") => "-", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.mul_expr()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn mul_expr(&mut self) -> Result { + let mut l = self.unary()?; + loop { + let op = match self.peek() { + Tok::Op("*") => "*", + Tok::Op("/") => "/", + _ => return Ok(l), + }; + self.at += 1; + self.link()?; + let r = self.unary()?; + l = Expr::Bin(op, Box::new(l), Box::new(r)); + } + } + + fn unary(&mut self) -> Result { + self.enter()?; + let e = if self.eat_op("-") { + Ok(Expr::Neg(Box::new(self.unary()?))) + } else if self.eat_op("+") { + self.unary() + } else if self.eat_kw("not") || matches!(self.peek(), Tok::Ident(w) if w == "!") { + if matches!(self.peek(), Tok::Ident(w) if w == "!") { + self.at += 1; + } + Ok(Expr::Not(Box::new(self.unary()?))) + } else { + self.postfix() + }; + self.leave(); + e + } + + fn postfix(&mut self) -> Result { + let mut e = self.primary()?; + loop { + if matches!(self.peek(), Tok::Op("." | ".." | "[" | "(")) { + self.link()?; + } + if self.eat_op(".") { + let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after .", self.line())) }; + e = Expr::Member(Box::new(e), name); + } else if self.eat_op("..") { + let Tok::Ident(name) = self.next() else { return Err(format!("line {}: expected a name after ..", self.line())) }; + e = Expr::Descend(Box::new(e), name); + } else if self.eat_op("[") { + let sign = match self.peek() { + Tok::Op("-") => Some(-1), + Tok::Op("+") => Some(1), + _ => None, + }; + let relative = sign.and_then(|sign| match (self.toks.get(self.at + 1).map(|t| &t.tok), self.toks.get(self.at + 2).map(|t| &t.tok)) { + (Some(Tok::Num(n)), Some(Tok::Op("]"))) if n.is_finite() && *n >= 0.0 && *n < 1e6 => Some(sign * (*n as i64)), + _ => None, + }); + let idx = if let Some(k) = relative { + self.at += 2; + Idx::Rel(k) + } else if self.eat_op("*") { + Idx::All + } else { + Idx::Abs(Box::new(self.expr()?)) + }; + self.expect_op("]")?; + e = Expr::Index(Box::new(e), idx); + } else if self.eat_op("(") { + let mut args = Vec::new(); + self.skip_newlines(); + if !self.is_op(")") { + loop { + args.push(self.expr()?); + self.skip_newlines(); + if !self.eat_op(",") { + break; + } + self.skip_newlines(); + } + } + self.expect_op(")")?; + e = Expr::Call(Box::new(e), args); + } else { + return Ok(e); + } + } + } + + fn primary(&mut self) -> Result { + let line = self.line(); + match self.next() { + Tok::Num(n) => Ok(Expr::Num(n)), + Tok::Str(s) => Ok(Expr::Str(s)), + Tok::Op("(") => { + self.skip_newlines(); + let e = self.expr()?; + self.skip_newlines(); + self.expect_op(")")?; + Ok(e) + } + Tok::Ident(w) if w.eq_ignore_ascii_case("null") => Ok(Expr::Null), + Tok::Ident(w) if w.eq_ignore_ascii_case("nan") => Ok(Expr::Num(f64::NAN)), + Tok::Ident(w) if w.eq_ignore_ascii_case("infinity") => Ok(Expr::Num(f64::INFINITY)), + Tok::Ident(w) if w.eq_ignore_ascii_case("true") => Ok(Expr::Num(1.0)), + Tok::Ident(w) if w.eq_ignore_ascii_case("false") => Ok(Expr::Num(0.0)), + Tok::Ident(w) => Ok(Expr::Ident(w)), + t => Err(format!("line {line}: unexpected {}", describe(&t))), + } + } +} + +fn describe(t: &Tok) -> String { + match t { + Tok::Num(n) => format!("number {n}"), + Tok::Str(s) => format!("string {s:?}"), + Tok::Ident(s) => s.clone(), + Tok::Op(o) => format!("{o:?}"), + Tok::Newline => "end of line".into(), + Tok::Eof => "end of script".into(), + } +} + +pub(super) fn parse(src: &str) -> Result, String> { + let toks = lex(src)?; + let mut p = Parser { toks, at: 0, depth: 0, chain: 0 }; + let body = p.block(&[])?; + match p.peek() { + Tok::Eof => Ok(body), + t => Err(format!("line {}: unexpected {}", p.line(), describe(t))), + } +} diff --git a/crates/js/src/formcalc/value.rs b/crates/js/src/formcalc/value.rs new file mode 100644 index 0000000000..16705e67ec --- /dev/null +++ b/crates/js/src/formcalc/value.rs @@ -0,0 +1,92 @@ +// ── values ────────────────────────────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Val { + Null, + Num(f64), + Str(String), + /// A form object, by index in the host. + Node(usize), + /// Several objects or values (`Row[*]`, `Row[*].Amount`). + List(Vec), + Host, + Layout, + Event, + /// `xfa` itself (`xfa.form`, `xfa.host`, …). + Xfa, + /// The instance manager of `name` under `parent`. + Im(usize, String), + /// A property object whose contents are not modelled (`$.border.fill…`): reads give + /// another sink, writes change nothing. + Sink, +} + +/// A number as FormCalc prints it: integers without a fraction, otherwise up to 15 digits. +pub(super) fn num_text(n: f64) -> String { + if !n.is_finite() { + return if n.is_nan() { + "NaN".into() + } else if n > 0.0 { + "Infinity".into() + } else { + "-Infinity".into() + }; + } + if n.fract() == 0.0 && n.abs() < 1e15 { + return format!("{}", n as i64); + } + let s = format!("{n:.12}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } +} + +/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). +pub(super) fn fixed(v: f64, decimals: usize) -> String { + let k = 10f64.powi(decimals.min(20) as i32); + let scaled = (v * k).round() / k; + // A small negative value rounds to -0.0, which would print as "-0". + let scaled = if scaled == 0.0 { 0.0 } else { scaled }; + format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) +} + +/// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. +pub(super) fn parse_num(s: &str) -> Option { + let t = s.trim(); + if t.is_empty() { + return None; + } + let mut end = 0; + let b = t.as_bytes(); + let mut seen_digit = false; + let mut seen_dot = false; + let mut seen_e = false; + let mut i = 0; + if i < b.len() && (b[i] == b'-' || b[i] == b'+') { + i += 1; + } + while i < b.len() { + let c = b[i]; + if c.is_ascii_digit() { + seen_digit = true; + end = i + 1; + } else if c == b'.' && !seen_dot && !seen_e { + seen_dot = true; + } else if (c == b'e' || c == b'E') && seen_digit && !seen_e { + seen_e = true; + if i + 1 < b.len() && (b[i + 1] == b'-' || b[i + 1] == b'+') { + i += 1; + } + } else { + break; + } + i += 1; + } + if !seen_digit { + return None; + } + t.get(..end).and_then(|p| p.parse::().ok()).filter(|v| v.is_finite()) +} + +pub(super) fn is_numeric_text(s: &str) -> bool { + s.trim().parse::().is_ok_and(f64::is_finite) +} diff --git a/crates/js/src/formcalc/words.rs b/crates/js/src/formcalc/words.rs new file mode 100644 index 0000000000..d0d119abb5 --- /dev/null +++ b/crates/js/src/formcalc/words.rs @@ -0,0 +1,75 @@ +const ONES: [&str; 20] = [ + "Zero", + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Eight", + "Nine", + "Ten", + "Eleven", + "Twelve", + "Thirteen", + "Fourteen", + "Fifteen", + "Sixteen", + "Seventeen", + "Eighteen", + "Nineteen", +]; + +const TENS: [&str; 10] = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]; + +fn words_below_1000(n: u64) -> String { + let mut parts = Vec::new(); + if n >= 100 { + parts.push(format!("{} Hundred", ONES[(n / 100) as usize])); + } + let r = n % 100; + if r >= 20 { + parts.push(if r.is_multiple_of(10) { + TENS[(r / 10) as usize].to_string() + } else { + format!("{}-{}", TENS[(r / 10) as usize], ONES[(r % 10) as usize]) + }); + } else if r > 0 || n == 0 { + parts.push(ONES[r as usize].to_string()); + } + parts.join(" ") +} + +/// `WordNum`: an integer in English words (up to the billions). +pub(super) fn word_num(n: f64, cents: bool) -> String { + if !n.is_finite() || !(0.0..1e12).contains(&n) { + return String::new(); + } + let cents_total = (n * 100.0).round() as u64; + let whole = if cents { cents_total / 100 } else { n.trunc() as u64 }; + if whole >= 1_000_000_000_000 { + // Rounding to cents carried past the supported range. + return String::new(); + } + let mut parts = Vec::new(); + let groups = [(1_000_000_000u64, "Billion"), (1_000_000, "Million"), (1_000, "Thousand")]; + let mut rest = whole; + for (unit, name) in groups { + if rest >= unit { + parts.push(format!("{} {name}", words_below_1000(rest / unit))); + rest %= unit; + } + } + if rest > 0 || parts.is_empty() { + parts.push(words_below_1000(rest)); + } + let mut out = parts.join(" "); + if cents { + let c = cents_total % 100; + let dollars = if whole == 1 { "Dollar" } else { "Dollars" }; + let cent = if c == 1 { "Cent" } else { "Cents" }; + out.push_str(&format!(" {dollars} And {c:02} {cent}")); + } + out +} diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs new file mode 100644 index 0000000000..3b9ebe6028 --- /dev/null +++ b/crates/js/src/globals.rs @@ -0,0 +1,297 @@ +use super::document::{field_object, get_field, get_nth_field_name, noop, num_fields, page_num_get, page_num_set, print, reset_form, submit_form}; +use super::format::{printd, printf, printx, scand}; +use super::host::{arg, function, host, s, text, text_line}; +use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; +use super::{ + Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, + js_string, +}; + +// ── app, util, console ────────────────────────────────────────────────────────────────────── + +/// A message argument: a string, or an object with `cMsg`, cut to a message's length. +fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { + match named_params(v, key, ctx)? { + Some(o) => { + let m = o.get(JsString::from(key), ctx)?; + text_line(&m, ctx) + } + None => text_line(v, ctx), + } +} + +/// `v` as a method's single object of named parameters: an object (not an array or function) +/// that has `key`, the parameter every such call names. Any other value, a `new String(…)` or a +/// caught `Error` included, is the first positional argument, read as text. +fn named_params(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult> { + match v.as_object() { + Some(o) if !o.is_array() && !o.is_callable() && o.has_property(JsString::from(key), ctx)? => Ok(Some(o.clone())), + _ => Ok(None), + } +} + +/// A message's title: `v` as text cut to a message's length, `None` when it is missing or empty. +fn title(v: &JsValue, ctx: &mut Context) -> JsResult> { + Ok(Some(text_line(v, ctx)?).filter(|t| !t.is_empty())) +} + +/// An icon or buttons number: `v` as a number, `None` when it is missing or isn't one (a value +/// that can't be converted counts as missing, so a bad argument never stops the message). +fn code(v: &JsValue, ctx: &mut Context) -> Option { + if v.is_undefined() || v.is_null() { + return None; + } + v.to_number(ctx).ok() +} + +/// `app.alert`'s message: `(cMsg, nIcon, nType, cTitle)`, or one object with those keys (as +/// the JavaScript for Acrobat API Reference allows for every method with named parameters). +pub(crate) fn alert_message(args: &[JsValue], ctx: &mut Context) -> JsResult { + let first = arg(args, 0); + let (text, icon, buttons, t) = match named_params(&first, "cMsg", ctx)? { + Some(o) => { + let get = |k: &str, ctx: &mut Context| o.get(JsString::from(k), ctx); + (get("cMsg", ctx)?, get("nIcon", ctx)?, get("nType", ctx)?, get("cTitle", ctx)?) + } + None => (first, arg(args, 1), arg(args, 2), arg(args, 3)), + }; + Ok(Message { + text: text_line(&text, ctx)?, + title: title(&t, ctx)?, + icon: MessageIcon::from_code(code(&icon, ctx)), + buttons: MessageButtons::from_code(code(&buttons, ctx)), + }) +} + +/// `xfa.host.messageBox`'s message: `(message, title, icon, buttons)`, as the XFA +/// specification orders them. +pub(crate) fn message_box_message(args: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(Message { + text: text_line(&arg(args, 0), ctx)?, + title: title(&arg(args, 1), ctx)?, + icon: MessageIcon::from_code(code(&arg(args, 2), ctx)), + buttons: MessageButtons::from_code(code(&arg(args, 3), ctx)), + }) +} + +/// `app.alert(cMsg, nIcon, nType, cTitle)`: records the message with its title, icon and buttons, +/// given in that order or as one object with those keys, and returns 1 (the OK button). +fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = alert_message(args, ctx)?; + host(ctx)?.borrow_mut().alert(m); + // Always the OK button for now: the viewer can't ask the user and run the script on with + // the answer yet (#4291). + Ok(JsValue::from(1)) +} + +/// `app.beep()`: queues a request to beep. +fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx)?.borrow_mut().request(Request::Beep); + Ok(JsValue::undefined()) +} + +/// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object +/// with `cURL`. +fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let u = message(&arg(args, 0), "cURL", ctx)?; + host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); + Ok(JsValue::undefined()) +} + +/// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print +/// and Save As. Other items do nothing. +pub(crate) fn menu_item(name: &str) -> Option { + Some(match name { + "ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments), + "ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks), + "ShowHideThumbnails" => Request::ShowPanel(Panel::Pages), + "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), + "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), + "Print" => Request::Print, + // Save opens Save As too, as it always has for XFA forms' buttons: a click on a form + // never overwrites the file unasked. + "SaveAs" | "Save" => Request::SaveAs, + _ => return None, + }) +} + +/// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens +/// Save As too); an unknown name does nothing. +fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let item = text(&arg(args, 0), ctx)?; + if let Some(r) = menu_item(&item) { + host(ctx)?.borrow_mut().request(r); + } + Ok(JsValue::undefined()) +} + +/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to +/// type one. +fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + // No one to answer: as if the dialog was cancelled. + Ok(JsValue::null()) +} + +fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = text_line(&arg(args, 0), ctx)?; + host(ctx)?.borrow_mut().print(m); + Ok(JsValue::undefined()) +} + +fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + host(ctx)?.borrow_mut().console.clear(); + Ok(JsValue::undefined()) +} + +fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + // Timers never fire in the sandbox; scripts get an object they can pass to clear*. + Ok(ObjectInitializer::new(ctx).build().into()) +} + +/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as +/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components. +fn color_object(ctx: &mut Context) -> JsObject { + let mut entries: Vec<(&str, Vec)> = vec![ + ("transparent", vec![s("T")]), + ("black", vec![s("G"), 0.into()]), + ("white", vec![s("G"), 1.into()]), + ("red", vec![s("RGB"), 1.into(), 0.into(), 0.into()]), + ("green", vec![s("RGB"), 0.into(), 1.into(), 0.into()]), + ("blue", vec![s("RGB"), 0.into(), 0.into(), 1.into()]), + ("cyan", vec![s("CMYK"), 1.into(), 0.into(), 0.into(), 0.into()]), + ("magenta", vec![s("CMYK"), 0.into(), 1.into(), 0.into(), 0.into()]), + ("yellow", vec![s("CMYK"), 0.into(), 0.into(), 1.into(), 0.into()]), + ("dkGray", vec![s("G"), JsValue::from(0.25)]), + ("gray", vec![s("G"), JsValue::from(0.5)]), + ("ltGray", vec![s("G"), JsValue::from(0.75)]), + ]; + let arrays: Vec<(&str, JsValue)> = entries.drain(..).map(|(k, v)| (k, JsArray::from_iter(v, ctx).into())).collect(); + let mut o = ObjectInitializer::new(ctx); + for (k, v) in arrays { + o.property(JsString::from(k), v, Attribute::all()); + } + o.build() +} + +/// The XFA version scripts see as `xfa_version`. +const XFA_VERSION: f64 = 3.5; + +/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, +/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, +/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and +/// `color` objects. +pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { + let globals: [(&str, usize, NativeFunction); 9] = [ + ("getField", 1, NativeFunction::from_fn_ptr(get_field)), + ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), + ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), + ("print", 1, NativeFunction::from_fn_ptr(print)), + ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), + ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), + ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), + ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), + ("dirty", 0, NativeFunction::from_fn_ptr(noop)), + ]; + for (name, len, f) in globals { + ctx.register_global_callable(JsString::from(name), len, f)?; + } + let global = ctx.global_object(); + let rw = Attribute::CONFIGURABLE; + let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields)); + let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get)); + let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set)); + let (file, pages, info, xfa) = { + let h = host(ctx)?.borrow(); + (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone(), h.doc.xfa) + }; + let mut io = ObjectInitializer::new(ctx); + for (k, v) in &info { + io.property(JsString::from(k.as_str()), s(v), Attribute::all()); + } + let info = io.build(); + use boa_engine::property::PropertyDescriptor; + global.define_property_or_throw(js_string!("numFields"), PropertyDescriptor::builder().get(nf).configurable(true), ctx)?; + global.define_property_or_throw(js_string!("pageNum"), PropertyDescriptor::builder().get(pg).set(ps).configurable(true), ctx)?; + ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?; + ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; + ctx.register_global_property(js_string!("info"), info, rw)?; + // XFA forms check both before they run: every document sees the XFA version, and only an + // XFA form sees `xfa_installed`. + ctx.register_global_property(js_string!("xfa_version"), JsValue::from(XFA_VERSION), rw)?; + if xfa { + ctx.register_global_property(js_string!("xfa_installed"), JsValue::from(true), rw)?; + } + + let app = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) + .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) + .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) + .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) + .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) + .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("formsVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("language"), s("ENU"), Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("app"), app, rw)?; + let util = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) + .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) + .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) + .build(); + ctx.register_global_property(js_string!("util"), util, rw)?; + let console = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(println), js_string!("println"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) + .function(NativeFunction::from_fn_ptr(console_clear), js_string!("clear"), 0) + .build(); + ctx.register_global_property(js_string!("console"), console, rw)?; + let display = ObjectInitializer::new(ctx) + .property(js_string!("visible"), DISPLAY_VISIBLE, Attribute::READONLY) + .property(js_string!("hidden"), DISPLAY_HIDDEN, Attribute::READONLY) + .property(js_string!("noPrint"), DISPLAY_NO_PRINT, Attribute::READONLY) + .property(js_string!("noView"), DISPLAY_NO_VIEW, Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("display"), display, rw)?; + let color = color_object(ctx); + ctx.register_global_property(js_string!("color"), color, rw)?; + Ok(()) +} + +pub(crate) fn platform() -> &'static str { + if cfg!(target_os = "macos") { + "MAC" + } else if cfg!(target_os = "windows") { + "WIN" + } else { + "UNIX" + } +} + +pub(crate) fn event_object(ctx: &mut Context, e: &Event) -> JsObject { + let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); + ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&e.name), Attribute::all()) + .property(js_string!("type"), s(&e.kind), Attribute::all()) + .property(js_string!("value"), s(&e.value), Attribute::all()) + .property(js_string!("change"), s(&e.change), Attribute::all()) + .property(js_string!("changeEx"), s(&e.change), Attribute::all()) + .property(js_string!("willCommit"), e.will_commit, Attribute::all()) + .property(js_string!("rc"), true, Attribute::all()) + .property(js_string!("target"), target, Attribute::all()) + .property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all()) + .property(js_string!("selStart"), 0, Attribute::all()) + .property(js_string!("selEnd"), 0, Attribute::all()) + .property(js_string!("commitKey"), 0, Attribute::all()) + .property(js_string!("modifier"), false, Attribute::all()) + .property(js_string!("shift"), false, Attribute::all()) + .build() +} diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs new file mode 100644 index 0000000000..8b3b1e6f1c --- /dev/null +++ b/crates/js/src/host.rs @@ -0,0 +1,202 @@ +use super::model::{DocInfo, FieldState, FieldType, Request}; +use super::run::MAX_ARRAY_LENGTH; +use super::{ + Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, Message, NativeFunction, + RefCell, xfa, +}; + +pub(crate) struct Host { + pub(crate) fields: Arc>, + pub(crate) changed: BTreeSet, + pub(crate) alerts: Vec, + pub(crate) console: Vec, + pub(crate) requests: Vec, + pub(crate) doc: DocInfo, + /// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`]. + pub(crate) kept: usize, + /// Alerts, console lines and requests the limits left out. + pub(crate) dropped: usize, +} + +impl Host { + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped = self.dropped.saturating_add(1); + false + } + } + } + + /// Keep message `m`, its text and title charged to the run's byte budget, up to + /// [`xfa::MAX_ALERTS`] messages. + pub(crate) fn alert(&mut self, m: Message) { + if self.alerts.len() >= xfa::MAX_ALERTS { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.bytes()) { + self.alerts.push(m); + } + } + + pub(crate) fn print(&mut self, m: String) { + if self.console.len() >= xfa::MAX_CONSOLE { + self.dropped = self.dropped.saturating_add(1); + } else if self.room(m.len()) { + self.console.push(m); + } + } + + /// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`] + /// requests and the run's byte budget. + pub(crate) fn request(&mut self, r: Request) { + if self.requests.len() >= xfa::MAX_EFFECTS { + self.dropped = self.dropped.saturating_add(1); + return; + } + let r = match r { + Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()), + Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)), + Request::Submit(u) => Request::Submit(xfa::clip(u)), + Request::Focus(n) => Request::Focus(xfa::clip(n)), + other => other, + }; + let bytes = match &r { + Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + if self.room(bytes) { + self.requests.push(r); + } + } +} + +pub(crate) type Shared = RefCell; + +/// The host state, installed before scripts run. +pub(crate) fn host(ctx: &Context) -> JsResult<&Shared> { + ctx.get_data::().ok_or_else(|| error("the script host is not set up")) +} + +pub(crate) fn s(v: &str) -> JsValue { + JsValue::from(JsString::from(v)) +} + +pub(crate) fn text(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + Ok(v.to_string(ctx)?.to_std_string_escaped()) +} + +/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer +/// string. +pub(crate) fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult { + if v.is_undefined() || v.is_null() { + return Ok(String::new()); + } + let js = v.to_string(ctx)?; + // Two UTF-16 units per character at most: the clip still sees more than a line's worth. + let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2); + let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js }; + Ok(xfa::clip(js.to_std_string_escaped())) +} + +pub(crate) fn arg(args: &[JsValue], i: usize) -> JsValue { + args.get(i).cloned().unwrap_or_default() +} + +pub(crate) fn error(msg: impl Into) -> JsError { + JsNativeError::typ().with_message(msg.into()).into() +} + +/// A JavaScript number for a text value that reads as one, as Acrobat's `field.value` gives. +pub(crate) fn as_number(t: &str) -> Option { + let t = t.trim(); + if t.is_empty() || t.starts_with('+') || t.len() > 1 && t.starts_with('0') && !t.starts_with("0.") { + return None; + } + t.parse::().ok().filter(|v| v.is_finite()) +} + +pub(crate) fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction { + FunctionObjectBuilder::new(ctx.realm(), f).build() +} + +pub(crate) fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { + if let Some(o) = v.as_object() + && o.is_array() + { + let a = JsArray::from_object(o.clone())?; + let n = a.length(ctx)?; + if n > MAX_ARRAY_LENGTH { + return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}"))); + } + let mut out = Vec::new(); + for i in 0..n { + let x = a.get(i, ctx)?; + out.push(text(&x, ctx)?); + } + return Ok(out); + } + Ok(if v.is_undefined() || v.is_null() { Vec::new() } else { vec![text(v, ctx)?] }) +} + +pub(crate) fn colour(c: &Option>, ctx: &mut Context) -> JsValue { + match c { + None => JsArray::from_iter([s("T")], ctx).into(), + Some(parts) => { + let vals: Vec = + parts.iter().enumerate().map(|(i, p)| if i == 0 { s(p) } else { JsValue::from(p.parse::().unwrap_or(0.0)) }).collect(); + JsArray::from_iter(vals, ctx).into() + } + } +} + +// ── field objects ─────────────────────────────────────────────────────────────────────────── + +fn field_index(ctx: &Context, name: &JsString) -> JsResult { + let n = name.to_std_string_escaped(); + host(ctx)?.borrow().fields.iter().position(|f| f.name == n).ok_or_else(|| error(format!("no field named {n}"))) +} + +pub(crate) fn read(ctx: &Context, name: &JsString) -> JsResult { + let i = field_index(ctx, name)?; + Ok(host(ctx)?.borrow().fields[i].clone()) +} + +pub(crate) fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> { + let i = field_index(ctx, name)?; + let mut h = host(ctx)?.borrow_mut(); + let field = Arc::make_mut(&mut h.fields).get_mut(i).ok_or_else(|| error("the field is no longer available"))?; + change(field); + h.changed.insert(i); + Ok(()) +} + +pub(crate) fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue { + match f.kind { + FieldType::CheckBox | FieldType::RadioButton => s(f.value.first().map_or("Off", String::as_str)), + FieldType::ListBox if f.value.len() > 1 => { + let vals: Vec = f.value.iter().map(|v| s(v)).collect(); + JsArray::from_iter(vals, ctx).into() + } + _ => { + let v = f.single(); + as_number(&v).map_or_else(|| s(&v), JsValue::from) + } + } +} + +pub(crate) fn set_value(f: &mut FieldState, vals: Vec) { + f.value = match f.kind { + FieldType::CheckBox | FieldType::RadioButton => vals.into_iter().filter(|v| v != "Off" && !v.is_empty()).take(1).collect(), + FieldType::ListBox => vals, + _ => vals.into_iter().take(1).collect(), + }; +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index d8d9fa8bac..93f57e7528 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -13,8 +13,8 @@ //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, //! `setFocus`. -//! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`, -//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`, +//! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`, +//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`, `scand`), `console` (`println`, //! `show`, `clear`), `display` and `color`. //! //! The engine is a sandbox: no file, network or timer access exists, and loops and recursion @@ -25,1144 +25,37 @@ use std::cell::RefCell; use std::collections::BTreeSet; +use std::sync::Arc; -use boa_engine::object::builtins::{JsArray, JsFunction}; +use boa_engine::context::HostHooks; +use boa_engine::object::builtins::{JsArray, JsDate, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; +use boa_engine::realm::Realm; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; -/// The kinds of field, as `field.type` names them. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FieldType { - Text, - CheckBox, - RadioButton, - ComboBox, - ListBox, - Button, - Signature, -} - -impl FieldType { - pub fn name(self) -> &'static str { - match self { - FieldType::Text => "text", - FieldType::CheckBox => "checkbox", - FieldType::RadioButton => "radiobutton", - FieldType::ComboBox => "combobox", - FieldType::ListBox => "listbox", - FieldType::Button => "button", - FieldType::Signature => "signature", - } - } -} - -/// `display.*`: visible, hidden, noPrint, noView. -pub const DISPLAY_VISIBLE: i32 = 0; -pub const DISPLAY_HIDDEN: i32 = 1; -pub const DISPLAY_NO_PRINT: i32 = 2; -pub const DISPLAY_NO_VIEW: i32 = 3; - -/// A field as scripts see it. -#[derive(Clone, Debug, PartialEq)] -pub struct FieldState { - pub name: String, - pub kind: FieldType, - /// Text: the text. Check box / radio: the selected export value (empty when off). Choice: - /// the selected export values. - pub value: Vec, - pub default: Vec, - pub readonly: bool, - pub required: bool, - pub display: i32, - /// Choice options (export value, display text); check boxes and radios: their export - /// values, one per widget. - pub options: Vec<(String, String)>, - pub char_limit: Option, - /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. - pub text_color: Option>, - pub fill_color: Option>, -} - -impl FieldState { - pub fn new(name: impl Into, kind: FieldType, value: Vec) -> FieldState { - FieldState { - name: name.into(), - kind, - value, - default: Vec::new(), - readonly: false, - required: false, - display: DISPLAY_VISIBLE, - options: Vec::new(), - char_limit: None, - text_color: None, - fill_color: None, - } - } - - fn single(&self) -> String { - self.value.first().cloned().unwrap_or_default() - } -} - -/// The event a script runs for. -#[derive(Clone, Debug, PartialEq)] -pub struct Event { - /// `Keystroke`, `Validate`, `Calculate`, `Format`, `Mouse Up`, `Open`, … - pub name: String, - /// `Field`, `Doc`, `Page`, … - pub kind: String, - /// The field the event is for. - pub target: Option, - pub value: String, - pub change: String, - pub will_commit: bool, -} - -impl Event { - /// A field event (`event.type` = "Field"). - pub fn field(name: &str, target: &str, value: &str) -> Event { - Event { name: name.into(), kind: "Field".into(), target: Some(target.into()), value: value.into(), change: String::new(), will_commit: true } - } - - /// A document event (`event.type` = "Doc"), e.g. Open or WillSave. - pub fn doc(name: &str) -> Event { - Event { name: name.into(), kind: "Doc".into(), target: None, value: String::new(), change: String::new(), will_commit: false } - } -} - -/// About the document, for `this.numPages`, `this.info` and the like. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct DocInfo { - pub file_name: String, - pub num_pages: usize, - /// The current page, 0-based. - pub page: usize, - /// Document information (`this.info.title` etc.): lower-case key, value. - pub info: Vec<(String, String)>, -} - -/// Something a script asked the viewer to do. -#[derive(Clone, Debug, PartialEq)] -pub enum Request { - /// `this.resetForm([names])` (empty: every field). - Reset(Vec), - Print, - /// `this.pageNum = n` (0-based). - GoToPage(usize), - LaunchUrl(String), - /// `this.submitForm(url)`: never sent on PdfCraft's own. - Submit(String), - /// `field.setFocus()`. - Focus(String), - Beep, - /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). - SaveAs, -} - -/// What running a script did. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Outcome { - /// `event.rc`: `false` rejects the keystroke or value. - pub rc: bool, - /// `event.value` after the script. - pub value: String, - pub change: String, - /// Fields whose value or properties the script changed, as they are now. - pub changed: Vec, - pub alerts: Vec, - pub console: Vec, - pub requests: Vec, - /// The script's error (syntax error, exception, or a limit reached), if it failed. - pub error: Option, - /// The script's completion value (what the console prints), unless undefined. - pub result: Option, -} - -/// Bounds that keep a script from hanging the viewer. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Limits { - pub loop_iterations: u64, - pub recursion: usize, -} - -impl Default for Limits { - fn default() -> Self { - Limits { loop_iterations: 1_000_000, recursion: 256 } - } -} - -struct Host { - fields: Vec, - changed: BTreeSet, - alerts: Vec, - console: Vec, - requests: Vec, - doc: DocInfo, -} - -type Shared = RefCell; - -/// The host state, installed before scripts run. -fn host(ctx: &Context) -> JsResult<&Shared> { - ctx.get_data::().ok_or_else(|| error("the script host is not set up")) -} - -fn s(v: &str) -> JsValue { - JsValue::from(JsString::from(v)) -} - -fn text(v: &JsValue, ctx: &mut Context) -> JsResult { - if v.is_undefined() || v.is_null() { - return Ok(String::new()); - } - Ok(v.to_string(ctx)?.to_std_string_escaped()) -} - -fn arg(args: &[JsValue], i: usize) -> JsValue { - args.get(i).cloned().unwrap_or_default() -} - -fn error(msg: impl Into) -> JsError { - JsNativeError::typ().with_message(msg.into()).into() -} - -/// A JavaScript number for a text value that reads as one, as Acrobat's `field.value` gives. -fn as_number(t: &str) -> Option { - let t = t.trim(); - if t.is_empty() || t.starts_with('+') || t.len() > 1 && t.starts_with('0') && !t.starts_with("0.") { - return None; - } - t.parse::().ok().filter(|v| v.is_finite()) -} - -fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction { - FunctionObjectBuilder::new(ctx.realm(), f).build() -} - -fn strings(v: &JsValue, ctx: &mut Context) -> JsResult> { - if let Some(o) = v.as_object() - && o.is_array() - { - let a = JsArray::from_object(o.clone())?; - let n = a.length(ctx)?; - if n > MAX_ARRAY_LENGTH { - return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}"))); - } - let mut out = Vec::new(); - for i in 0..n { - let x = a.get(i, ctx)?; - out.push(text(&x, ctx)?); - } - return Ok(out); - } - Ok(if v.is_undefined() || v.is_null() { Vec::new() } else { vec![text(v, ctx)?] }) -} - -fn colour(c: &Option>, ctx: &mut Context) -> JsValue { - match c { - None => JsArray::from_iter([s("T")], ctx).into(), - Some(parts) => { - let vals: Vec = - parts.iter().enumerate().map(|(i, p)| if i == 0 { s(p) } else { JsValue::from(p.parse::().unwrap_or(0.0)) }).collect(); - JsArray::from_iter(vals, ctx).into() - } - } -} - -// ── field objects ─────────────────────────────────────────────────────────────────────────── - -fn field_index(ctx: &Context, name: &JsString) -> JsResult { - let n = name.to_std_string_escaped(); - host(ctx)?.borrow().fields.iter().position(|f| f.name == n).ok_or_else(|| error(format!("no field named {n}"))) -} - -fn read(ctx: &Context, name: &JsString) -> JsResult { - let i = field_index(ctx, name)?; - Ok(host(ctx)?.borrow().fields[i].clone()) -} - -fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> { - let i = field_index(ctx, name)?; - let mut h = host(ctx)?.borrow_mut(); - change(&mut h.fields[i]); - h.changed.insert(i); - Ok(()) -} - -fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue { - match f.kind { - FieldType::CheckBox | FieldType::RadioButton => s(f.value.first().map_or("Off", String::as_str)), - FieldType::ListBox if f.value.len() > 1 => { - let vals: Vec = f.value.iter().map(|v| s(v)).collect(); - JsArray::from_iter(vals, ctx).into() - } - _ => { - let v = f.single(); - as_number(&v).map_or_else(|| s(&v), JsValue::from) - } - } -} - -fn set_value(f: &mut FieldState, vals: Vec) { - f.value = match f.kind { - FieldType::CheckBox | FieldType::RadioButton => vals.into_iter().filter(|v| v != "Off" && !v.is_empty()).take(1).collect(), - FieldType::ListBox => vals, - _ => vals.into_iter().take(1).collect(), - }; -} - -macro_rules! getter { - ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ - fn get(_: &JsValue, _: &[JsValue], name: &JsString, $c: &mut Context) -> JsResult { - let $f = read($c, name)?; - Ok($body) - } - function($ctx, NativeFunction::from_copy_closure_with_captures(get, $name.clone())) - }}; -} - -fn field_object(ctx: &mut Context, name: &str) -> JsObject { - let n = JsString::from(name); - let value_get = getter!(ctx, n, |f, c| value_of(&f, c)); - let value_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let vals = strings(&arg(args, 0), c)?; - write(c, name, |f| set_value(f, vals))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let as_string = getter!(ctx, n, |f, _c| s(&match f.kind { - FieldType::CheckBox | FieldType::RadioButton => f.value.first().cloned().unwrap_or_else(|| "Off".into()), - _ => f.value.join(","), - })); - let default = getter!(ctx, n, |f, _c| s(&f.default.first().cloned().unwrap_or_default())); - let kind = getter!(ctx, n, |f, _c| s(f.kind.name())); - let fname = getter!(ctx, n, |f, _c| s(&f.name)); - let readonly_get = getter!(ctx, n, |f, _c| JsValue::from(f.readonly)); - let readonly_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let on = arg(args, 0).to_boolean(); - write(c, name, |f| f.readonly = on)?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let required_get = getter!(ctx, n, |f, _c| JsValue::from(f.required)); - let required_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let on = arg(args, 0).to_boolean(); - write(c, name, |f| f.required = on)?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let display_get = getter!(ctx, n, |f, _c| JsValue::from(f.display)); - let display_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let d = arg(args, 0).to_number(c)? as i32; - write(c, name, |f| f.display = d.clamp(0, 3))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let hidden_get = getter!(ctx, n, |f, _c| JsValue::from(f.display == DISPLAY_HIDDEN)); - let hidden_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let on = arg(args, 0).to_boolean(); - write(c, name, |f| f.display = if on { DISPLAY_HIDDEN } else { DISPLAY_VISIBLE })?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let num_items = getter!(ctx, n, |f, _c| JsValue::from(f.options.len() as f64)); - let char_limit = getter!(ctx, n, |f, _c| JsValue::from(f.char_limit.unwrap_or(0) as f64)); - let export_values = getter!(ctx, n, |f, c| { - let vals: Vec = f.options.iter().map(|o| s(&o.0)).collect(); - JsArray::from_iter(vals, c).into() - }); - let indices = getter!(ctx, n, |f, c| { - let idx: Vec = f.options.iter().enumerate().filter(|(_, o)| f.value.contains(&o.0)).map(|(i, _)| JsValue::from(i as f64)).collect(); - if idx.len() == 1 { idx[0].clone() } else { JsArray::from_iter(idx, c).into() } - }); - let text_color_get = getter!(ctx, n, |f, c| colour(&f.text_color, c)); - let text_color_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let v = strings(&arg(args, 0), c)?; - write(c, name, |f| f.text_color = Some(v))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - let fill_color_get = getter!(ctx, n, |f, c| colour(&f.fill_color, c)); - let fill_color_set = { - fn set(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let v = strings(&arg(args, 0), c)?; - write(c, name, |f| f.fill_color = Some(v))?; - Ok(JsValue::undefined()) - } - function(ctx, NativeFunction::from_copy_closure_with_captures(set, n.clone())) - }; - - fn get_item_at(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let f = read(c, name)?; - let i = arg(args, 0).to_number(c)?; - // bExportValue defaults to true. - let export = args.get(1).is_none_or(JsValue::to_boolean); - let i = if i < 0.0 { f.options.len().saturating_sub(1) } else { i as usize }; - let o = f.options.get(i).ok_or_else(|| error(format!("{} has no item {i}", f.name)))?; - Ok(s(if export { &o.0 } else { &o.1 })) - } - fn is_box_checked(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let f = read(c, name)?; - let i = arg(args, 0).to_number(c)? as usize; - let on = f.options.get(i).is_some_and(|o| f.value.first() == Some(&o.0)); - Ok(JsValue::from(on)) - } - fn check_this_box(_: &JsValue, args: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - let f = read(c, name)?; - let i = arg(args, 0).to_number(c)? as usize; - let on = args.get(1).is_none_or(JsValue::to_boolean); - let export = f.options.get(i).map(|o| o.0.clone()).ok_or_else(|| error(format!("{} has no widget {i}", f.name)))?; - write(c, name, |f| { - if on { - f.value = vec![export]; - } else if f.value.first() == Some(&export) { - f.value.clear(); - } - })?; - Ok(JsValue::undefined()) - } - fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult { - host(c)?.borrow_mut().requests.push(Request::Focus(name.to_std_string_escaped())); - Ok(JsValue::undefined()) - } - - let rw = Attribute::CONFIGURABLE | Attribute::ENUMERABLE; - let get_item_at = NativeFunction::from_copy_closure_with_captures(get_item_at, n.clone()); - let is_box_checked = NativeFunction::from_copy_closure_with_captures(is_box_checked, n.clone()); - let check_this_box = NativeFunction::from_copy_closure_with_captures(check_this_box, n.clone()); - let set_focus = NativeFunction::from_copy_closure_with_captures(set_focus, n.clone()); - ObjectInitializer::new(ctx) - .accessor(js_string!("value"), Some(value_get), Some(value_set), rw) - .accessor(js_string!("valueAsString"), Some(as_string), None, rw) - .accessor(js_string!("defaultValue"), Some(default), None, rw) - .accessor(js_string!("type"), Some(kind), None, rw) - .accessor(js_string!("name"), Some(fname), None, rw) - .accessor(js_string!("readonly"), Some(readonly_get), Some(readonly_set), rw) - .accessor(js_string!("required"), Some(required_get), Some(required_set), rw) - .accessor(js_string!("display"), Some(display_get), Some(display_set), rw) - .accessor(js_string!("hidden"), Some(hidden_get), Some(hidden_set), rw) - .accessor(js_string!("numItems"), Some(num_items), None, rw) - .accessor(js_string!("charLimit"), Some(char_limit), None, rw) - .accessor(js_string!("exportValues"), Some(export_values), None, rw) - .accessor(js_string!("currentValueIndices"), Some(indices), None, rw) - .accessor(js_string!("textColor"), Some(text_color_get), Some(text_color_set), rw) - .accessor(js_string!("fillColor"), Some(fill_color_get), Some(fill_color_set), rw) - .function(get_item_at, js_string!("getItemAt"), 2) - .function(is_box_checked, js_string!("isBoxChecked"), 1) - .function(check_this_box, js_string!("checkThisBox"), 2) - .function(set_focus, js_string!("setFocus"), 0) - .build() -} - -// ── the document (global object) ──────────────────────────────────────────────────────────── - -fn get_field(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let name = text(&arg(args, 0), ctx)?; - let exists = host(ctx)?.borrow().fields.iter().any(|f| f.name == name); - if exists { - return Ok(field_object(ctx, &name).into()); - } - // A parent name (e.g. "total" for "total.0"): its first kid stands in, as for groups. - let prefix = format!("{name}."); - let kid = host(ctx)?.borrow().fields.iter().find(|f| f.name.starts_with(&prefix)).map(|f| f.name.clone()); - Ok(kid.map_or(JsValue::null(), |k| field_object(ctx, &k).into())) -} - -fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let i = arg(args, 0).to_number(ctx)?; - let name = host(ctx)?.borrow().fields.get(i.max(0.0) as usize).map(|f| f.name.clone()); - Ok(name.map_or(JsValue::null(), |n| s(&n))) -} - -fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let names = strings(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().requests.push(Request::Reset(names)); - Ok(JsValue::undefined()) -} - -fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().requests.push(Request::Print); - Ok(JsValue::undefined()) -} - -fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let a = arg(args, 0); - let url = match a.as_object() { - Some(o) if !o.is_array() => { - let u = o.get(js_string!("cURL"), ctx)?; - text(&u, ctx)? - } - _ => text(&a, ctx)?, - }; - host(ctx)?.borrow_mut().requests.push(Request::Submit(url)); - Ok(JsValue::undefined()) -} - -fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) -} - -fn num_fields(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().fields.len() as f64)) -} - -fn page_num_get(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) -} - -fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let n = arg(args, 0).to_number(ctx)?; - let mut h = host(ctx)?.borrow_mut(); - let last = h.doc.num_pages.saturating_sub(1); - let p = (n.max(0.0) as usize).min(last); - h.doc.page = p; - h.requests.push(Request::GoToPage(p)); - Ok(JsValue::undefined()) -} - -// ── app, util, console ────────────────────────────────────────────────────────────────────── - -/// A message argument: a string, or an object with `cMsg`. -fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { - match v.as_object() { - Some(o) if !o.is_array() && !o.is_callable() => { - let m = o.get(JsString::from(key), ctx)?; - text(&m, ctx) - } - _ => text(v, ctx), - } -} - -fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = message(&arg(args, 0), "cMsg", ctx)?; - host(ctx)?.borrow_mut().alerts.push(m); - // The OK button. - Ok(JsValue::from(1)) -} - -fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().requests.push(Request::Beep); - Ok(JsValue::undefined()) -} - -fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let u = message(&arg(args, 0), "cURL", ctx)?; - host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u)); - Ok(JsValue::undefined()) -} - -fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - // No one to answer: as if the dialog was cancelled. - Ok(JsValue::null()) -} - -fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = text(&arg(args, 0), ctx)?; - host(ctx)?.borrow_mut().console.push(m); - Ok(JsValue::undefined()) -} - -fn console_clear(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - host(ctx)?.borrow_mut().console.clear(); - Ok(JsValue::undefined()) -} - -fn timer(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - // Timers never fire in the sandbox; scripts get an object they can pass to clear*. - Ok(ObjectInitializer::new(ctx).build().into()) -} - -/// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups -/// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` -/// without grouping. -fn printf(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let fmt = text(&arg(args, 0), ctx)?; - let mut out = String::new(); - let check_output = |len: usize| -> JsResult<()> { - if len > MAX_PRINTF_OUTPUT_BYTES { - return Err(error(format!("util.printf output exceeds {MAX_PRINTF_OUTPUT_BYTES} bytes"))); - } - Ok(()) - }; - let mut next = 1; - let mut chars = fmt.chars().peekable(); - while let Some(c) = chars.next() { - if c != '%' { - check_output(out.len().saturating_add(c.len_utf8()))?; - out.push(c); - continue; - } - if chars.peek() == Some(&'%') { - chars.next(); - check_output(out.len().saturating_add(1))?; - out.push('%'); - continue; - } - let mut sep = 1u8; - if chars.peek() == Some(&',') { - chars.next(); - sep = chars.next().and_then(|d| d.to_digit(10)).unwrap_or(0) as u8; - } - let (mut plus, mut space, mut zero) = (false, false, false); - while let Some(&f) = chars.peek() { - match f { - '+' => plus = true, - ' ' => space = true, - '0' => zero = true, - '#' => {} - _ => break, - } - chars.next(); - } - let mut width = 0usize; - while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { - width = width.saturating_mul(10).saturating_add(d as usize); - chars.next(); - } - let mut prec = None; - if chars.peek() == Some(&'.') { - chars.next(); - let mut p = 0usize; - while let Some(d) = chars.peek().and_then(|d| d.to_digit(10)) { - p = p.saturating_mul(10).saturating_add(d as usize); - chars.next(); - } - prec = Some(p); - } - if width > MAX_PRINTF_WIDTH_PRECISION || prec.is_some_and(|p| p > MAX_PRINTF_WIDTH_PRECISION) { - return Err(error(format!("util.printf width or precision exceeds {MAX_PRINTF_WIDTH_PRECISION}"))); - } - let conv = chars.next().unwrap_or('s'); - let v = arg(args, next); - next += 1; - let body = match conv { - 'd' | 'f' => { - let n = v.to_number(ctx)?; - let p = if conv == 'd' { 0 } else { prec.unwrap_or(6) }; - let n = if conv == 'd' { n.trunc() } else { n }; - let mut t = group(&format!("{:.*}", p, n.abs()), sep); - if n < 0.0 { - t.insert(0, '-'); - } else if plus { - t.insert(0, '+'); - } else if space { - t.insert(0, ' '); - } - t - } - 'x' => format!("{:X}", v.to_number(ctx)? as i64), - _ => { - let t = text(&v, ctx)?; - match prec { - Some(p) => t.chars().take(p).collect(), - None => t, - } - } - }; - let pad = width.saturating_sub(body.chars().count()); - check_output(out.len().saturating_add(pad).saturating_add(body.len()))?; - if zero && matches!(conv, 'd' | 'f') { - let (sign, digits) = if body.starts_with(['-', '+', ' ']) { body.split_at(1) } else { ("", body.as_str()) }; - out.push_str(sign); - out.push_str(&"0".repeat(pad)); - out.push_str(digits); - } else { - out.push_str(&" ".repeat(pad)); - out.push_str(&body); - } - } - Ok(s(&out)) -} - -/// Group the integer part of a plain decimal number per `util.printf`'s `,n` flag. -fn group(t: &str, sep: u8) -> String { - let (int, frac) = t.split_once('.').map_or((t, None), |(a, b)| (a, Some(b))); - let (thousands, point) = match sep { - 0 => (Some(','), '.'), - 2 => (Some('.'), ','), - 3 => (None, ','), - _ => (None, '.'), - }; - let mut g = String::new(); - for (i, ch) in int.chars().enumerate() { - if let Some(th) = thousands - && i > 0 - && (int.len() - i).is_multiple_of(3) - { - g.push(th); - } - g.push(ch); - } - if let Some(f) = frac { - g.push(point); - g.push_str(f); - } - g -} - -const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - -/// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, -/// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). -fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let f = arg(args, 0); - let fmt = if f.is_number() { - match f.to_number(ctx)? as i32 { - 0 => "D:yyyymmddHHMMss".to_string(), - 1 => "yyyy.mm.dd HH:MM:ss".to_string(), - _ => "m/d/yy h:MM:ss tt".to_string(), - } - } else { - text(&f, ctx)? - }; - let d = arg(args, 1); - let Some(date) = d.as_object() else { return Err(error("util.printd needs a Date")) }; - let mut part = |m: &str| -> JsResult { - let func = date.get(JsString::from(m), ctx)?; - let func = func.as_callable().ok_or_else(|| error("util.printd needs a Date"))?; - let v = func.call(&d, &[], ctx)?.to_number(ctx)?; - if v.is_nan() { - return Err(error("invalid date")); - } - Ok(v as i64) - }; - let (y, mo, day, wd, h, mi, sec) = - (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); - let mut out = String::new(); - let b = fmt.as_bytes(); - let mut i = 0; - while i < b.len() { - let c = b[i]; - let run = b[i..].iter().take_while(|x| **x == c).count(); - let push_num = |out: &mut String, v: i64, n: usize| { - if n >= 2 { - out.push_str(&format!("{v:02}")); - } else { - out.push_str(&v.to_string()); - } - }; - match c { - b'y' => { - if run >= 4 { - out.push_str(&format!("{y:04}")); - } else { - out.push_str(&format!("{:02}", y % 100)); - } - } - b'm' => match run { - r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), - 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), - n => push_num(&mut out, mo + 1, n), - }, - b'd' => match run { - r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), - 3 => out.push_str(&DAYS[wd as usize % 7][..3]), - n => push_num(&mut out, day, n), - }, - b'H' => push_num(&mut out, h, run), - b'h' => push_num(&mut out, if h % 12 == 0 { 12 } else { h % 12 }, run), - b'M' => push_num(&mut out, mi, run), - b's' => push_num(&mut out, sec, run), - b't' => { - let ampm = if h < 12 { "am" } else { "pm" }; - out.push_str(if run >= 2 { ampm } else { &m[..1] }); - } - b'\\' if i + 1 < b.len() => { - // The escaped character may be multi-byte; copy it whole. - let ch = fmt[i + 1..].chars().next().unwrap_or(' '); - out.push(ch); - i += 1 + ch.len_utf8(); - continue; - } - _ => { - // Copy the rest of a multi-byte character too. - let ch = fmt[i..].chars().next().unwrap_or(' '); - out.push(ch); - i += ch.len_utf8(); - continue; - } - } - i += run; - } - Ok(s(&out)) -} - -/// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` -/// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. -fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let fmt: Vec = text(&arg(args, 0), ctx)?.chars().collect(); - let src: Vec = text(&arg(args, 1), ctx)?.chars().collect(); - let (mut out, mut j, mut case) = (String::new(), 0, 0); - let mut i = 0; - let push = |out: &mut String, c: char, case: i32| match case { - 1 => out.extend(c.to_uppercase()), - -1 => out.extend(c.to_lowercase()), - _ => out.push(c), - }; - while i < fmt.len() { - let f = fmt[i]; - i += 1; - let take = |j: &mut usize, ok: &dyn Fn(char) -> bool| -> Option { - while *j < src.len() { - let c = src[*j]; - *j += 1; - if ok(c) { - return Some(c); - } - } - None - }; - match f { - '?' => { - if let Some(c) = take(&mut j, &|_| true) { - push(&mut out, c, case) - } - } - 'X' => { - if let Some(c) = take(&mut j, &|c| c.is_alphanumeric()) { - push(&mut out, c, case) - } - } - 'A' => { - if let Some(c) = take(&mut j, &|c| c.is_alphabetic()) { - push(&mut out, c, case) - } - } - '9' => { - if let Some(c) = take(&mut j, &|c| c.is_ascii_digit()) { - push(&mut out, c, case) - } - } - '*' => { - for c in src[j.min(src.len())..].iter().copied() { - push(&mut out, c, case); - } - j = src.len(); - } - '\\' if i < fmt.len() => { - out.push(fmt[i]); - i += 1; - } - '>' => case = 1, - '<' => case = -1, - '=' => case = 0, - c => out.push(c), - } - } - Ok(s(&out)) -} - -fn color_object(ctx: &mut Context) -> JsObject { - let mut entries: Vec<(&str, Vec)> = vec![ - ("transparent", vec![s("T")]), - ("black", vec![s("G"), 0.into()]), - ("white", vec![s("G"), 1.into()]), - ("red", vec![s("RGB"), 1.into(), 0.into(), 0.into()]), - ("green", vec![s("RGB"), 0.into(), 1.into(), 0.into()]), - ("blue", vec![s("RGB"), 0.into(), 0.into(), 1.into()]), - ("cyan", vec![s("CMYK"), 1.into(), 0.into(), 0.into(), 0.into()]), - ("magenta", vec![s("CMYK"), 0.into(), 1.into(), 0.into(), 0.into()]), - ("yellow", vec![s("CMYK"), 0.into(), 0.into(), 1.into(), 0.into()]), - ("dkGray", vec![s("G"), JsValue::from(0.25)]), - ("gray", vec![s("G"), JsValue::from(0.5)]), - ("ltGray", vec![s("G"), JsValue::from(0.75)]), - ]; - let arrays: Vec<(&str, JsValue)> = entries.drain(..).map(|(k, v)| (k, JsArray::from_iter(v, ctx).into())).collect(); - let mut o = ObjectInitializer::new(ctx); - for (k, v) in arrays { - o.property(JsString::from(k), v, Attribute::all()); - } - o.build() -} - -fn install(ctx: &mut Context) -> JsResult<()> { - let globals: [(&str, usize, NativeFunction); 9] = [ - ("getField", 1, NativeFunction::from_fn_ptr(get_field)), - ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), - ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), - ("print", 1, NativeFunction::from_fn_ptr(print)), - ("submitForm", 1, NativeFunction::from_fn_ptr(submit_form)), - ("mailDoc", 1, NativeFunction::from_fn_ptr(noop)), - ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), - ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), - ("dirty", 0, NativeFunction::from_fn_ptr(noop)), - ]; - for (name, len, f) in globals { - ctx.register_global_callable(JsString::from(name), len, f)?; - } - let global = ctx.global_object(); - let rw = Attribute::CONFIGURABLE; - let nf = function(ctx, NativeFunction::from_fn_ptr(num_fields)); - let pg = function(ctx, NativeFunction::from_fn_ptr(page_num_get)); - let ps = function(ctx, NativeFunction::from_fn_ptr(page_num_set)); - let (file, pages, info) = { - let h = host(ctx)?.borrow(); - (h.doc.file_name.clone(), h.doc.num_pages, h.doc.info.clone()) - }; - let mut io = ObjectInitializer::new(ctx); - for (k, v) in &info { - io.property(JsString::from(k.as_str()), s(v), Attribute::all()); - } - let info = io.build(); - use boa_engine::property::PropertyDescriptor; - global.define_property_or_throw(js_string!("numFields"), PropertyDescriptor::builder().get(nf).configurable(true), ctx)?; - global.define_property_or_throw(js_string!("pageNum"), PropertyDescriptor::builder().get(pg).set(ps).configurable(true), ctx)?; - ctx.register_global_property(js_string!("numPages"), JsValue::from(pages as f64), rw)?; - ctx.register_global_property(js_string!("documentFileName"), s(&file), rw)?; - ctx.register_global_property(js_string!("info"), info, rw)?; - - let app = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(alert), js_string!("alert"), 1) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) - .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) - .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) - .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) - .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) - .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) - .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) - .property(js_string!("formsVersion"), JsValue::from(24.0), Attribute::READONLY) - .property(js_string!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("language"), s("ENU"), Attribute::READONLY) - .build(); - ctx.register_global_property(js_string!("app"), app, rw)?; - let util = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) - .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) - .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) - .build(); - ctx.register_global_property(js_string!("util"), util, rw)?; - let console = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(println), js_string!("println"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) - .function(NativeFunction::from_fn_ptr(console_clear), js_string!("clear"), 0) - .build(); - ctx.register_global_property(js_string!("console"), console, rw)?; - let display = ObjectInitializer::new(ctx) - .property(js_string!("visible"), DISPLAY_VISIBLE, Attribute::READONLY) - .property(js_string!("hidden"), DISPLAY_HIDDEN, Attribute::READONLY) - .property(js_string!("noPrint"), DISPLAY_NO_PRINT, Attribute::READONLY) - .property(js_string!("noView"), DISPLAY_NO_VIEW, Attribute::READONLY) - .build(); - ctx.register_global_property(js_string!("display"), display, rw)?; - let color = color_object(ctx); - ctx.register_global_property(js_string!("color"), color, rw)?; - Ok(()) -} - -fn platform() -> &'static str { - if cfg!(target_os = "macos") { - "MAC" - } else if cfg!(target_os = "windows") { - "WIN" - } else { - "UNIX" - } -} - -fn event_object(ctx: &mut Context, e: &Event) -> JsObject { - let target = e.target.as_ref().map_or(JsValue::null(), |t| field_object(ctx, t).into()); - ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&e.name), Attribute::all()) - .property(js_string!("type"), s(&e.kind), Attribute::all()) - .property(js_string!("value"), s(&e.value), Attribute::all()) - .property(js_string!("change"), s(&e.change), Attribute::all()) - .property(js_string!("changeEx"), s(&e.change), Attribute::all()) - .property(js_string!("willCommit"), e.will_commit, Attribute::all()) - .property(js_string!("rc"), true, Attribute::all()) - .property(js_string!("target"), target, Attribute::all()) - .property(js_string!("targetName"), s(e.target.as_deref().unwrap_or("")), Attribute::all()) - .property(js_string!("selStart"), 0, Attribute::all()) - .property(js_string!("selEnd"), 0, Attribute::all()) - .property(js_string!("commitKey"), 0, Attribute::all()) - .property(js_string!("modifier"), false, Attribute::all()) - .property(js_string!("shift"), false, Attribute::all()) - .build() -} - -// ── stack safety ──────────────────────────────────────────────────────────────────────────── -// -// boa's parser and compiler recurse once per nesting level and have no limit of their own: about -// a hundred nested parentheses overflow a 2 MiB stack, which aborts the whole app. Scripts come -// from documents, so `run` refuses nesting no real form needs and runs the engine on a thread -// with a large stack (address space, committed only as it is used). Together these bound the -// stack a script can use: brackets and prefix operators by the limits below, and every other -// construct (a few KiB per level, several bytes of source each) by the length limit. - -/// Longest script, in bytes. -pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; -/// Largest width or precision accepted by `util.printf`. -const MAX_PRINTF_WIDTH_PRECISION: usize = 4096; -/// Longest `util.printf` output, in bytes. -const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20; -/// Longest array read item by item (`resetForm`, a field's `value` or colours). A sparse array's -/// length costs the script nothing but the host one string per item; real lists are tiny. -const MAX_ARRAY_LENGTH: u64 = 1 << 20; -/// Deepest nesting of `(`, `[` and `{`. -const MAX_BRACKET_DEPTH: usize = 64; -/// Longest run of prefix operators (`!`, `~`, `+`, `-`). -const MAX_PREFIX_RUN: usize = 64; -/// Most `?` (conditional expressions nest without brackets). -const MAX_CONDITIONALS: usize = 4096; -/// The script thread's stack. +mod document; +mod format; +mod globals; +mod host; +mod model; +mod run; + +#[cfg(test)] +pub(crate) use format::PRINTD_SCANNED; +use format::{printd, printf, printx, scand}; +pub(crate) use globals::menu_item; +use globals::platform; +use host::{arg, as_number, error, function, s, text}; +pub use model::{ + DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, DocInfo, Event, FieldState, FieldType, Limits, Message, MessageButtons, + MessageIcon, Outcome, Panel, Request, +}; #[cfg(not(target_arch = "wasm32"))] -const SCRIPT_STACK: usize = 1 << 30; - -/// Why `source` is refused, if it is: too long, or nested deeper than any form needs. Strings -/// and comments are skipped; brackets in regular expressions and templates count, which only -/// errs on the safe side. -fn refuse(source: &str) -> Option { - if source.len() > MAX_SCRIPT_BYTES { - return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024)); - } - let too_deep = || Some("the script is nested too deeply to run safely".to_string()); - let b = source.as_bytes(); - let (mut depth, mut prefix, mut conditionals) = (0usize, 0usize, 0usize); - let mut i = 0; - while let Some(&c) = b.get(i) { - i += 1; - match c { - b'\'' | b'"' => { - // To the closing quote (or the end of the line, where an unclosed string ends). - while let Some(&d) = b.get(i) { - i += 1; - if d == b'\\' { - i += 1; - } else if d == c || d == b'\n' { - break; - } - } - prefix = 0; - } - b'/' if b.get(i) == Some(&b'/') => { - while b.get(i).is_some_and(|d| *d != b'\n') { - i += 1; - } - } - b'/' if b.get(i) == Some(&b'*') => { - i += 1; - while i < b.len() && !b[i..].starts_with(b"*/") { - i += 1; - } - i += 2; - } - b'(' | b'[' | b'{' => { - depth += 1; - if depth > MAX_BRACKET_DEPTH { - return too_deep(); - } - prefix = 0; - } - b')' | b']' | b'}' => { - depth = depth.saturating_sub(1); - prefix = 0; - } - b'!' | b'~' | b'+' | b'-' => { - prefix += 1; - if prefix > MAX_PREFIX_RUN { - return too_deep(); - } - } - b'?' => { - conditionals += 1; - if conditionals > MAX_CONDITIONALS { - return too_deep(); - } - prefix = 0; - } - c if c.is_ascii_whitespace() => {} - _ => prefix = 0, - } - } - None -} - -/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the -/// functions field scripts call), with the form's `fields`. -pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome { - let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; - if let Some(why) = std::iter::once(script).chain(doc_scripts.iter().map(String::as_str)).find_map(refuse) { - return failed(why); - } - #[cfg(not(target_arch = "wasm32"))] - { - std::thread::scope(|scope| { - let spawned = std::thread::Builder::new() - .name("pdfcraft-js".into()) - .stack_size(SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, fields, doc_scripts, limits)); - match spawned { - Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), - Err(e) => failed(format!("the script engine could not start: {e}")), - } - }) - } - #[cfg(target_arch = "wasm32")] - run_here(script, event, doc, fields, doc_scripts, limits) -} - -fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome { - let mut ctx = Context::default(); - ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); - ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); - ctx.insert_data(RefCell::new(Host { - fields: fields.to_vec(), - changed: BTreeSet::new(), - alerts: Vec::new(), - console: Vec::new(), - requests: Vec::new(), - doc: doc.clone(), - })); - let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; - let result = (|| -> JsResult<()> { - install(&mut ctx)?; - let ev = event_object(&mut ctx, event); - ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; - for d in doc_scripts { - ctx.eval(Source::from_bytes(d))?; - } - let r = ctx.eval(Source::from_bytes(script))?; - if !r.is_undefined() { - out.result = Some(text(&r, &mut ctx)?); - } - let rc = ev.get(js_string!("rc"), &mut ctx)?; - out.rc = rc.to_boolean(); - let v = ev.get(js_string!("value"), &mut ctx)?; - out.value = text(&v, &mut ctx)?; - let c = ev.get(js_string!("change"), &mut ctx)?; - out.change = text(&c, &mut ctx)?; - Ok(()) - })(); - if let Err(e) = result { - out.error = Some(e.to_string()); - } - if let Some(h) = ctx.remove_data::() { - let h = h.into_inner(); - out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); - out.alerts = h.alerts; - out.console = h.console; - out.requests = h.requests; - } - out -} +use run::SCRIPT_STACK; +use run::refuse; +pub(crate) use run::{CheckedCodeFromStrings, MAX_SCRIPT_BYTES}; +pub use run::{run, run_shared}; pub mod formcalc; pub mod xfa; diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs new file mode 100644 index 0000000000..36b09bec97 --- /dev/null +++ b/crates/js/src/model.rs @@ -0,0 +1,305 @@ +/// The kinds of field, as `field.type` names them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldType { + Text, + CheckBox, + RadioButton, + ComboBox, + ListBox, + Button, + Signature, +} + +impl FieldType { + pub fn name(self) -> &'static str { + match self { + FieldType::Text => "text", + FieldType::CheckBox => "checkbox", + FieldType::RadioButton => "radiobutton", + FieldType::ComboBox => "combobox", + FieldType::ListBox => "listbox", + FieldType::Button => "button", + FieldType::Signature => "signature", + } + } +} + +/// `display.*`: visible, hidden, noPrint, noView. +pub const DISPLAY_VISIBLE: i32 = 0; + +pub const DISPLAY_HIDDEN: i32 = 1; + +pub const DISPLAY_NO_PRINT: i32 = 2; + +pub const DISPLAY_NO_VIEW: i32 = 3; + +/// A field as scripts see it. +#[derive(Clone, Debug, PartialEq)] +pub struct FieldState { + pub name: String, + pub kind: FieldType, + /// Text: the text. Check box / radio: the selected export value (empty when off). Choice: + /// the selected export values. + pub value: Vec, + pub default: Vec, + pub readonly: bool, + pub required: bool, + pub display: i32, + /// Choice options (export value, display text); check boxes and radios: their export + /// values, one per widget. + pub options: Vec<(String, String)>, + pub char_limit: Option, + /// `textColor` / `fillColor` as Acrobat colour arrays (`["RGB", r, g, b]` etc.), when set. + pub text_color: Option>, + pub fill_color: Option>, +} + +impl FieldState { + pub fn new(name: impl Into, kind: FieldType, value: Vec) -> FieldState { + FieldState { + name: name.into(), + kind, + value, + default: Vec::new(), + readonly: false, + required: false, + display: DISPLAY_VISIBLE, + options: Vec::new(), + char_limit: None, + text_color: None, + fill_color: None, + } + } + + pub(crate) fn single(&self) -> String { + self.value.first().cloned().unwrap_or_default() + } +} + +/// The event a script runs for. +#[derive(Clone, Debug, PartialEq)] +pub struct Event { + /// `Keystroke`, `Validate`, `Calculate`, `Format`, `Mouse Up`, `Open`, … + pub name: String, + /// `Field`, `Doc`, `Page`, … + pub kind: String, + /// The field the event is for. + pub target: Option, + pub value: String, + pub change: String, + pub will_commit: bool, +} + +impl Event { + /// A field event (`event.type` = "Field"). + pub fn field(name: &str, target: &str, value: &str) -> Event { + Event { name: name.into(), kind: "Field".into(), target: Some(target.into()), value: value.into(), change: String::new(), will_commit: true } + } + + /// A document event (`event.type` = "Doc"), e.g. Open or WillSave. + pub fn doc(name: &str) -> Event { + Event { name: name.into(), kind: "Doc".into(), target: None, value: String::new(), change: String::new(), will_commit: false } + } +} + +/// About the document, for `this.numPages`, `this.info` and the like. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DocInfo { + pub file_name: String, + pub num_pages: usize, + /// The current page, 0-based. + pub page: usize, + /// Document information (`this.info.title` etc.): lower-case key, value. + pub info: Vec<(String, String)>, + /// The document has an XFA form, so scripts see `xfa_installed`. + pub xfa: bool, +} + +/// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s +/// icon, numbered 0 to 3 in the JavaScript for Acrobat API Reference and the XFA +/// specification alike. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MessageIcon { + /// 0, the default. + #[default] + Error, + /// 1. + Warning, + /// 2. + Question, + /// 3. + Status, +} + +impl MessageIcon { + /// The icon numbered `code`; a missing, fractional or out-of-range number is the default. + pub fn from_code(code: Option) -> MessageIcon { + match whole_code(code) { + Some(1) => MessageIcon::Warning, + Some(2) => MessageIcon::Question, + Some(3) => MessageIcon::Status, + _ => MessageIcon::Error, + } + } + + /// A stable name for tools and logs: `error`, `warning`, `question` or `status`. + pub fn name(self) -> &'static str { + match self { + MessageIcon::Error => "error", + MessageIcon::Warning => "warning", + MessageIcon::Question => "question", + MessageIcon::Status => "status", + } + } +} + +/// The buttons a script's message offers: `app.alert`'s `nType` and `xfa.host.messageBox`'s +/// buttons, numbered 0 to 3 in both references. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MessageButtons { + /// 0, the default. + #[default] + Ok, + /// 1. + OkCancel, + /// 2. + YesNo, + /// 3. + YesNoCancel, +} + +impl MessageButtons { + /// The buttons numbered `code`; a missing, fractional or out-of-range number is the default. + pub fn from_code(code: Option) -> MessageButtons { + match whole_code(code) { + Some(1) => MessageButtons::OkCancel, + Some(2) => MessageButtons::YesNo, + Some(3) => MessageButtons::YesNoCancel, + _ => MessageButtons::Ok, + } + } + + /// A stable name for tools and logs: `ok`, `ok_cancel`, `yes_no` or `yes_no_cancel`. + pub fn name(self) -> &'static str { + match self { + MessageButtons::Ok => "ok", + MessageButtons::OkCancel => "ok_cancel", + MessageButtons::YesNo => "yes_no", + MessageButtons::YesNoCancel => "yes_no_cancel", + } + } +} + +/// `code` as one of the numbers 0 to 3 the references define, when it is exactly one. +fn whole_code(code: Option) -> Option { + let c = code.filter(|c| c.is_finite() && c.fract() == 0.0 && (0.0..=3.0).contains(c))?; + // In 0..=3 and whole, so the cast is exact. + Some(c as u8) +} + +/// A message a script showed (`app.alert`, `xfa.host.messageBox`), with the title, icon and +/// buttons it asked for. Its text and title are cut to a message's length. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Message { + pub text: String, + /// The dialog's title, when the script gave one. + pub title: Option, + pub icon: MessageIcon, + pub buttons: MessageButtons, +} + +impl Message { + /// A message with only text: no title, the default icon and an OK button. + pub fn plain(text: impl Into) -> Message { + Message { text: text.into(), ..Default::default() } + } + + /// Bytes of text the message holds, charged to a run's budget. + pub(crate) fn bytes(&self) -> usize { + self.text.len().saturating_add(self.title.as_ref().map_or(0, String::len)) + } +} + +/// Something a script asked the viewer to do. +#[derive(Clone, Debug, PartialEq)] +pub enum Request { + /// `this.resetForm([names])` (empty: every field). + Reset(Vec), + Print, + /// `this.pageNum = n` (0-based). + GoToPage(usize), + LaunchUrl(String), + /// `this.submitForm(url)`: never sent on PdfCraft's own. + Submit(String), + /// `field.setFocus()`. + Focus(String), + Beep, + /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). + SaveAs, + /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. + ShowPanel(Panel), +} + +/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Panel { + /// The document's bookmarks (outline). + Bookmarks, + /// Page thumbnails. + Pages, + /// The files attached to the document. + Attachments, + /// The document's signatures and their validity. + Signatures, + /// The document's layers (optional content). + Layers, +} + +impl Panel { + /// The panel's name in automation output, fixed whatever the variant is called. + pub fn id(self) -> &'static str { + match self { + Panel::Bookmarks => "bookmarks", + Panel::Pages => "pages", + Panel::Attachments => "attachments", + Panel::Signatures => "signatures", + Panel::Layers => "layers", + } + } +} + +/// What running a script did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Outcome { + /// `event.rc`: `false` rejects the keystroke or value. + pub rc: bool, + /// `event.value` after the script. + pub value: String, + pub change: String, + /// Fields whose value or properties the script changed, as they are now. + pub changed: Vec, + /// The messages the script showed, in order. + pub alerts: Vec, + pub console: Vec, + pub requests: Vec, + /// The script's error (syntax error, exception, or a limit reached), if it failed. + pub error: Option, + /// Errors in the document-level scripts that ran first. Each is a script of its own: one + /// that throws stops where it failed, and the others and the script itself still run (#3940). + pub doc_errors: Vec, + /// The script's completion value (what the console prints), unless undefined. + pub result: Option, +} + +/// Bounds that keep a script from hanging the viewer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Limits { + pub loop_iterations: u64, + pub recursion: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { loop_iterations: 1_000_000, recursion: 256 } + } +} diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs new file mode 100644 index 0000000000..07c0e984bb --- /dev/null +++ b/crates/js/src/run.rs @@ -0,0 +1,256 @@ +use super::globals::{event_object, install}; +use super::host::{Host, Shared, text}; +use super::model::{DocInfo, Event, FieldState, Limits, Outcome}; +use super::{Arc, Attribute, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, Realm, RefCell, Source, js_string}; + +/// Longest script, in bytes. +pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; + +/// Largest width or precision accepted by `util.printf`. +pub(crate) const MAX_PRINTF_WIDTH_PRECISION: usize = 4096; + +/// Longest `util.printf` output, in bytes. +pub(crate) const MAX_PRINTF_OUTPUT_BYTES: usize = 1 << 20; + +/// Longest array read item by item (`resetForm`, a field's `value` or colours). A sparse array's +/// length costs the script nothing but the host one string per item; real lists are tiny. +pub(crate) const MAX_ARRAY_LENGTH: u64 = 1 << 20; + +/// Deepest nesting of `(`, `[` and `{`. +const MAX_BRACKET_DEPTH: usize = 64; + +/// Longest run of prefix operators (`!`, `~`, `+`, `-`). +const MAX_PREFIX_RUN: usize = 64; + +/// Most `?` (conditional expressions nest without brackets). +const MAX_CONDITIONALS: usize = 4096; + +/// The script thread's stack. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) const SCRIPT_STACK: usize = 1 << 30; + +/// Why `source` is refused, if it is: too long, or nested deeper than any form needs. Strings +/// and comments are skipped; brackets in regular expressions and templates count, which only +/// errs on the safe side. +pub(crate) fn refuse(source: &str) -> Option { + if source.len() > MAX_SCRIPT_BYTES { + return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024)); + } + let too_deep = || Some("the script is nested too deeply to run safely".to_string()); + let b = source.as_bytes(); + let (mut depth, mut prefix, mut conditionals) = (0usize, 0usize, 0usize); + let mut i = 0; + while let Some(&c) = b.get(i) { + i += 1; + match c { + b'\'' | b'"' => { + // To the closing quote (or the end of the line, where an unclosed string ends). + while let Some(&d) = b.get(i) { + i += 1; + if d == b'\\' { + i += 1; + } else if d == c || d == b'\n' { + break; + } + } + prefix = 0; + } + b'/' if b.get(i) == Some(&b'/') => { + while b.get(i).is_some_and(|d| *d != b'\n') { + i += 1; + } + } + b'/' if b.get(i) == Some(&b'*') => { + i += 1; + while i < b.len() && !b[i..].starts_with(b"*/") { + i += 1; + } + i += 2; + } + b'(' | b'[' | b'{' => { + depth += 1; + if depth > MAX_BRACKET_DEPTH { + return too_deep(); + } + prefix = 0; + } + b')' | b']' | b'}' => { + depth = depth.saturating_sub(1); + prefix = 0; + } + b'!' | b'~' | b'+' | b'-' => { + prefix += 1; + if prefix > MAX_PREFIX_RUN { + return too_deep(); + } + } + b'?' => { + conditionals += 1; + if conditionals > MAX_CONDITIONALS { + return too_deep(); + } + prefix = 0; + } + c if c.is_ascii_whitespace() => {} + _ => prefix = 0, + } + } + None +} + +/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the +/// functions field scripts call), with the form's `fields`. +pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome { + run_shared(script, event, doc, Arc::new(fields.to_vec()), doc_scripts, limits) +} + +/// Like [`run`], sharing an immutable initial field snapshot. Scripts still get a fresh +/// context; the first field write copies the snapshot, so neither the caller nor another +/// script can observe uncommitted writes. Read-only calculations avoid copying the whole form. +pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { + let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; + if let Some(why) = refuse(script) { + return failed(why); + } + // A document script too long or too deeply nested to run safely is skipped and reported, + // as one that throws is; the others and the script still run (#3940). + let mut skipped = Vec::new(); + let kept: Vec<(usize, &str)> = doc_scripts + .iter() + .enumerate() + .filter_map(|(i, d)| match refuse(d) { + Some(why) => { + skipped.push((i, why)); + None + } + None => Some((i, d.as_str())), + }) + .collect(); + let (kept, skipped) = (&kept, &skipped); + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::scope(|scope| { + let spawned = std::thread::Builder::new() + .name("pdfcraft-js".into()) + .stack_size(SCRIPT_STACK) + .spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits)); + match spawned { + Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), + Err(e) => failed(format!("the script engine could not start: {e}")), + } + }) + } + #[cfg(target_arch = "wasm32")] + run_here(script, event, doc, fields, kept, skipped, limits) +} + +/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator +/// and async kin). Such code is built at run time, so it never passed [`refuse`], and boa's +/// recursive parser would overflow the stack on a deeply nested one. +struct NoCodeFromStrings; + +impl HostHooks for NoCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, _: &[JsString], _: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) + } +} + +/// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), +/// for runners that start the script itself through `eval`. +pub(crate) struct CheckedCodeFromStrings; + +impl HostHooks for CheckedCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); + if units > MAX_SCRIPT_BYTES { + return Err(JsNativeError::range().with_message("the code is too long to compile").into()); + } + // As one text: brackets left open in one parameter carry into the next. + let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); + source.push('\n'); + source.push_str(&body.to_std_string_lossy()); + match refuse(&source) { + Some(why) => Err(JsNativeError::range().with_message(why).into()), + None => Ok(()), + } + } +} + +/// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place. +fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String { + format!("document script {}: {error}", index.saturating_add(1)) +} + +/// `doc_scripts` are the document scripts to run with their places among all of them; +/// `skipped` the ones refused before running, with the reason. +fn run_here( + script: &str, + event: &Event, + doc: &DocInfo, + fields: Arc>, + doc_scripts: &[(usize, &str)], + skipped: &[(usize, String)], + limits: Limits, +) -> Outcome { + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => { + let error = Some(format!("the script engine could not start: {e}")); + return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; + } + }; + ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); + ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); + ctx.insert_data(RefCell::new(Host { + fields, + changed: BTreeSet::new(), + alerts: Vec::new(), + console: Vec::new(), + requests: Vec::new(), + doc: doc.clone(), + kept: 0, + dropped: 0, + })); + let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() }; + let mut doc_errors: Vec<(usize, String)> = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); + let result = (|| -> JsResult<()> { + install(&mut ctx)?; + let ev = event_object(&mut ctx, event); + ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; + // One document script that throws (calling a method PdfCraft lacks, say) must not keep + // every field script in the form from running: what it defined before the error, and + // the other scripts, stay available (#3940). + for &(i, d) in doc_scripts { + if let Err(e) = ctx.eval(Source::from_bytes(d)) { + doc_errors.push((i, doc_error(i, &e))); + } + } + let r = ctx.eval(Source::from_bytes(script))?; + if !r.is_undefined() { + out.result = Some(text(&r, &mut ctx)?); + } + let rc = ev.get(js_string!("rc"), &mut ctx)?; + out.rc = rc.to_boolean(); + let v = ev.get(js_string!("value"), &mut ctx)?; + out.value = text(&v, &mut ctx)?; + let c = ev.get(js_string!("change"), &mut ctx)?; + out.change = text(&c, &mut ctx)?; + Ok(()) + })(); + if let Err(e) = result { + out.error = Some(e.to_string()); + } + doc_errors.sort_by_key(|(i, _)| *i); + out.doc_errors = doc_errors.into_iter().map(|(_, e)| e).collect(); + if let Some(h) = ctx.remove_data::() { + let h = h.into_inner(); + out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); + out.alerts = h.alerts; + out.console = h.console; + out.requests = h.requests; + if h.dropped > 0 { + out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped)); + } + } + out +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 61d3a71e51..2d3877efe3 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -16,7 +16,7 @@ fn fields() -> Vec { } fn doc() -> DocInfo { - DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())] } + DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], xfa: false } } fn go(script: &str, event: &Event) -> Outcome { @@ -37,11 +37,12 @@ fn numbers_with_leading_zeros_stay_text() { assert_eq!(o.value, "string:number"); } +/// A validate script refuses a value with `event.rc = false` and says why in a message. #[test] fn validate_scripts_reject_with_rc_and_alert() { let o = go("if (event.value > 10) { app.alert('Too many'); event.rc = false; }", &Event::field("Validate", "qty", "11")); assert!(!o.rc); - assert_eq!(o.alerts, ["Too many"]); + assert_eq!(o.alerts, [Message::plain("Too many")]); let o = go("if (event.value > 10) { event.rc = false; }", &Event::field("Validate", "qty", "3")); assert!(o.rc); } @@ -244,6 +245,143 @@ fn document_requests_and_console() { ); } +/// Bytes of text a run handed back in its messages, console lines and requests. +fn kept_bytes(o: &Outcome) -> usize { + let req = |r: &Request| match r { + Request::Reset(names) => names.iter().map(String::len).sum(), + Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(), + _ => 0, + }; + o.console.iter().map(String::len).sum::() + o.alerts.iter().map(Message::bytes).sum::() + o.requests.iter().map(req).sum::() +} + +/// Console lines, messages and requests are capped in number and in bytes, and what is left out is +/// noted. +#[test] +fn exec_menu_item_requests_are_capped_like_other_requests() { + let o = go("for (var i = 0; i < 12000; i++) app.execMenuItem('Print');", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.error, None); + assert_eq!(o.requests.len(), crate::xfa::MAX_EFFECTS); +} + +#[test] +fn console_alerts_and_requests_are_capped_in_count_and_bytes() { + let o = go( + "var s = 'x'; while (s.length < 8192) s += s; \ + for (var i = 0; i < 1500; i++) { console.println(s); app.alert(s); } \ + var a = []; for (var i = 0; i < 2000; i++) a.push(s); \ + for (var i = 0; i < 5; i++) this.resetForm(a); \ + for (var i = 0; i < 12000; i++) app.launchURL('u');", + &Event::field("Mouse Up", "agree", ""), + ); + assert_eq!(o.error, None); + assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len()); + assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len()); + assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len()); + assert!(o.console.iter().chain(o.alerts.iter().map(|m| &m.text)).all(|m| m.chars().count() <= 4_097)); + assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o)); + assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last()); +} + +/// The message `app.alert(args)` shows, from a button's Mouse Up script. +fn alerted(args: &str) -> Message { + let o = go(&format!("app.alert({args});"), &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.error, None, "{args}"); + assert_eq!(o.alerts.len(), 1, "{args}"); + o.alerts.into_iter().next().unwrap_or_default() +} + +/// `app.alert(cMsg, nIcon, nType, cTitle)` keeps every icon and button set the JavaScript for +/// Acrobat API Reference numbers, and the title. +#[test] +fn app_alert_keeps_its_title_icon_and_buttons() { + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let m = alerted(&format!("'Sure?', {i}, {b}, 'Order form'")); + assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}"); + } + } + // Only the message: no title, the Error icon and the OK button, the defaults. + assert_eq!(alerted("'Saved'"), Message::plain("Saved")); + assert_eq!(alerted("'Saved', 3"), Message { icon: MessageIcon::Status, ..Message::plain("Saved") }); +} + +/// The single-object form names the same parameters, in any order, and reads nothing else. +#[test] +fn app_alert_reads_the_object_form() { + let m = alerted("{ cTitle: 'Order form', nType: 2, cMsg: 'Clear the rows?', nIcon: 2, bBeep: true }"); + assert_eq!( + m, + Message { text: "Clear the rows?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::YesNo } + ); + assert_eq!(alerted("{ cMsg: 'Done' }"), Message::plain("Done")); +} + +/// An object without `cMsg` isn't the object form: it is the message itself, as text, so a +/// String object or a caught error shows what it says; the same for `app.launchURL` and `cURL`. +#[test] +fn objects_that_name_no_parameter_are_the_message_itself() { + assert_eq!(alerted("new String('Saved')"), Message::plain("Saved")); + let o = go("try { throw new Error('No such field'); } catch (e) { app.alert(e); }", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.alerts.first().map(|m| m.text.as_str()), Some("Error: No such field"), "{o:?}"); + let o = go("app.launchURL(new String('https://example.com/form'));", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.requests, vec![Request::LaunchUrl("https://example.com/form".into())], "{o:?}"); +} + +/// An icon or button number that isn't one of the four (fractional, negative, too large, not a +/// number, or a value that can't be converted) is the default, and the message still shows; +/// a number given as text counts. +#[test] +fn app_alert_falls_back_on_bad_icon_and_button_numbers() { + for bad in ["4", "-1", "1.5", "NaN", "Infinity", "'x'", "{}", "Symbol('s')", "null", "undefined"] { + let m = alerted(&format!("'Hi', {bad}, {bad}, ''")); + assert_eq!(m, Message::plain("Hi"), "{bad}"); + } + assert_eq!(alerted("'Hi', '2', '3'"), Message { icon: MessageIcon::Question, buttons: MessageButtons::YesNoCancel, ..Message::plain("Hi") }); + // Every call still answers OK (1) until answers are asked for (#4291). + let o = go("event.value = String(app.alert('Delete?', 2, 2));", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.value, "1"); +} + +/// A title is cut to a message's length like the text, and both are charged to the run's byte +/// budget: titles can't carry more text out than messages can. +#[test] +fn app_alert_titles_are_clipped_and_charged() { + use crate::host::Host; + let m = alerted("'Hi', 0, 0, new Array(100000).join('t')"); + assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097), "{:?}", m.title.as_ref().map(String::len)); + // The title's bytes count against the budget with the text's, and a message the budget + // has no room for is left out and counted. + let mut h = Host { + fields: Arc::new(Vec::new()), + changed: BTreeSet::new(), + alerts: Vec::new(), + console: Vec::new(), + requests: Vec::new(), + doc: doc(), + kept: 0, + dropped: 0, + }; + h.alert(Message { title: Some("Order form".into()), ..Message::plain("Saved") }); + assert_eq!(h.kept, "Saved".len() + "Order form".len()); + h.kept = crate::xfa::MAX_PAYLOAD - "Saved".len(); + h.alert(Message { title: Some("T".into()), ..Message::plain("Saved") }); + assert_eq!((h.alerts.len(), h.dropped), (1, 1), "a title past the budget leaves its message out"); +} + +#[test] +fn printd_reads_a_long_literal_run_in_linear_work() { + crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed); + let o = go("event.value = util.printd(new Array(20001).join('x'), new Date(2024, 0, 5)).length;", &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + assert_eq!(o.value, "20000"); + // Other tests format short dates at the same time; they add a few dozen bytes at most. + let scanned = crate::PRINTD_SCANNED.load(std::sync::atomic::Ordering::Relaxed); + assert!(scanned < 40_000, "printd scanned {scanned} bytes for a 20000-byte format"); +} + #[test] fn document_level_functions_are_available() { let o = run( @@ -257,6 +395,61 @@ fn document_level_functions_are_available() { assert_eq!(o.value, "8"); } +/// #3940: a document script that throws after defining a helper (here calling an Acrobat method +/// PdfCraft lacks) still leaves the helper, and the later scripts, to the field script. +#[test] +fn a_failing_document_script_does_not_stop_the_field_script() { + let docs = [ + "function double(x) { return x * 2; }\nthis.getField('qty').buttonSetIcon(null);\nvar after = 1;".to_string(), + "function plus(x) { return x + 1; }".to_string(), + "this is not javascript".to_string(), + ]; + let o = run( + "event.value = plus(double(getField('qty').value));", + &Event::field("Calculate", "total", ""), + &doc(), + &fields(), + &docs, + Limits::default(), + ); + assert_eq!((o.error.as_deref(), o.value.as_str()), (None, "9")); + assert_eq!(o.doc_errors.len(), 2, "{:?}", o.doc_errors); + assert!(o.doc_errors[0].starts_with("document script 1: TypeError"), "{:?}", o.doc_errors); + assert!(o.doc_errors[1].starts_with("document script 3: SyntaxError"), "{:?}", o.doc_errors); + // The failing script stopped where it failed. + let o = run("event.value = typeof after;", &Event::field("Calculate", "total", ""), &doc(), &fields(), &docs, Limits::default()); + assert_eq!(o.value, "undefined"); + // Working document scripts report nothing. + assert!(go("event.value = 1;", &Event::field("Calculate", "total", "")).doc_errors.is_empty()); +} + +/// A document script refused before running (too long, or nested too deeply to run safely) is +/// skipped like one that throws: never run, reported in its place, the rest still run. +#[test] +fn a_document_script_too_big_to_run_is_skipped_and_reported() { + let docs = [ + "this.getField('qty').buttonSetIcon(null);".to_string(), + format!("var big = 1{};", "+1".repeat(MAX_SCRIPT_BYTES / 2)), + "function double(x) { return x * 2; }".to_string(), + format!("var deep = {}1{};", "(".repeat(10_000), ")".repeat(10_000)), + ]; + let o = run( + "event.value = double(getField('qty').value) + typeof big;", + &Event::field("Calculate", "total", ""), + &doc(), + &fields(), + &docs, + Limits::default(), + ); + assert_eq!((o.error.as_deref(), o.value.as_str()), (None, "8undefined")); + let places: Vec<&str> = o.doc_errors.iter().map(|e| e.split(':').next().unwrap_or_default()).collect(); + assert_eq!(places, ["document script 1", "document script 2", "document script 4"], "{:?}", o.doc_errors); + assert!(o.doc_errors[1].contains("too long") && o.doc_errors[2].contains("nested too deeply"), "{:?}", o.doc_errors); + // The field script's own limits still refuse it outright. + let o = run(&format!("1{}", "+1".repeat(MAX_SCRIPT_BYTES)), &Event::field("Calculate", "total", ""), &doc(), &fields(), &docs, Limits::default()); + assert!(o.error.is_some_and(|e| e.contains("too long")) && o.doc_errors.is_empty()); +} + #[test] fn errors_and_runaway_scripts_are_contained() { let o = go("event.value = nosuch.thing;", &Event::field("Calculate", "total", "keep")); @@ -296,9 +489,11 @@ fn deeply_nested_scripts_fail_instead_of_crashing() { let o = go(s, &Event::doc("Open")); assert!(o.error.is_some(), "{}…", &s[..20]); } - // A hostile document-level script is refused too, before the field script runs. + // A hostile document-level script is refused too: it never runs, and is reported, while + // the field script still does (#3940). let o = run("event.value = 'ran';", &Event::field("Calculate", "a", ""), &doc(), &fields(), &[hostile[0].clone()], Limits::default()); - assert!(o.error.is_some()); + assert_eq!((o.error, o.value.as_str()), (None, "ran")); + assert!(o.doc_errors.len() == 1 && o.doc_errors[0].contains("nested too deeply"), "{:?}", o.doc_errors); } #[test] @@ -313,9 +508,26 @@ fn ordinary_nesting_still_runs() { assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000"); } +/// Code built from strings at run time would reach the parser without the size and nesting +/// checks the script itself passed, so `eval` and the `Function` constructors refuse it. +#[test] +fn scripts_cannot_compile_code_from_strings() { + for s in [ + "event.value = eval('1 + 1');", + "event.value = (0, eval)('1 + 1');", + "event.value = new Function('return 2')();", + "event.value = Function('a', 'return a')(2);", + "event.value = (function* () {}).constructor('yield 2')().next().value;", + "event.value = new Array(1001).join('(') + '1' + new Array(1001).join(')'); event.value = eval(event.value);", + ] { + let o = go(s, &Event::field("Calculate", "a", "")); + assert!(o.error.as_deref().is_some_and(|e| e.contains("compile code from strings")), "{s}: {o:?}"); + } +} + mod xfa_model { - use crate::Limits; use crate::xfa::*; + use crate::{Limits, Message, MessageButtons, MessageIcon}; pub(super) fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode { XfaNode { @@ -380,12 +592,15 @@ mod xfa_model { } } + /// Run the JavaScript `script` for `activity` on the test form, from the node at `target`. fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 }; run_xfa(script, &ev, &doc, &form(), Limits::default()) } + /// A calculate script finds its siblings by name and yields its last expression, and a + /// value it sets comes back as an effect. #[test] fn calculate_scripts_see_siblings_by_name_and_yield_their_last_expression() { let o = go("qty.rawValue * price.rawValue", "form1[0].page1[0].total[0]", "calculate"); @@ -395,6 +610,76 @@ mod xfa_model { assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); } + /// A long value written again and again stops at the run's byte budget, and the write that + /// does not fit is left out of the field the script reads as well as of the effect. + #[test] + fn a_value_past_the_byte_budget_is_left_out_of_reads_and_effects_alike() { + let o = go( + "var s = 'x'; while (s.length < 65536) s += s; \ + for (var i = 0; i < 300; i++) details.note.rawValue = ('00000' + i).slice(-6) + s.slice(6); \ + details.note.rawValue.slice(0, 6)", + "form1[0].page1[0].total[0]", + "calculate", + ); + assert_eq!(o.error, None, "{o:?}"); + let set: Vec<&str> = o + .effects + .iter() + .filter_map(|e| if let XfaEffect::SetValue { value, .. } = e { Some(value.get(..6).unwrap_or(value)) } else { None }) + .collect(); + assert_eq!(set.len(), 1); + assert_eq!(o.result.as_deref(), set.first().copied()); + } + + /// The script runs through `eval`, so code built from strings at run time is held to the + /// script's own size and nesting checks instead of being refused outright. + #[test] + fn code_built_from_strings_is_checked_like_the_script() { + let total = "form1[0].page1[0].total[0]"; + assert_eq!(go("eval('qty.rawValue * 2')", total, "calculate").result.as_deref(), Some("8")); + assert_eq!(go("new Function('return 3')()", total, "calculate").result.as_deref(), Some("3")); + for s in [ + "eval(new Array(1001).join('(') + '1' + new Array(1001).join(')'))", + "new Function(new Array(1001).join('[') + new Array(1001).join(']'))", + ] { + let o = go(s, total, "calculate"); + assert!(o.error.as_deref().is_some_and(|e| e.contains("nested too deeply")), "{s}: {o:?}"); + } + } + + /// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020. + pub(super) fn dated_form() -> XfaNode { + let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) }; + XfaNode { + name: "form1".into(), + som: "form1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![XfaNode { + name: "page1".into(), + som: "form1[0].page1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![d], + ..Default::default() + }], + ..Default::default() + } + } + + /// `formattedValue` reads the value as the field's picture shows it, and `rawValue` the + /// value itself; once the script sets the value, `formattedValue` gives that value. + #[test] + fn formatted_value_reads_the_shown_value_until_the_script_sets_one() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let run = |script: &str| run_xfa(script, &ev, &doc, &dated_form(), Limits::default()); + let o = run("this.formattedValue + ' ' + this.rawValue"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20")); + let o = run("this.rawValue = '2026-10-21'; this.formattedValue"); + assert_eq!(o.result.as_deref(), Some("2026-10-21")); + } + + /// Long values a script sets, and long results, are cut where they are stored, with a note. #[test] fn long_values_and_results_are_cut_where_they_are_stored() { // The value kept for later reads is the one the effect saves. @@ -476,6 +761,8 @@ mod xfa_model { assert_eq!(o.result.as_deref(), Some("4/1")); } + /// `xfa.host`, `xfa.layout`, `xfa.event` and `app` calls become effects, and messages keep + /// their details. #[test] fn host_layout_event_and_app_calls_become_effects() { let o = go( @@ -487,7 +774,12 @@ mod xfa_model { assert_eq!( o.effects, vec![ - XfaEffect::MessageBox("Hi".into()), + XfaEffect::MessageBox(Message { + text: "Hi".into(), + title: Some("T".into()), + icon: MessageIcon::Status, + buttons: MessageButtons::OkCancel + }), XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::SaveAs, @@ -532,6 +824,7 @@ mod xfa_model { assert_eq!(o.result.as_deref(), Some("1002")); } + /// A value set again replaces the earlier one, and hostile loops end at the caps. #[test] fn values_set_again_merge_and_hostile_loops_are_capped() { // A calculate that sets its own value in a loop leaves one effect: the last value. @@ -553,9 +846,127 @@ mod xfa_model { assert!(o.notes.iter().any(|n| n.contains("changes")), "{:?}", o.notes); // Long messages are cut. let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click"); - assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); + assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.text.chars().count() <= 4_097)); } + /// The message box `script` showed from a button's click script. + fn boxed(script: &str) -> Message { + let o = go(script, "form1[0].page1[0].go[0]", "click"); + assert_eq!(o.error, None, "{script}"); + match o.effects.as_slice() { + [XfaEffect::MessageBox(m)] => m.clone(), + other => panic!("{script}: {other:?}"), + } + } + + /// `xfa.host.messageBox(message, title, icon, buttons)` keeps every icon and button set the + /// XFA specification numbers, and the title. + #[test] + fn message_box_keeps_its_title_icon_and_buttons() { + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let m = boxed(&format!("xfa.host.messageBox('Sure?', 'Order form', {i}, {b})")); + assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}"); + } + } + assert_eq!(boxed("xfa.host.messageBox('Saved')"), Message::plain("Saved")); + // A bad number is the default; the message still shows. + assert_eq!(boxed("xfa.host.messageBox('Hi', '', 7, -2)"), Message::plain("Hi")); + assert_eq!(boxed("xfa.host.messageBox('Hi', null, 2.5, 'x')"), Message::plain("Hi")); + let m = boxed("xfa.host.messageBox('Hi', new Array(100000).join('t'))"); + assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097)); + // Answers are always OK for now (#4291). + let o = go("this.rawValue = xfa.host.messageBox('Delete?', 'T', 2, 2)", "form1[0].page1[0].qty[0]", "click"); + assert!(o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { value, .. } if value == "1")), "{:?}", o.effects); + } + + /// `app.alert` in an XFA form's script reads its own order (`cMsg, nIcon, nType, cTitle`) + /// and its object form, not `messageBox`'s. + #[test] + fn app_alert_in_an_xfa_script_reads_app_alerts_arguments() { + let m = boxed("app.alert('Sure?', 1, 3, 'Order form')"); + assert_eq!( + m, + Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Warning, buttons: MessageButtons::YesNoCancel } + ); + let m = boxed("app.alert({ cMsg: 'Sure?', cTitle: 'Order form', nIcon: 2, nType: 1 })"); + assert_eq!( + m, + Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::OkCancel } + ); + } + + /// Past the byte budget, adding or removing a row is left out of the snapshot as well as the + /// effects, so the script never counts rows the form will not get. + #[test] + fn rows_changed_past_the_byte_budget_are_left_out_of_the_snapshot_too() { + let o = go( + "var s = 'x'; while (s.length < 4096) s += s; for (var i = 0; i < 9000; i++) xfa.host.gotoURL(s); \ + table._row.removeInstance(0); var added = table._row.addInstance(1); (added === null) + ':' + table._row.count", + "form1[0].page1[0].go[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert!(o.notes.iter().any(|n| n.contains("MiB")), "{:?}", o.notes); + assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. }))); + assert_eq!(o.result.as_deref(), Some("true:2")); + } + + /// Past the effect cap, presence and access set on a new object are left out of the snapshot + /// as well as the effects; one set again on an object that already has its effect still lands. + #[test] + fn presence_and_access_past_the_effect_cap_are_left_out_of_the_snapshot_too() { + let read = "qty.presence + ':' + qty.access + ':' + price.presence"; + let before = go(read, "form1[0].page1[0]", "click").result; + let o = go( + &format!( + "price.presence = 'invisible'; for (var i = 0; i < 20000; i++) xfa.host.beep(); qty.presence = 'hidden'; qty.access = 'readOnly'; price.presence = 'hidden'; {read}" + ), + "form1[0].page1[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.effects.len(), MAX_EFFECTS); + let unchanged = before.as_deref().and_then(|b| b.rsplit_once(':')).map(|(qty, _)| format!("{qty}:hidden")); + assert_eq!(o.result, unchanged); + assert!(o.effects.contains(&XfaEffect::SetPresence { som: "form1[0].page1[0].price[0]".into(), presence: "hidden".into() })); + assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::SetPresence { som, .. } | XfaEffect::SetAccess { som, .. } if som.contains("qty")))); + } + + /// Past the effect cap, a value written to a field with no effect yet is left out of reads as + /// well as the effects; a field written again before the cap still takes its new value. + #[test] + fn a_value_past_the_effect_cap_is_left_out_of_reads_and_effects_alike() { + let o = go( + "price.rawValue = 1; for (var i = 0; i < 20000; i++) xfa.host.beep(); qty.rawValue = 9; price.rawValue = 3; qty.rawValue + ':' + price.rawValue", + "form1[0].page1[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.effects.len(), MAX_EFFECTS); + assert_eq!(o.result.as_deref(), Some("4:3")); + assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { som, .. } if som.contains("qty")))); + } + + #[test] + fn urls_and_reset_lists_are_clipped_and_deduplicated() { + let o = go( + "var s = 'x'; while (s.length < 65536) s += s; for (var i = 0; i < 600; i++) xfa.host.gotoURL(s + i); \ + var r = 'a,'; while (r.length < 262144) r += r; xfa.host.resetData(r);", + "form1[0].page1[0]", + "initialize", + ); + assert_eq!(o.error, None); + let urls: Vec<&String> = o.effects.iter().filter_map(|e| if let XfaEffect::LaunchUrl(u) = e { Some(u) } else { None }).collect(); + assert_eq!(urls.len(), 600); + assert!(urls.iter().all(|u| u.chars().count() <= 4_097)); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 1); + } + + /// Time the engine takes to start is not taken from the script's own time. #[test] fn a_slow_engine_start_does_not_count_against_the_script() { let ms = std::time::Duration::from_millis; @@ -590,9 +1001,9 @@ mod xfa_model { mod formcalc { use super::xfa_model::form; - use crate::Limits; use crate::formcalc::run_formcalc; use crate::xfa::*; + use crate::{Limits, Message, MessageButtons, MessageIcon}; fn go(script: &str, target: &str, activity: &str) -> XfaOutcome { let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() }; @@ -607,6 +1018,78 @@ mod formcalc { o.result.unwrap_or_default() } + /// A negative value that rounds to zero under the picture prints without a minus sign. + #[test] + fn format_does_not_print_negative_zero() { + assert_eq!(calc(r#"Format("9.9", -0.04)"#), "0.0"); + assert_eq!(calc(r#"Format("99", -0.2)"#), "00"); + assert_eq!(calc(r#"Format("9.9", -1.5)"#), "-1.5"); + } + + /// Assigning one long value to many fields at once stops at the run's byte budget and is + /// charged per copy, and a reset list names each field once. + #[test] + fn bulk_assignments_and_reset_lists_are_bounded() { + let f = |i: usize| super::xfa_model::field("f", &format!("form1[0].page1[0].f[{i}]"), "", false); + let root = XfaNode { + name: "form1".into(), + som: "form1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![XfaNode { + name: "page1".into(), + som: "form1[0].page1[0]".into(), + kind: Some(XfaKind::Subform), + children: (0..1300).map(f).collect(), + ..Default::default() + }], + ..Default::default() + }; + let ev = XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() }; + let run = |script: &str| run_formcalc(script, &ev, &XfaDoc::default(), &root, Limits::default()); + let o = run("var s = \"x\"\nwhile (Len(s) < 65536) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error, None, "{o:?}"); + let kept: usize = o.effects.iter().map(|e| if let XfaEffect::SetValue { value, .. } = e { value.len() } else { 0 }).sum(); + assert!(kept <= MAX_PAYLOAD, "{kept} bytes of values kept"); + assert!(o.notes.iter().any(|n| n.contains("left out")), "{:?}", o.notes); + // 1300 copies of a 1 MiB value are more work than a script may do. + let o = run("var s = \"x\"\nwhile (Len(s) < 1048576) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error.as_deref(), Some("the script ran too long"), "{:?}", o.notes); + let o = run("var r = \"a,\"\nwhile (Len(r) < 262144) do r = Concat(r, r) endwhile\n$host.resetData(r)\n$host.resetData(r)"); + assert_eq!(o.error, None, "{o:?}"); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 2); + } + + /// FormCalc presence and access past the effect cap are left out of the snapshot too. + #[test] + fn formcalc_presence_and_access_past_the_effect_cap_are_left_out_of_the_snapshot_too() { + let o = go( + "for i = 1 upto 20000 do $host.beep() endfor\nqty.presence = \"hidden\"\nqty.access = \"readOnly\"\nConcat(qty.presence, \":\", qty.access)", + "form1[0].page1[0]", + "click", + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.effects.len(), MAX_EFFECTS); + let before = go("Concat(qty.presence, \":\", qty.access)", "form1[0].page1[0]", "click"); + assert_eq!(o.result, before.result); + } + + /// FormCalc reads `formattedValue` as the field's picture shows it too, until the script + /// sets the value. + #[test] + fn formcalc_formatted_value_reads_the_shown_value_until_the_script_sets_one() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let run = |script: &str| run_formcalc(script, &ev, &doc, &super::xfa_model::dated_form(), Limits::default()); + let o = run("Concat($.formattedValue, \" \", $.rawValue)"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20")); + let o = run("$.rawValue = \"2026-10-21\"\n$.formattedValue"); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("2026-10-21")); + } + + /// FormCalc arithmetic, string conversion and comparisons follow FormCalc's own rules. #[test] fn arithmetic_strings_and_comparisons_follow_formcalc_rules() { assert_eq!(calc("1 + 2 * 3"), "7"); @@ -651,6 +1134,17 @@ mod formcalc { assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x"); } + /// Accepted day numbers past chrono's own date range (about year 262143) keep the dates the + /// pre-chrono algorithm gave, on both sides of the epoch, through Num2Date and Format. + #[test] + fn date_numbers_beyond_chronos_range_keep_their_dates() { + assert_eq!(calc(r#"Num2Date(99000000, "YYYY-MM-DD")"#), "272952-10-16"); + assert_eq!(calc(r#"Format("YYYY-MM-DD", 99000000)"#), "272952-10-16"); + assert_eq!(calc(r#"Num2Date(99999999, "YYYY-MM-DD")"#), "275690-09-11"); + assert_eq!(calc(r#"Num2Date(-99000000, "YYYY-MM-DD")"#), "-269153-03-16"); + assert_eq!(calc(r#"Num2Date(-99999999, "YYYY-MM-DD")"#), "-271891-04-20"); + } + #[test] fn str_and_format_round_exact_ties_away_from_zero() { assert_eq!(calc("Str(2.5, 3, 0)"), " 3"); @@ -664,9 +1158,25 @@ mod formcalc { assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34"); } + #[test] + fn round_keeps_huge_values_finite() { + assert_eq!(calc("Round(1e300, 12) == 1e300"), "1"); + assert_eq!(calc("Round(1.2345, 2)"), "1.23"); + } + #[test] fn built_in_functions_cover_the_common_set() { + assert_numeric_and_logical_builtins(); + assert_string_builtins(); + assert_format_and_date_builtins(); + assert_financial_and_unit_builtins(); + assert_builtin_errors(); + } + + fn assert_numeric_and_logical_builtins() { assert_eq!(calc("Abs(-2.5)"), "2.5"); + assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b"); + assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb"); assert_eq!(calc("Ceil(2.1)"), "3"); assert_eq!(calc("Floor(-2.1)"), "-3"); assert_eq!(calc("Mod(7, 3)"), "1"); @@ -681,6 +1191,9 @@ mod formcalc { assert_eq!(calc(r#"Within(5, 1, 10)"#), "1"); assert_eq!(calc(r#"HasValue("")"#), "0"); assert_eq!(calc(r#"HasValue("x")"#), "1"); + } + + fn assert_string_builtins() { assert_eq!(calc(r#"At("abcd", "c")"#), "3"); assert_eq!(calc(r#"Left("hello", 2)"#), "he"); assert_eq!(calc(r#"Right("hello", 2)"#), "lo"); @@ -697,6 +1210,11 @@ mod formcalc { assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two"); assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents"); assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent"); + assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); + assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_format_and_date_builtins() { assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50"); assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00"); assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024"); @@ -708,18 +1226,37 @@ mod formcalc { assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD")"#), "2024-02-29"); assert_eq!(calc(r#"Num2Date(Date2Num("2024-02-29") + 1, "MMMM D, YYYY")"#), "March 1, 2024"); assert_eq!(calc(r#"Time2Num("01:00:00")"#), "3600000"); + assert_eq!(calc(r#"IsoTime2Num("12:30:45Z")"#), "45045000"); + assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250"); + assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000"); assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01"); + } + + fn assert_financial_and_unit_builtins() { assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49"); assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25"); assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51"); assert_eq!(calc(r#"UnitValue("2in", "mm")"#), "50.8"); assert_eq!(calc(r#"UnitType("2.5cm")"#), "cm"); - assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); - assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1"); + assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1"); + assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72"); + } + + fn assert_builtin_errors() { assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network")); assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function")); } + #[test] + fn format_nine_pads_the_integer_part() { + assert_eq!(calc(r#"Format("99999", 42)"#), "00042"); + assert_eq!(calc(r#"Format("9999", 5)"#), "0005"); + assert_eq!(calc(r#"Format("999.99", 4.5)"#), "004.50"); + assert_eq!(calc(r#"Format("zzz9", 42)"#), "42"); + assert_eq!(calc(r#"Format("99", 12345)"#), "12345"); + } + #[test] fn date2num_rejects_days_past_the_end_of_the_month() { assert_eq!(calc(r#"Date2Num("2023-02-30")"#), "0"); @@ -777,8 +1314,16 @@ s"# assert!(o.error.unwrap().contains("endif")); } + /// FormCalc resolves form objects, assigns to them and records the effects. #[test] fn form_objects_resolve_assign_and_record_effects() { + assert_formcalc_reads_fields_and_rows(); + assert_formcalc_reads_runtime_properties(); + assert_formcalc_records_primary_effects(); + assert_formcalc_updates_instances_and_records_requests(); + } + + fn assert_formcalc_reads_fields_and_rows() { // Fields deref to their values; `$` is the current field. assert_eq!(calc("qty * price"), "10"); assert_eq!(calc("$.name"), "total"); @@ -802,7 +1347,6 @@ s"# assert_eq!(in_row("Sum(row[*].amount)"), "15"); assert_eq!(in_row("row[1].amount"), "5"); assert_eq!(in_row("row[-1].amount"), "10"); - // Past the ends there is no object, so a property of it is an error (as in Acrobat). let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate"); assert!(past.error.unwrap().contains("no object")); assert_eq!(in_row("Exists(row[-5])"), "0"); @@ -814,12 +1358,18 @@ s"# assert_eq!(calc("table._row.count"), "2"); assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1"); assert_eq!(calc("HasValue(total)"), "0"); + } + + fn assert_formcalc_reads_runtime_properties() { assert_eq!(calc("$host.numPages"), "3"); assert_eq!(calc("$layout.pageCount()"), "3"); assert_eq!(calc("$event.name"), "calculate"); assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5"); assert_eq!(calc("$.border.fill.color.value"), ""); assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name")); + } + + fn assert_formcalc_records_primary_effects() { // Assignment to a field (by name and by path), a property and a variable. let o = go( r#"page1.qty = 6 @@ -842,12 +1392,14 @@ table._row.count"#, XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "7".into() }, XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() }, XfaEffect::SetAccess { som: "form1[0].page1[0].price[0]".into(), access: "readOnly".into() }, - XfaEffect::MessageBox("Total 17.5".into()), + XfaEffect::MessageBox(Message::plain("Total 17.5")), XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()), XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, ] ); - // Instance managers: remove, setInstances, count assignment, and the floor of one. + } + + fn assert_formcalc_updates_instances_and_records_requests() { let o = go( "table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count", "form1[0].page1[0].go[0]", @@ -870,7 +1422,7 @@ $host.gotoURL("https://example.org")"#, ); assert_eq!( o.effects, - vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())] + vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into()),] ); } @@ -909,60 +1461,82 @@ $host.gotoURL("https://example.org")"#, assert_eq!(run(&script).result.as_deref(), Some("2")); } + /// Hostile FormCalc scripts end with an error and bounded output. #[test] fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() { - let total = "form1[0].page1[0].total[0]"; - let fast = Limits { loop_iterations: 10_000, recursion: 32 }; - let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; - let doc = XfaDoc::default(); - let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast); - assert!(run("while (1) do endwhile").error.unwrap().contains("looped")); - assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); - assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); - assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); + assert_hostile_control_flow_is_bounded(); + assert_hostile_numeric_inputs_are_safe(); + assert_hostile_outputs_are_bounded(); + assert_hostile_nesting_is_bounded(); + assert_hostile_deadlines_and_parse_errors(); + assert_hostile_instance_growth_is_bounded(); + assert_hostile_message_count_is_bounded(); + assert_hostile_small_stack_is_safe(); + } + + fn assert_hostile_control_flow_is_bounded() { + assert!(run_hostile_formcalc("while (1) do endwhile").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); // A 120k-term chain is refused at parse time (its tree would be as deep as it is long). - assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run("nothing.x = 1").error.unwrap().contains("unknown name")); - assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing")); + assert!(run_hostile_formcalc(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("nothing.x = 1").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("$form.nothing = 1").error.unwrap().contains("no nothing")); // Values go into fields only: a subform or the form itself is refused, nothing is recorded. - let o = run("details = \"junk\""); + let o = run_hostile_formcalc("details = \"junk\""); assert!(o.error.unwrap().contains("not a field")); assert!(o.effects.is_empty()); - assert!(run("$form = \"junk\"").error.unwrap().contains("not a field")); + assert!(run_hostile_formcalc("$form = \"junk\"").error.unwrap().contains("not a field")); + } + + fn assert_hostile_numeric_inputs_are_safe() { // Hostile numbers into the string functions. - assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); - assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); - assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); - assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); - assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Round(1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Choose(1e300, 1)"#).error.is_none()); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); + assert_eq!(run_hostile_formcalc(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); + assert_eq!(run_hostile_formcalc(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Round(1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Choose(1e300, 1)"#).error.is_none()); + } + + fn assert_hostile_outputs_are_bounded() { // Strings can't grow past the cap through any function, and messages and values are clipped. assert!( - run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") + run_hostile_formcalc("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") .error .unwrap() .contains("too long") ); - assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); - let o = run("$host.messageBox(Space(100000))"); - assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len()); - let o = run("qty = Space(100000)"); + assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); + let o = run_hostile_formcalc("$host.messageBox(Space(100000))"); + assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.text.chars().count() <= 4100), "{:?}", o.effects.len()); + let o = run_hostile_formcalc("qty = Space(100000)"); assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len()); assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes); + } + + fn assert_hostile_nesting_is_bounded() { // A comment of dashes is not deep nesting. - assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); + assert_eq!(run_hostile_formcalc(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); // Parentheses don't reset the chain count: a tree is never deeper than the cap. let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string(); for _ in 0..30 { nested = format!("({nested}{})", "+1".repeat(40)); } - assert!(run(&nested).error.unwrap().contains("too long")); - assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); + assert!(run_hostile_formcalc(&nested).error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); // Nested statements inside recursion count against the depth limit. let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20)); - assert!(run(&deep).error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&deep).error.unwrap().contains("nested")); + } + + fn assert_hostile_deadlines_and_parse_errors() { + let total = "form1[0].page1[0].total[0]"; + let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; + let doc = XfaDoc::default(); // A deadline already passed stops the script at its first look at the clock. let o = crate::formcalc::run_formcalc_at( "for i = 1 upto 100000 do Eval(\"1\") endfor", @@ -973,29 +1547,40 @@ $host.gotoURL("https://example.org")"#, Some(std::time::Duration::ZERO), ); assert!(o.error.unwrap().contains("too long")); - assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); - assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); - assert!(run("Space(1e12)").error.is_none()); - assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); - assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); - assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); - assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None); - assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); - assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); - assert!(run("\"unterminated").error.unwrap().contains("unterminated")); - assert!(run("1 ) 2").error.is_some()); - assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); + assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("Space(1e12)").error.is_none()); + assert!(run_hostile_formcalc("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Time2Num("99999999999999999999:0:0")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("\"unterminated").error.unwrap().contains("unterminated")); + assert!(run_hostile_formcalc("1 ) 2").error.is_some()); + assert!(run_hostile_formcalc("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + } + + fn assert_hostile_instance_growth_is_bounded() { // Adding rows forever stops at the instance cap with the effects it managed. - let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); assert!(o.error.unwrap().contains("looped")); assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len()); - let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); assert_eq!(o.error, None); assert_eq!(o.result.as_deref(), Some("1002")); + } + + fn assert_hostile_message_count_is_bounded() { // Message boxes past the cap are dropped and noted. - let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor"); + let o = run_hostile_formcalc("for i = 1 upto 500 do $host.messageBox(i) endfor"); assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100); assert!(!o.notes.is_empty()); + } + + fn assert_hostile_small_stack_is_safe() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); // On a small stack (the wasm build's), the limits stop the script before the stack does. let small = std::thread::Builder::new() .stack_size(1 << 20) @@ -1032,6 +1617,34 @@ $host.gotoURL("https://example.org")"#, ); assert!(o.abandoned || o.error.is_some(), "{o:?}"); } + + /// FormCalc's `$host.messageBox(message, title, icon, buttons)` keeps every icon and button + /// set and the title, as the JavaScript one does. + #[test] + fn formcalc_message_box_keeps_its_title_icon_and_buttons() { + let fc = |s: &str| go(s, "form1[0].page1[0].go[0]", "click"); + let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status]; + let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel]; + for (i, icon) in icons.iter().enumerate() { + for (b, kind) in buttons.iter().enumerate() { + let o = fc(&format!("$host.messageBox(\"Sure?\", \"Order form\", {i}, {b})")); + let want = Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }; + assert_eq!(o.effects, vec![XfaEffect::MessageBox(want)], "{i} {b}"); + } + } + // Missing or bad numbers are the defaults; a long title is cut like the message. + assert_eq!(fc("$host.messageBox(\"Hi\")").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]); + assert_eq!(fc("$host.messageBox(\"Hi\", \"\", 9, 1.5)").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]); + let o = fc("$host.messageBox(\"Hi\", Space(100000))"); + assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097))); + } + + fn run_hostile_formcalc(script: &str) -> XfaOutcome { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let fast = Limits { loop_iterations: 10_000, recursion: 32 }; + run_formcalc(script, &ev, &doc, &form(), fast) + } } mod session { @@ -1040,6 +1653,8 @@ mod session { use crate::xfa::*; use crate::xfa_session::{Script, XfaSession}; + /// A JavaScript or FormCalc script for `activity` on `target`, against the test form with + /// default limits and a 3 s timeout. fn script(source: &str, formcalc: bool, target: &str, activity: &str) -> Script { Script { source: source.into(), @@ -1051,6 +1666,9 @@ mod session { } } + /// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller + /// sends: nothing an earlier script changed or defined leaks into the next, a failing script + /// doesn't end the session, and hundreds of scripts run on its one thread. #[test] fn scripts_of_one_event_run_on_one_thread_each_on_the_form_the_caller_sends() { let mut s = XfaSession::new(XfaDoc { page_count: 3, ..Default::default() }); @@ -1090,6 +1708,8 @@ mod session { assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); } + /// A JavaScript or FormCalc script whose engine takes longer to be ready than the script's + /// time limit still runs and returns its result, and the session stays alive. #[test] fn a_scripts_time_starts_when_its_context_is_ready() { // The engine made slow to be ready (300 ms) under a 50 ms script limit: the script is @@ -1110,6 +1730,9 @@ mod session { assert_eq!(s.dead(), None); } + /// An endless JavaScript loop past its 200 ms limit is abandoned within 2 s and leaves the + /// session dead, so nothing more runs in it; an endless FormCalc loop is abandoned or + /// reported as running too long. #[test] fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() { let mut s = XfaSession::new(XfaDoc::default()); @@ -1132,3 +1755,306 @@ mod session { assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}"); } } + +#[test] +fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { + let initial = Arc::new(fields()); + let before = initial.as_ref().clone(); + let event = Event::field("Calculate", "total", ""); + let write = run_shared( + "var scriptLocal = 1; getField('qty').value = 9; getField('agree').checkThisBox(0, true); event.value = getField('qty').value;", + &event, + &doc(), + Arc::clone(&initial), + &[], + Limits::default(), + ); + assert_eq!(write.error, None); + assert_eq!(write.value, "9"); + assert_eq!(write.changed.iter().find(|f| f.name == "qty").unwrap().value, ["9"]); + assert_eq!(write.changed.iter().find(|f| f.name == "agree").unwrap().value, ["Yes"]); + assert_eq!(*initial, before, "script writes cannot mutate the caller's snapshot"); + let failed = run_shared("getField('qty').value = 99; throw new Error('rejected');", &event, &doc(), Arc::clone(&initial), &[], Limits::default()); + assert!(failed.error.is_some()); + assert_eq!(*initial, before, "an exception cannot leak partial writes into the initial snapshot"); + let read = run_shared( + "event.value = typeof scriptLocal + ':' + getField('qty').value + ':' + getField('agree').isBoxChecked(0);", + &event, + &doc(), + initial, + &[], + Limits::default(), + ); + assert_eq!(read.error, None); + assert_eq!(read.value, "undefined:4:false"); + assert!(read.changed.is_empty()); +} + +mod scand { + use super::*; + use crate::xfa::{XfaDoc, XfaEvent, run_xfa}; + + /// Runs `util.scand(format, text)` for each case and checks the date it gives, written as + /// `yyyy-mm-dd HH:MM:ss`, or `null`. + fn assert_scand(cases: &[(&str, &str, &str)]) { + let list: Vec = cases.iter().map(|(f, t, _)| format!("['{f}', '{t}']")).collect(); + let script = format!( + "event.value = [{}].map(function (c) {{ var d = util.scand(c[0], c[1]); \ + return d === null ? 'null' : util.printd('yyyy-mm-dd HH:MM:ss', d); }}).join('|');", + list.join(", ") + ); + let o = go(&script, &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + let got: Vec<&str> = o.value.split('|').collect(); + assert_eq!(got.len(), cases.len()); + for ((f, t, want), got) in cases.iter().zip(got) { + assert_eq!(got, *want, "util.scand('{f}', '{t}')"); + } + } + + // The cases below are another viewer's answers, observed black-box with a synthetic file. + + /// Any separator may come between fields, whether the format has that one, another or none, + /// but a text with a space or a letter before or after the date reads nothing. + #[test] + fn scand_allows_separators_between_fields_but_nothing_before_or_after_them() { + assert_scand(&[ + ("yyyymmdd", "20261018", "2026-10-18 00:00:00"), + ("yyyymmdd", "2026-10-18", "2026-10-18 00:00:00"), + ("yyyymmdd", "2026 10 18", "2026-10-18 00:00:00"), + ("mm/dd/yyyy", "10/18/2026", "2026-10-18 00:00:00"), + ("mm/dd/yyyy", "10-18-2026", "2026-10-18 00:00:00"), + ("yyyymmdd", " 20261018 ", "null"), + ("yyyymmdd", "20261018x", "null"), + ("yyyymmdd", "x20261018", "null"), + ]); + } + + /// Digits typed together fill the joined fields with exactly as many digits as each field has + /// letters, so a run of any other length reads nothing. + #[test] + fn scand_splits_digits_typed_together_by_the_formats_letter_counts() { + assert_scand(&[ + ("yyyymmdd", "261018", "null"), + ("yyyymmdd", "2026118", "null"), + ("yyyymmdd", "202610181", "null"), + ("yymmdd", "20261018", "null"), + ("yyyymdd", "2026118", "2026-01-18 00:00:00"), + ("yyyymmd", "2026118", "2026-11-08 00:00:00"), + ("yyyymd", "2026118", "null"), + ("yyyymd", "202618", "2026-01-08 00:00:00"), + ("yyyymd", "20261018", "null"), + ("yymd", "2618", "2026-01-08 00:00:00"), + ("yymd", "261018", "null"), + ]); + } + + /// A month past 12, a day past the month's end and February 29 outside a leap year are + /// refused, while a month or day of 0 is read as 1. + #[test] + fn scand_refuses_impossible_dates_and_reads_a_zero_month_or_day_as_1() { + assert_scand(&[ + ("yyyymmdd", "20261318", "null"), + ("yyyymmdd", "20260230", "null"), + ("yyyymmdd", "20260229", "null"), + ("yyyymmdd", "20240229", "2024-02-29 00:00:00"), + ("mm/dd/yyyy", "13/45/2026", "null"), + ("yyyymmdd", "20260018", "2026-01-18 00:00:00"), + ("yyyymmdd", "20261000", "2026-10-01 00:00:00"), + ]); + } + + /// A field typed by itself may have one or two digits, but a `yyyy` year needs four; `yy` reads + /// as 1950–2049; month names may be full or three letters in any case; weekday names are read. + #[test] + fn scand_reads_lone_fields_two_digit_years_and_month_and_weekday_names() { + assert_scand(&[ + ("mm/dd/yyyy", "1/5/2026", "2026-01-05 00:00:00"), + ("m/d/yyyy", "1/5/2026", "2026-01-05 00:00:00"), + ("m/d/yyyy", "01/05/2026", "2026-01-05 00:00:00"), + ("m/d/yy", "1/5/26", "2026-01-05 00:00:00"), + ("mm/dd/yyyy", "10/18/26", "null"), + ("yyyy/mm/dd", "26/10/18", "null"), + ("yymmdd", "261018", "2026-10-18 00:00:00"), + ("yymmdd", "491018", "2049-10-18 00:00:00"), + ("yymmdd", "501018", "1950-10-18 00:00:00"), + ("mmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"), + ("mmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"), + ("mmm d, yyyy", "oct 18, 2026", "2026-10-18 00:00:00"), + ("mmmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"), + ("mmmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"), + ("dd-mmm-yyyy", "18-Oct-2026", "2026-10-18 00:00:00"), + ("dddd, mmmm d, yyyy", "Sunday, October 18, 2026", "2026-10-18 00:00:00"), + ]); + } + + /// A time is read along with a date, but a format with only time fields reads nothing. + #[test] + fn scand_reads_a_time_only_with_a_date() { + assert_scand(&[ + ("mm/dd/yyyy HH:MM:ss", "10/18/2026 21:07:05", "2026-10-18 21:07:05"), + ("HH:MM", "09:07", "null"), + ("HH:MM", "9:07", "null"), + ("h:MM tt", "9:07 pm", "null"), + ("h:MM tt", "9:07 am", "null"), + ("h:MM tt", "12:07 am", "null"), + ("h:MM tt", "9:07pm", "null"), + ]); + } + + /// An empty text reads as today at noon, and a text that is not a string (a number, or none + /// at all) reads nothing. + #[test] + fn scand_reads_only_text_and_an_empty_text_is_today_at_noon() { + let o = go( + "var d = util.scand('yyyymmdd', ''), n = new Date(); \ + event.value = [d.toDateString() == n.toDateString(), d.getHours(), d.getMinutes(), d.getSeconds(), \ + util.scand('yyyymmdd', 20261018), util.scand(), util.scand('yyyymmdd')].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|12|0|0|||"); + } + + // Not observed: the choices made where the other viewer wasn't asked. + + /// A year left out is this year, `h:MM tt` reads a 12-hour time and refuses hours it cannot + /// have, and a letter the format writes must be typed there. + #[test] + fn scand_takes_this_year_when_none_is_given_and_reads_12_hour_times_and_written_letters() { + assert_scand(&[ + ("mm/dd/yyyy h:MM tt", "10/18/2026 9:07 pm", "2026-10-18 21:07:00"), + ("mm/dd/yyyy h:MM tt", "10/18/2026 12:07 AM", "2026-10-18 00:07:00"), + ("mm/dd/yyyy h:MM tt", "10/18/2026 13:07 pm", "null"), + ("mm/dd/yyyy HH:MM", "10/18/2026 24:00", "null"), + ("yyyy-mm-ddTHH:MM", "2026-10-18T21:07", "2026-10-18 21:07:00"), + ("yyyy-mm-ddTHH:MM", "2026-10-18X21:07", "null"), + ]); + let o = go( + "var d = util.scand('mm/dd', '10/18'); event.value = [d.getFullYear() == new Date().getFullYear(), d.getMonth(), d.getDate()].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|9|18"); + } + + /// A format of 50,000 literal letters and one of 100,000 fields, with texts to match, are read + /// in linear time. The limit is generous so a loaded runner passes (it also covers starting + /// the engine and building the strings); a quadratic scan of inputs this size takes minutes. + #[test] + fn scand_reads_long_formats_and_texts_in_linear_time() { + // Scripts can pass anything; native code isn't bounded by the loop limit. + let started = std::time::Instant::now(); + let o = go( + "var x = new Array(50001).join('x'), md = new Array(50001).join('md'), ones = new Array(100000).join('1 ') + '1'; \ + event.value = [util.scand(x + 'yyyy', x + '2026') !== null, util.scand('yyyy' + md, '2026 ' + ones) !== null].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|true"); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); + } + + /// The check an XFA form runs on leaving a date field tries six formats with `util.scand` and + /// writes the first that reads the text back as `yyyymmdd`, or reports an invalid date. + #[test] + fn xfa_scripts_check_typed_dates_with_util_scand() { + // The check a form runs when a date field is left: the first of six formats that reads + // the typed text, written back as yyyymmdd; or an invalid date. Results as another viewer gives them. + let check = |typed: &str| { + let script = format!( + "var six = ['yyyymmdd', 'yymmdd', 'yyyymdd', 'yyyymmd', 'yyyymd', 'yymd'], out = 'invalid'; \ + for (var i = 0; i < six.length; i++) {{ var d = util.scand(six[i], '{typed}'); \ + if (d !== null) {{ out = util.printd('yyyymmdd', d); break; }} }} out" + ); + let event = XfaEvent { activity: "exit".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + run_xfa(&script, &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default()) + }; + for (typed, want) in [ + ("20261018", "20261018"), + ("261018", "20261018"), + ("2026118", "20260118"), + ("202618", "20260108"), + ("2618", "20260108"), + ("2026-10-18", "20261018"), + ("20261318", "invalid"), + ("20260230", "invalid"), + ] { + let o = check(typed); + assert_eq!(o.error, None, "{typed}: {o:?}"); + assert_eq!(o.result.as_deref(), Some(want), "{typed}"); + } + } +} + +mod menu_items { + use super::*; + use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + + /// Every side panel has a fixed id in automation output. + #[test] + fn each_panel_has_a_fixed_id_for_automation() { + let ids = [Panel::Bookmarks, Panel::Pages, Panel::Attachments, Panel::Signatures, Panel::Layers].map(Panel::id); + assert_eq!(ids, ["bookmarks", "pages", "attachments", "signatures", "layers"]); + } + + /// Each side panel item, Print and SaveAs make their request, in order; an unknown item + /// makes none and is not an error. + #[test] + fn exec_menu_item_shows_side_panels_prints_and_saves() { + let o = go( + "app.execMenuItem('ShowHideFileAttachment'); app.execMenuItem('ShowHideBookmarks'); app.execMenuItem('ShowHideThumbnails'); \ + app.execMenuItem('ShowHideSignatures'); app.execMenuItem('ShowHideOptCont'); app.execMenuItem('Print'); \ + app.execMenuItem('SaveAs'); app.execMenuItem('NoSuchItem');", + &Event::field("Mouse Up", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!( + o.requests, + [ + Request::ShowPanel(Panel::Attachments), + Request::ShowPanel(Panel::Bookmarks), + Request::ShowPanel(Panel::Pages), + Request::ShowPanel(Panel::Signatures), + Request::ShowPanel(Panel::Layers), + Request::Print, + Request::SaveAs, + ] + ); + } + + /// `app.execMenuItem` works in XFA scripts too: a click shows the attachments panel. + #[test] + fn xfa_scripts_show_the_attachments_panel() { + let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; + let o = run_xfa("app.execMenuItem('ShowHideFileAttachment')", &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default()); + assert_eq!(o.error, None, "{o:?}"); + assert!(o.effects.contains(&XfaEffect::ShowPanel(Panel::Attachments)), "{:?}", o.effects); + } +} + +/// The check XFA forms run on opening: without both globals, or with a version below 2.8, it +/// alerts that the viewer can't show the form. +const XFA_CHECK: &str = "if (typeof(xfa_installed) == 'undefined' || typeof(xfa_version) == 'undefined' || xfa_version < 2.8) app.alert('old viewer'); \ + event.value = typeof xfa_installed + ' ' + typeof xfa_version + ' ' + xfa_version;"; + +/// An XFA form sees `xfa_installed` (true) and `xfa_version` (3.5), so the check passes. +#[test] +fn an_xfa_form_sees_the_xfa_plugin_installed() { + let xfa = DocInfo { xfa: true, ..doc() }; + let o = run(XFA_CHECK, &Event::doc("Open"), &xfa, &fields(), &[], Limits::default()); + assert_eq!(o.error, None); + assert_eq!(o.value, "boolean number 3.5"); + assert!(o.alerts.is_empty(), "{:?}", o.alerts); + let o = run("event.value = String(xfa_installed);", &Event::doc("Open"), &xfa, &fields(), &[], Limits::default()); + assert_eq!(o.value, "true"); +} + +/// A form without XFA sees `xfa_version` but no `xfa_installed`, so the check alerts. +#[test] +fn a_plain_form_sees_the_xfa_version_but_not_installed() { + let o = go(XFA_CHECK, &Event::doc("Open")); + assert_eq!(o.error, None); + assert_eq!(o.value, "undefined number 3.5"); + assert_eq!(o.alerts, [Message::plain("old viewer")]); +} diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index a2361da7e4..eb275d1822 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -8,1490 +8,29 @@ //! children, then its siblings, then each ancestor's children. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use boa_engine::object::ObjectInitializer; use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; -use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, text}; - -/// What kind of template object a node is. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum XfaKind { - /// `xfa.form` itself. - Form, - Subform, - Field, - ExclGroup, - Draw, - Area, -} - -impl XfaKind { - pub(crate) fn class_name(self) -> &'static str { - match self { - XfaKind::Form => "form", - XfaKind::Subform => "subform", - XfaKind::Field => "field", - XfaKind::ExclGroup => "exclGroup", - XfaKind::Draw => "draw", - XfaKind::Area => "area", - } - } -} - -/// One object of the form, as the caller snapshots it. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaNode { - pub name: String, - /// The SOM expression naming this instance (`form[0].head[0].familyName[0]`). - pub som: String, - pub kind: Option, - /// Fields: the raw value (ISO dates, on values for buttons); empty when null. - pub value: String, - /// Numeric fields give numbers to scripts. - pub numeric: bool, - /// `visible`, `invisible`, `hidden` or `inactive`. - pub presence: String, - /// `open`, `readOnly`, `protected` or `nonInteractive`. - pub access: String, - /// Subforms: may have several instances (`occur max` other than 1). - pub repeatable: bool, - pub occur_min: usize, - pub occur_max: Option, - /// This instance's index among its same-named siblings. - pub index: usize, - pub children: Vec, -} - -/// The event a script runs for. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaEvent { - /// `click`, `calculate`, `validate`, `initialize`, `change`, `exit`, … - pub activity: String, - /// The SOM of `this`. - pub target: String, - pub new_text: String, - pub prev_text: String, -} - -/// About the document. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaDoc { - pub file_name: String, - /// The current page, 0-based. - pub page: usize, - pub page_count: usize, -} - -/// Something a script changed or asked for. -#[derive(Clone, Debug, PartialEq)] -pub enum XfaEffect { - SetValue { - som: String, - value: String, - }, - SetPresence { - som: String, - presence: String, - }, - SetAccess { - som: String, - access: String, - }, - /// A new instance of a repeating subform, named by its own SOM (`…row[3]`). - AddInstance { - som: String, - }, - /// The instance at this SOM is removed. - RemoveInstance { - som: String, - }, - MessageBox(String), - /// `xfa.host.resetData()`: the named fields, or every field when empty. - ResetData(Vec), - Print, - SaveAs, - LaunchUrl(String), - SetFocus(String), - Beep, - /// `xfa.form.recalculate(true)`. - Recalculate, - /// `xfa.form.remerge()`, `xfa.layout.relayout()`. - Relayout, -} - -/// What running a script did. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct XfaOutcome { - pub effects: Vec, - pub console: Vec, - /// The script's error, if it failed. - pub error: Option, - /// The script's completion value (a calculate script's result; a validate script's verdict). - pub result: Option, - /// The completion value as a boolean, when it was one. - pub result_bool: Option, - /// Limits the script ran into (too many effects, message boxes or console lines): what - /// was left out, for the caller to report. - pub notes: Vec, - /// The script ran past its time limit and was left running on its own thread (the engine - /// can't interrupt it): the caller should run no more of this form's scripts. - pub abandoned: bool, -} - -/// Most nodes a snapshot may hold. -const MAX_NODES: usize = 200_000; -/// Most instances a script may add in one run. -pub(crate) const MAX_ADDED: usize = 1_000; -/// Deepest tree followed. -const MAX_DEPTH: usize = 64; -/// Most effects one run may produce (after values set again on the same object are merged). -pub const MAX_EFFECTS: usize = 10_000; -/// Most message boxes one run may show. -pub const MAX_ALERTS: usize = 100; -/// Most console lines one run may print. -pub const MAX_CONSOLE: usize = 1_000; -/// Longest message or console line kept, in characters. -const MAX_LINE: usize = 4_096; - -/// Longest value a script may put in a field (characters); longer ones are cut and noted. -const MAX_VALUE: usize = 65_536; - -/// `m` cut to [`MAX_LINE`] characters. -pub(crate) fn clip(m: String) -> String { - match m.char_indices().nth(MAX_LINE) { - Some((at, _)) => format!("{}…", m.get(..at).unwrap_or_default()), - None => m, - } -} - -/// `v` cut to [`MAX_VALUE`] characters, and whether it was cut. -pub(crate) fn clip_value(v: String) -> (String, bool) { - match v.char_indices().nth(MAX_VALUE) { - Some((at, _)) => (v.get(..at).unwrap_or_default().to_string(), true), - None => (v, false), - } -} - -/// A script's completion value (what a calculate script puts in its field) cut to -/// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. -pub(crate) fn clip_result(out: &mut XfaOutcome) { - if let Some(r) = out.result.take() { - let (r, cut) = clip_value(r); - if cut { - out.notes.push(format!("the script's result was longer than {MAX_VALUE} characters; it was cut")); - } - out.result = Some(r); - } -} - -/// One node of the flattened form, shared by the JavaScript and FormCalc engines. -pub(crate) struct HNode { - pub(crate) name: String, - pub(crate) som: String, - pub(crate) kind: XfaKind, - pub(crate) value: String, - pub(crate) numeric: bool, - pub(crate) presence: String, - pub(crate) access: String, - pub(crate) repeatable: bool, - pub(crate) occur_min: usize, - pub(crate) occur_max: Option, - pub(crate) index: usize, - pub(crate) parent: Option, - pub(crate) children: Vec, - /// Removed by `removeInstance`: skipped everywhere. - pub(crate) gone: bool, -} - -/// The form as a script sees and changes it, with the effects it records. -pub(crate) struct XHost { - pub(crate) nodes: Vec, - pub(crate) effects: Vec, - pub(crate) console: Vec, - pub(crate) doc: XfaDoc, - added: usize, - /// Index of `this`. - pub(crate) current: usize, - /// Where the effect setting a property of an object is in `effects`, by (property, SOM): - /// setting it again replaces that effect (the last value wins) unless something that - /// depends on order (rows added or removed, a reset) came after it. - set_at: HashMap<(u8, String), usize>, - /// Effects before this index are behind an order-dependent effect. - barrier: usize, - alerts: usize, - dropped_effects: usize, - clipped_values: usize, - dropped_alerts: usize, - dropped_console: usize, -} - -impl XHost { - /// The host for a script on `root` (the template's root subform), with `this` at the node - /// named by `target`. `xfa.form` is a synthetic root whose child is the root subform. - pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost { - let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() }; - let mut nodes = Vec::new(); - flatten(&form, None, &mut nodes, 0); - let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); - XHost { - nodes, - effects: Vec::new(), - console: Vec::new(), - doc: doc.clone(), - added: 0, - current, - set_at: HashMap::new(), - barrier: 0, - alerts: 0, - dropped_effects: 0, - clipped_values: 0, - dropped_alerts: 0, - dropped_console: 0, - } - } - - /// Record an effect: values, presence and access set again on the same object replace the - /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes) effects are counted - /// and dropped. - pub(crate) fn push(&mut self, e: XfaEffect) { - let e = match e { - XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), - XfaEffect::SetValue { som, value } => { - let (value, cut) = clip_value(value); - if cut { - self.clipped_values += 1; - } - XfaEffect::SetValue { som, value } - } - other => other, - }; - let key = match &e { - XfaEffect::SetValue { som, .. } => Some((0u8, som.clone())), - XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), - XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), - _ => None, - }; - if let Some(key) = key { - if let Some(&at) = self.set_at.get(&key) - && at >= self.barrier - && let Some(slot) = self.effects.get_mut(at) - { - *slot = e; - return; - } - if self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return; - } - self.set_at.insert(key, self.effects.len()); - self.effects.push(e); - return; - } - if matches!(e, XfaEffect::MessageBox(_)) { - if self.alerts >= MAX_ALERTS { - self.dropped_alerts += 1; - return; - } - self.alerts += 1; - } - if self.effects.len() >= MAX_EFFECTS { - self.dropped_effects += 1; - return; - } - if matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. } | XfaEffect::ResetData(_)) { - self.barrier = self.effects.len() + 1; - } - self.effects.push(e); - } - - pub(crate) fn print(&mut self, line: String) { - if self.console.len() >= MAX_CONSOLE { - self.dropped_console += 1; - } else { - self.console.push(line); - } - } - - /// What the limits left out. - pub(crate) fn notes(&self) -> Vec { - let mut out = Vec::new(); - if self.dropped_effects > 0 { - out.push(format!("the script made more than {MAX_EFFECTS} changes; {} more were left out", self.dropped_effects)); - } - if self.dropped_alerts > 0 { - out.push(format!("the script showed more than {MAX_ALERTS} messages; {} more were left out", self.dropped_alerts)); - } - if self.dropped_console > 0 { - out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); - } - if self.clipped_values > 0 { - out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); - } - out - } - - /// Give node `i` the value `text` when it is a field or exclusion group (nothing else - /// holds a value; writing one into a subform would put text where another viewer expects - /// a data group). Returns whether it was. - pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { - let Some(n) = self.nodes.get_mut(i) else { return false }; - if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { - return false; - } - // Cut here, not only in the effect: later reads in the script see what is saved, and - // the snapshot stays bounded however often a long value is written. - let (text, cut) = clip_value(text); - n.value = text.clone(); - let som = n.som.clone(); - if cut { - self.clipped_values += 1; - } - self.push(XfaEffect::SetValue { som, value: text }); - true - } -} - -type Shared = RefCell; - -fn host(ctx: &Context) -> JsResult<&Shared> { - ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) -} - -fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { - if depth > MAX_DEPTH || out.len() >= MAX_NODES { - return None; - } - let idx = out.len(); - out.push(HNode { - name: n.name.clone(), - som: n.som.clone(), - kind: n.kind.unwrap_or(XfaKind::Subform), - value: n.value.clone(), - numeric: n.numeric, - presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() }, - access: if n.access.is_empty() { "open".into() } else { n.access.clone() }, - repeatable: n.repeatable, - occur_min: n.occur_min, - occur_max: n.occur_max, - index: n.index, - parent, - children: Vec::new(), - gone: false, - }); - let mut kids = Vec::new(); - for c in &n.children { - if let Some(ci) = flatten(c, Some(idx), out, depth + 1) { - kids.push(ci); - } - } - if let Some(h) = out.get_mut(idx) { - h.children = kids; - } - Some(idx) -} - -// ── node objects ──────────────────────────────────────────────────────────────────────────── - -const IDX: &str = "__xfaNode"; - -fn node_index(target: &JsValue, ctx: &mut Context) -> JsResult { - let o = target.as_object().ok_or_else(|| error("not an XFA node"))?; - let v = o.get(JsString::from(IDX), ctx)?; - let n = v.to_number(ctx)?; - if !n.is_finite() || n < 0.0 { - return Err(error("not an XFA node")); - } - Ok(n as usize) -} - -/// A Proxy standing for node `idx`. -fn node_object(ctx: &mut Context, idx: usize) -> JsResult { - let target = ObjectInitializer::new(ctx).property(JsString::from(IDX), JsValue::from(idx as f64), Attribute::READONLY).build(); - let proxy = JsProxy::builder(target).get(node_get).set(node_set).has(node_has).build(ctx)?; - Ok(proxy.into()) -} - -/// An object that takes any property read or write without complaint (`this.border.fill…`, -/// `this.fillColor = …`): what a script sets there changes nothing here. -fn sink(ctx: &mut Context) -> JsResult { - let target = ObjectInitializer::new(ctx).build(); - let proxy = JsProxy::builder(target).get(sink_get).set(sink_set).build(ctx)?; - Ok(proxy.into()) -} - -fn sink_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::undefined()); - } - match text(&key, ctx)?.as_str() { - "value" | "toString" | "valueOf" => Ok(s("")), - _ => sink(ctx), - } -} - -fn sink_set(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(true)) -} - -fn node_has(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(true)) -} - -fn value_js(h: &HNode) -> JsValue { - if h.value.is_empty() { - return JsValue::null(); - } - if h.numeric - && let Some(n) = as_number(&h.value) - { - return JsValue::from(n); - } - s(&h.value) -} - -/// `(idx, name)` of the child of `parent` named `name`, first instance. -pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option { - let p = h.nodes.get(parent)?; - p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) -} - -/// Every live instance named `name` under `parent`, in order. -pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec { - h.nodes - .get(parent) - .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) - .unwrap_or_default() -} - -fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { - let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); - let arr = JsArray::from_iter(vals, ctx); - let arr_obj: JsObject = arr.into(); - fn item(_: &JsValue, args: &[JsValue], arr: &JsObject, ctx: &mut Context) -> JsResult { - let i = arg(args, 0).to_number(ctx)?; - if !i.is_finite() || i < 0.0 { - return Ok(JsValue::null()); - } - let v = JsArray::from_object(arr.clone())?.get(i as u64, ctx)?; - if v.is_undefined() { - return Ok(JsValue::null()); - } - let idx = v.to_number(ctx)? as usize; - node_object(ctx, idx) - } - let item_fn = function(ctx, NativeFunction::from_copy_closure_with_captures(item, arr_obj)); - Ok(ObjectInitializer::new(ctx) - .property(js_string!("length"), JsValue::from(items.len() as f64), Attribute::READONLY) - .property(js_string!("item"), item_fn, Attribute::all()) - .build() - .into()) -} - -/// The instance manager of the subform named `name` under `parent`. -fn instance_manager(ctx: &mut Context, parent: usize, name: &str) -> JsResult { - let (count, min, max) = { - let h = host(ctx)?.borrow(); - let live = instances(&h, parent, name); - let first = live.first().and_then(|i| h.nodes.get(*i)); - (live.len(), first.map_or(0, |n| n.occur_min), first.and_then(|n| n.occur_max)) - }; - let key = ObjectInitializer::new(ctx) - .property(js_string!("parent"), JsValue::from(parent as f64), Attribute::READONLY) - .property(js_string!("name"), s(name), Attribute::READONLY) - .build(); - fn target_of(key: &JsObject, ctx: &mut Context) -> JsResult<(usize, String)> { - let p = key.get(js_string!("parent"), ctx)?.to_number(ctx)? as usize; - let n = text(&key.get(js_string!("name"), ctx)?, ctx)?; - Ok((p, n)) - } - fn add_instance(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - match add_instance_of(ctx, p, &n)? { - Some(i) => node_object(ctx, i), - None => Ok(JsValue::null()), - } - } - fn insert_instance(this: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - add_instance(this, args, key, ctx) - } - fn remove_instance(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let i = arg(args, 0).to_number(ctx)?; - if i.is_finite() && i >= 0.0 { - remove_instance_of(ctx, p, &n, i as usize)?; - } - Ok(JsValue::undefined()) - } - fn set_instances(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let want = arg(args, 0).to_number(ctx)?; - if !want.is_finite() || want < 0.0 { - return Ok(JsValue::undefined()); - } - host(ctx)?.borrow_mut().set_instances(p, &n, want as usize); - Ok(JsValue::undefined()) - } - fn move_instance(_: &JsValue, _: &[JsValue], _: &JsObject, _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) - } - fn count_get(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let (p, n) = target_of(key, ctx)?; - let c = instances(&host(ctx)?.borrow(), p, &n).len(); - Ok(JsValue::from(c as f64)) - } - fn count_set(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - set_instances(&JsValue::undefined(), args, key, ctx) - } - let count_g = function(ctx, NativeFunction::from_copy_closure_with_captures(count_get, key.clone())); - let count_s = function(ctx, NativeFunction::from_copy_closure_with_captures(count_set, key.clone())); - let add = function(ctx, NativeFunction::from_copy_closure_with_captures(add_instance, key.clone())); - let insert = function(ctx, NativeFunction::from_copy_closure_with_captures(insert_instance, key.clone())); - let remove = function(ctx, NativeFunction::from_copy_closure_with_captures(remove_instance, key.clone())); - let set = function(ctx, NativeFunction::from_copy_closure_with_captures(set_instances, key.clone())); - let mv = function(ctx, NativeFunction::from_copy_closure_with_captures(move_instance, key)); - let _ = count; - let o = ObjectInitializer::new(ctx) - .property(js_string!("min"), JsValue::from(min as f64), Attribute::READONLY) - .property(js_string!("max"), max.map_or(JsValue::from(-1.0), |m| JsValue::from(m as f64)), Attribute::READONLY) - .property(js_string!("addInstance"), add, Attribute::all()) - .property(js_string!("insertInstance"), insert, Attribute::all()) - .property(js_string!("removeInstance"), remove, Attribute::all()) - .property(js_string!("setInstances"), set, Attribute::all()) - .property(js_string!("moveInstance"), mv, Attribute::all()) - .property(js_string!("className"), s("instanceManager"), Attribute::READONLY) - .build(); - use boa_engine::property::PropertyDescriptor; - o.define_property_or_throw(js_string!("count"), PropertyDescriptor::builder().get(count_g).set(count_s).configurable(true), ctx)?; - Ok(o.into()) -} - -/// Re-derive the SOM of `idx` and everything under it from its parent and index. -fn renumber(h: &mut XHost, idx: usize, depth: usize) { - if depth > MAX_DEPTH { - return; - } - let (parent_som, name, index, kids) = { - let Some(n) = h.nodes.get(idx) else { return }; - let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); - (parent_som, n.name.clone(), n.index, n.children.clone()) - }; - let som = if name.starts_with('#') || name.is_empty() { - parent_som - } else if parent_som.is_empty() { - format!("{name}[{index}]") - } else { - format!("{parent_som}.{name}[{index}]") - }; - if let Some(n) = h.nodes.get_mut(idx) { - n.som = som; - } - for k in kids { - renumber(h, k, depth + 1); - } -} - -/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index. -fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) -> Option { - if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES { - return None; - } - let (template, kids) = { - let n = h.nodes.get(src)?; - ( - HNode { - name: n.name.clone(), - som: String::new(), - kind: n.kind, - value: String::new(), - numeric: n.numeric, - presence: n.presence.clone(), - access: n.access.clone(), - repeatable: n.repeatable, - occur_min: n.occur_min, - occur_max: n.occur_max, - index: n.index, - parent, - children: Vec::new(), - gone: false, - }, - n.children.clone(), - ) - }; - let idx = h.nodes.len(); - h.nodes.push(template); - let mut copied = Vec::new(); - for k in kids { - // Nested repeating subforms copy their first instance only. - let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0); - if skip { - continue; - } - if let Some(ci) = clone_subtree(h, k, Some(idx), depth + 1) { - copied.push(ci); - } - } - if let Some(n) = h.nodes.get_mut(idx) { - n.children = copied; - } - Some(idx) -} - -fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult> { - let mut h = host(ctx)?.borrow_mut(); - Ok(h.add_instance(parent, name)) -} - -fn remove_instance_of(ctx: &mut Context, parent: usize, name: &str, i: usize) -> JsResult<()> { - let mut h = host(ctx)?.borrow_mut(); - h.remove_instance(parent, name, i); - Ok(()) -} - -impl XHost { - /// A new instance of the subform `name` under `parent`, after the last one; `None` when the - /// subform doesn't repeat, is at its maximum, or the run added its limit. - pub(crate) fn add_instance(&mut self, parent: usize, name: &str) -> Option { - let h = self; - let live = instances(h, parent, name); - let &first = live.first()?; - let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); - if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED { - return None; - } - let new = clone_subtree(h, first, Some(parent), 0)?; - let last = live.last().copied().unwrap_or(first); - if let Some(n) = h.nodes.get_mut(new) { - n.index = live.len(); - } - // Keep instances together: right after the last one. - if let Some(p) = h.nodes.get_mut(parent) { - let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); - p.children.insert(pos, new); - } - renumber(h, new, 0); - h.added += 1; - let som = h.nodes.get(new).map(|n| n.som.clone()).unwrap_or_default(); - h.push(XfaEffect::AddInstance { som }); - Some(new) - } - - /// Add or remove instances of the subform `name` under `parent` until there are `want`. - /// Bounded: adding stops at the cap or the maximum, removing at the floor, and a step that - /// changes nothing ends the loop. - pub(crate) fn set_instances(&mut self, parent: usize, name: &str, want: usize) { - let want = want.min(MAX_ADDED); - for _ in 0..=MAX_ADDED { - let have = instances(self, parent, name).len(); - if have < want { - if self.add_instance(parent, name).is_none() { - break; - } - } else if have > want && have > 0 { - self.remove_instance(parent, name, have - 1); - if instances(self, parent, name).len() == have { - break; - } - } else { - break; - } - } - } - - /// Remove instance `i` of the subform `name` under `parent` (never below the minimum, or - /// below one). - pub(crate) fn remove_instance(&mut self, parent: usize, name: &str, i: usize) { - let h = self; - let live = instances(h, parent, name); - let Some(&gone) = live.get(i) else { return }; - let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); - if live.len() <= min { - return; - } - let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); - if let Some(n) = h.nodes.get_mut(gone) { - n.gone = true; - } - // Later instances move down one. - for (k, &inst) in live.iter().enumerate().skip(i + 1) { - if let Some(n) = h.nodes.get_mut(inst) { - n.index = k - 1; - } - renumber(h, inst, 0); - } - h.push(XfaEffect::RemoveInstance { som }); - } -} - -/// Resolve a SOM expression from node `from`: `$`, `$form`, `$record`, `$host`, `xfa.form…`, -/// `parent`, `..name` (descendant search), `name[n]`, `name[*]`, `_name`. -pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { - let expr = expr.trim(); - if expr.is_empty() { - return vec![from]; - } - let root = 0usize; - let mut cur: Vec = vec![from]; - let mut rest = expr; - let starts = - [("$form", root), ("xfa.form", root), ("$record", root), ("xfa.datasets.data", root), ("xfa.record", root), ("$", from), ("this", from)]; - for (prefix, at) in starts { - if let Some(r) = rest.strip_prefix(prefix) - && (r.is_empty() || r.starts_with('.') || r.starts_with('[')) - { - cur = vec![at]; - rest = r; - break; - } - } - let mut depth = 0; - while !rest.is_empty() && depth < MAX_DEPTH { - depth += 1; - let descend = rest.starts_with(".."); - rest = rest.trim_start_matches('.'); - let end = rest.find(['.', '[']).unwrap_or(rest.len()); - let (name, after) = rest.split_at(end); - let name = name.trim(); - let (index, after) = match after.strip_prefix('[') { - Some(a) => { - let close = a.find(']').unwrap_or(a.len()); - let idx = a.get(..close).map(str::trim).unwrap_or(""); - (Some(idx.to_string()), a.get(close + 1..).unwrap_or("")) - } - None => (None, after), - }; - rest = after; - let mut next = Vec::new(); - for &c in &cur { - match name { - "" => {} - "parent" => { - if let Some(p) = h.nodes.get(c).and_then(|n| n.parent) { - next.push(p); - } - } - _ => { - let mut found: Vec = if descend { descendants_named(h, c, name, 0) } else { instances(h, c, name) }; - // A relative name a node has no child for is looked for among its siblings - // and in each enclosing container, nearest first (so a field's script can - // say `resolveNode("otherField")`). - if found.is_empty() && depth == 1 && !descend { - let mut at = h.nodes.get(c).and_then(|n| n.parent); - let mut guard = 0; - while let Some(p) = at - && guard < MAX_DEPTH - { - found = instances(h, p, name); - if !found.is_empty() { - break; - } - at = h.nodes.get(p).and_then(|n| n.parent); - guard += 1; - } - } - match index.as_deref() { - Some("*") => next.extend(found), - Some(i) => { - if let Some(k) = i.parse::().ok().and_then(|i| found.get(i)) { - next.push(*k); - } - } - None => next.extend(found.first()), - } - } - } - } - // Each node once: `row[*].parent.row[*]` would otherwise multiply the list by the - // number of rows at every step. - let mut seen = std::collections::HashSet::with_capacity(next.len()); - next.retain(|i| seen.insert(*i)); - cur = next; - if cur.is_empty() { - break; - } - } - cur -} - -pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { - let mut out = Vec::new(); - if depth > MAX_DEPTH { - return out; - } - let Some(n) = h.nodes.get(from) else { return out }; - for &c in &n.children { - if let Some(k) = h.nodes.get(c) - && !k.gone - { - if k.name == name { - out.push(c); - } else { - out.extend(descendants_named(h, c, name, depth + 1)); - } - } - } - out -} - -fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(&arg(args, 0), ctx)?; - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::undefined()); - } - let key = text(&key, ctx)?; - let snapshot = { - let h = host(ctx)?.borrow(); - let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::undefined()) }; - (n.name.clone(), n.som.clone(), n.kind, n.value.clone(), n.presence.clone(), n.access.clone(), n.index, n.parent, n.children.clone()) - }; - let (name, som, kind, value, presence, access, index, parent, children) = snapshot; - Ok(match key.as_str() { - IDX => JsValue::from(idx as f64), - "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), - "formattedValue" => s(&value), - "name" => s(&name), - "somExpression" => s(&som), - "className" => s(kind.class_name()), - "index" | "instanceIndex" => JsValue::from(index as f64), - "presence" => s(&presence), - "access" => s(&access), - "isNull" => JsValue::from(value.is_empty()), - "mandatory" => s("disabled"), - "parent" => match parent { - Some(p) => node_object(ctx, p)?, - None => JsValue::null(), - }, - "nodes" => { - let live: Vec = { - let h = host(ctx)?.borrow(); - children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone)).collect() - }; - list_object(ctx, &live)? - } - "all" => { - let live = match parent { - Some(p) => instances(&host(ctx)?.borrow(), p, &name), - None => vec![idx], - }; - list_object(ctx, &live)? - } - "instanceManager" => match parent { - Some(p) => instance_manager(ctx, p, &name)?, - None => JsValue::undefined(), - }, - "resolveNode" => resolver(ctx, idx, false)?, - "resolveNodes" => resolver(ctx, idx, true)?, - "recalculate" => function(ctx, NativeFunction::from_fn_ptr(recalculate)).into(), - "remerge" => function(ctx, NativeFunction::from_fn_ptr(relayout)).into(), - "getAttribute" - | "setAttribute" - | "execEvent" - | "execInitialize" - | "execCalculate" - | "execValidate" - | "getElement" - | "setElement" - | "isPropertySpecified" - | "loadXML" - | "saveXML" - | "clone" => function(ctx, NativeFunction::from_fn_ptr(noop)).into(), - "toString" | "valueOf" | "toJSON" => function(ctx, NativeFunction::from_fn_ptr(node_to_string)).into(), - "length" => JsValue::undefined(), - "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" | "format" - | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" | "oneOfChild" | "locale" - | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" | "calculate" | "traversal" | "extras" - | "desc" => sink(ctx)?, - other => { - if let Some(child) = other.strip_prefix('_') { - let exists = child_named(&host(ctx)?.borrow(), idx, child).is_some(); - return if exists { instance_manager(ctx, idx, child) } else { Ok(JsValue::undefined()) }; - } - let found = child_named(&host(ctx)?.borrow(), idx, other); - match found { - Some(c) => node_object(ctx, c)?, - None => JsValue::undefined(), - } - } - }) -} - -fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(&arg(args, 0), ctx)?; - let key = arg(args, 1); - if key.as_symbol().is_some() { - return Ok(JsValue::from(true)); - } - let key = text(&key, ctx)?; - let value = arg(args, 2); - let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? }; - let mut h = host(ctx)?.borrow_mut(); - let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) }; - let som = n.som.clone(); - match key.as_str() { - "rawValue" | "value" | "formattedValue" => { - // Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded. - h.set_value(idx, value); - } - "presence" => { - let p = match value.as_str() { - "hidden" | "invisible" | "inactive" | "visible" => value.clone(), - _ => return Ok(JsValue::from(true)), - }; - if let Some(n) = h.nodes.get_mut(idx) { - n.presence = p.clone(); - } - h.push(XfaEffect::SetPresence { som, presence: p }); - } - "access" => { - let a = match value.as_str() { - "open" | "readOnly" | "protected" | "nonInteractive" => value.clone(), - _ => return Ok(JsValue::from(true)), - }; - if let Some(n) = h.nodes.get_mut(idx) { - n.access = a.clone(); - } - h.push(XfaEffect::SetAccess { som, access: a }); - } - _ => {} - } - Ok(JsValue::from(true)) -} - -fn node_to_string(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - let idx = node_index(this, ctx)?; - Ok(s(&host(ctx)?.borrow().nodes.get(idx).map(|n| n.value.clone()).unwrap_or_default())) -} - -fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::undefined()) -} - -/// `resolveNode(som)` / `resolveNodes(som)` bound to node `from`. -fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { - let key = ObjectInitializer::new(ctx) - .property(js_string!("from"), JsValue::from(from as f64), Attribute::READONLY) - .property(js_string!("many"), JsValue::from(many), Attribute::READONLY) - .build(); - fn resolve(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { - let from = key.get(js_string!("from"), ctx)?.to_number(ctx)? as usize; - let many = key.get(js_string!("many"), ctx)?.to_boolean(); - let expr = text(&arg(args, 0), ctx)?; - let found = resolve_som(&host(ctx)?.borrow(), from, &expr); - if many { - return list_object(ctx, &found); - } - match found.first() { - Some(i) => node_object(ctx, *i), - None => Ok(JsValue::null()), - } - } - Ok(function(ctx, NativeFunction::from_copy_closure_with_captures(resolve, key)).into()) -} - -// ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── - -fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { - host(ctx)?.borrow_mut().push(e); - Ok(()) -} - -fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = clip(text(&arg(args, 0), ctx)?); - effect(ctx, XfaEffect::MessageBox(m))?; - Ok(JsValue::from(1.0)) -} - -fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let list = text(&arg(args, 0), ctx)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - effect(ctx, XfaEffect::ResetData(names))?; - Ok(JsValue::undefined()) -} - -fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Print)?; - Ok(JsValue::undefined()) -} - -fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Beep)?; - Ok(JsValue::undefined()) -} - -fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let u = text(&arg(args, 0), ctx)?; - effect(ctx, XfaEffect::LaunchUrl(u))?; - Ok(JsValue::undefined()) -} - -fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let target = arg(args, 0); - let som = match target.as_object() { - Some(_) if node_index(&target, ctx).is_ok() => { - let i = node_index(&target, ctx)?; - host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default() - } - _ => { - let expr = text(&target, ctx)?; - let h = host(ctx)?.borrow(); - let found = resolve_som(&h, h.current, &expr); - found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr) - } - }; - effect(ctx, XfaEffect::SetFocus(som))?; - Ok(JsValue::undefined()) -} - -fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let item = text(&arg(args, 0), ctx)?; - match item.as_str() { - "SaveAs" | "Save" => effect(ctx, XfaEffect::SaveAs)?, - "Print" => effect(ctx, XfaEffect::Print)?, - _ => {} - } - Ok(JsValue::undefined()) -} - -fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Recalculate)?; - Ok(JsValue::undefined()) -} - -fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - effect(ctx, XfaEffect::Relayout)?; - Ok(JsValue::undefined()) -} - -fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { - let m = clip(text(&arg(args, 0), ctx)?); - host(ctx)?.borrow_mut().print(m); - Ok(JsValue::undefined()) -} - -fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::null()) -} - -fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) -} - -fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) -} - -fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { - Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) -} - -fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { - Ok(JsValue::from(0.0)) -} - -fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { - let current = host(ctx)?.borrow().current; - resolver(ctx, current, many) -} - -fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { - let rw = Attribute::CONFIGURABLE; - let form = node_object(ctx, 0)?; - let this_node = node_object(ctx, current)?; - let host_obj = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(message_box), js_string!("messageBox"), 4) - .function(NativeFunction::from_fn_ptr(reset_data), js_string!("resetData"), 1) - .function(NativeFunction::from_fn_ptr(print), js_string!("print"), 8) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) - .function(NativeFunction::from_fn_ptr(goto_url), js_string!("gotoURL"), 2) - .function(NativeFunction::from_fn_ptr(set_focus), js_string!("setFocus"), 1) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 4) - .function(NativeFunction::from_fn_ptr(noop), js_string!("openList"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("exportData"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("importData"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("pageDown"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("pageUp"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("getFocus"), 0) - .property(js_string!("currentPage"), JsValue::from(doc.page as f64), rw) - .property(js_string!("numPages"), JsValue::from(doc.page_count as f64), rw) - .property(js_string!("name"), s("Acrobat"), Attribute::READONLY) - .property(js_string!("appType"), s("Exchange-Pro"), Attribute::READONLY) - .property(js_string!("version"), s("11"), Attribute::READONLY) - .property(js_string!("variation"), s("Full"), Attribute::READONLY) - .property(js_string!("language"), s("en"), Attribute::READONLY) - .property(js_string!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("calculationsEnabled"), JsValue::from(true), rw) - .property(js_string!("validationsEnabled"), JsValue::from(true), rw) - .property(js_string!("title"), s(&doc.file_name), rw) - .build(); - let layout = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(layout_page), js_string!("page"), 1) - .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1) - .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0) - .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0) - .function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("w"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("x"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("y"), 2) - .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) - .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) - .build(); - let ev = ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&event.activity), Attribute::all()) - .property(js_string!("newText"), s(&event.new_text), Attribute::all()) - .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) - .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) - .property(js_string!("change"), s(""), Attribute::all()) - .property(js_string!("target"), this_node.clone(), Attribute::all()) - .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) - .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) - .property(js_string!("shift"), JsValue::from(false), Attribute::all()) - .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) - .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) - .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) - .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) - .build(); - let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); - let template = sink(ctx)?; - let resolve_one = xfa_resolve(ctx, false)?; - let resolve_many = xfa_resolve(ctx, true)?; - let xfa = ObjectInitializer::new(ctx) - .property(js_string!("form"), form.clone(), rw) - .property(js_string!("host"), host_obj, rw) - .property(js_string!("layout"), layout, rw) - .property(js_string!("event"), ev.clone(), rw) - .property(js_string!("datasets"), datasets, rw) - .property(js_string!("record"), form.clone(), rw) - .property(js_string!("template"), template, rw) - .property(js_string!("resolveNode"), resolve_one, rw) - .property(js_string!("resolveNodes"), resolve_many, rw) - .function(NativeFunction::from_fn_ptr(recalculate), js_string!("recalculate"), 1) - .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) - .build(); - ctx.register_global_property(js_string!("xfa"), xfa, rw)?; - ctx.register_global_property(js_string!("event"), ev, rw)?; - ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; - let app = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4) - .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) - .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) - .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) - .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("setTimeOut"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("setInterval"), 2) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) - .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) - .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) - .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) - .property(js_string!("platform"), s(platform()), Attribute::READONLY) - .property(js_string!("language"), s("ENU"), Attribute::READONLY) - .build(); - ctx.register_global_property(js_string!("app"), app, rw)?; - let console = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(console_println), js_string!("println"), 1) - .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("clear"), 0) - .build(); - ctx.register_global_property(js_string!("console"), console, rw)?; - let util = ObjectInitializer::new(ctx) - .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) - .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) - .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) - .build(); - ctx.register_global_property(js_string!("util"), util, rw)?; - // Unqualified names: the current node's children, its siblings, then each ancestor's - // children, the nearest first. Instance managers as `_name`. - let scopes: Vec = { - let h = host(ctx)?.borrow(); - let mut out = vec![current]; - let mut at = h.nodes.get(current).and_then(|n| n.parent); - let mut guard = 0; - while let Some(p) = at - && guard < MAX_DEPTH - { - out.push(p); - at = h.nodes.get(p).and_then(|n| n.parent); - guard += 1; - } - out - }; - // Names that stay what JavaScript (or this host) means by them: a field called `Date` - // is reached as `this.parent.Date` or by resolveNode. - const RESERVED: &[&str] = &[ - "xfa", - "event", - "app", - "console", - "util", - "this", - "__xfa_this", - "__xfa_src", - "eval", - "arguments", - "globalThis", - "window", - "undefined", - "NaN", - "Infinity", - "Object", - "Function", - "Array", - "Number", - "String", - "Boolean", - "Symbol", - "Date", - "RegExp", - "Error", - "TypeError", - "RangeError", - "SyntaxError", - "Math", - "JSON", - "Map", - "Set", - "WeakMap", - "WeakSet", - "Promise", - "Proxy", - "Reflect", - "parseInt", - "parseFloat", - "isNaN", - "isFinite", - "encodeURIComponent", - "decodeURIComponent", - "encodeURI", - "decodeURI", - "escape", - "unescape", - "BigInt", - "ArrayBuffer", - "DataView", - "Intl", - ]; - let mut defined: Vec = RESERVED.iter().map(|s| s.to_string()).collect(); - for scope in scopes { - let kids: Vec<(usize, String, bool)> = { - let h = host(ctx)?.borrow(); - h.nodes - .get(scope) - .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect()) - .unwrap_or_default() - }; - for (c, name, repeatable) in kids { - if !is_identifier(&name) || defined.contains(&name) { - continue; - } - let obj = node_object(ctx, c)?; - ctx.register_global_property(JsString::from(name.as_str()), obj, rw)?; - if repeatable { - let im = instance_manager(ctx, scope, &name)?; - ctx.register_global_property(JsString::from(format!("_{name}").as_str()), im, rw)?; - } - defined.push(name); - } - } - Ok(()) -} - -fn is_identifier(name: &str) -> bool { - let mut chars = name.chars(); - match chars.next() { - Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} - _ => return false, - } - chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') - && !matches!( - name, - "var" - | "let" - | "const" - | "function" - | "return" - | "if" - | "else" - | "for" - | "while" - | "new" - | "null" - | "true" - | "false" - | "undefined" - | "typeof" - ) -} - -/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. -pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { - let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; - if let Some(why) = refuse(script) { - return failed(why); - } - #[cfg(not(target_arch = "wasm32"))] - { - std::thread::scope(|scope| { - let spawned = std::thread::Builder::new() - .name("pdfcraft-xfa-js".into()) - .stack_size(crate::SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {})); - match spawned { - Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), - Err(e) => failed(format!("the script engine could not start: {e}")), - } - }) - } - #[cfg(target_arch = "wasm32")] - run_here(script, event, doc, root.clone(), limits, &|| {}) -} - -/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready -/// (loops inside nested function calls can run for hours within the engine's per-frame loop -/// limit) is left on its thread, which ends when the engine's own limits stop it, and an -/// outcome with `abandoned` set comes back at once. In the browser build there are no -/// threads: the script runs to its limits. -pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { - if let Some(why) = refuse(script) { - return XfaOutcome { error: Some(why), ..Default::default() }; - } - let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); - run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) -} - -/// Longest the script engine may take to be ready for a script (its thread and context, and -/// for the first script in a process the engine's own set-up) before the script is abandoned. -/// It doesn't count against the script's time: on a busy machine the start alone can take -/// longer than the tightest limit, and abandoning a script turns the form's scripts off. -pub(crate) const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); - -/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its -/// outcome, counted from when it calls `started` (once the engine is ready); past that the -/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs -/// here. -pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome { - run_within_limits(name, ENGINE_START_LIMIT, timeout, script) -} - -/// [`run_within`] with the engine's start-up limit given. -pub(crate) fn run_within_limits( - name: &str, - start_limit: std::time::Duration, - timeout: std::time::Duration, - script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static, -) -> XfaOutcome { - #[cfg(not(target_arch = "wasm32"))] - { - use std::sync::mpsc::RecvTimeoutError; - use std::time::Instant; - enum Step { - Started, - Done(XfaOutcome), - } - let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; - let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; - let (tx, rx) = std::sync::mpsc::channel(); - let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { - // The receiver is gone when the caller stopped waiting: nothing to report then. - let started = tx.clone(); - let outcome = script(&move || { - let _ = started.send(Step::Started); - }); - let _ = tx.send(Step::Done(outcome)); - }); - if let Err(e) = spawned { - return failed(format!("the script engine could not start: {e}")); - } - // Wait for the engine within its start-up limit, then for the script within its own. - let (mut running, mut limit) = (false, start_limit); - let mut deadline = Instant::now().checked_add(limit); - loop { - let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); - match rx.recv_timeout(left) { - Ok(Step::Done(o)) => return o, - Ok(Step::Started) => { - // A second call changes nothing: the script's time has already begun. - if !running { - (running, limit) = (true, timeout); - deadline = Instant::now().checked_add(limit); - } - } - Err(RecvTimeoutError::Timeout) if running => { - return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())); - } - Err(RecvTimeoutError::Timeout) => { - return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())); - } - Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()), - } - } - } - #[cfg(target_arch = "wasm32")] - { - let _ = (name, start_limit, timeout); - script(&|| {}) - } -} - -/// The first script in a process sets the engine up (its built-ins, parser and compiler come -/// into use): a trivial script takes that on, so a form's script is never timed with it. -pub(crate) fn warm_up() { - static WARM: std::sync::Once = std::sync::Once::new(); - // `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again. - WARM.call_once_force(|_| { - let mut ctx = Context::default(); - let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})")); - }); -} - -/// `started` is called once the engine is ready: a time limit on the script runs from then. -pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { - warm_up(); - let mut ctx = Context::default(); - ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); - ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); - let host_state = XHost::new(root, &event.target, doc); - let current = host_state.current; - ctx.insert_data(RefCell::new(host_state)); - let mut out = XfaOutcome::default(); - let result = (|| -> JsResult<()> { - install(&mut ctx, event, doc, current)?; - // Ready for the script: its time starts now, the set-up above not charged to it. - started(); - ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; - // Direct eval inside a function called on the node: `this` is the node and the - // completion value is the script's last expression (what a calculate script yields). - let r = ctx.eval(Source::from_bytes(b"(function () { return eval(__xfa_src); }).call(__xfa_this)"))?; - if !r.is_undefined() { - // Best effort: a value that can't be made text (a symbol, say) is no failure. - out.result = Some(text(&r, &mut ctx).unwrap_or_default()); - if r.is_boolean() { - out.result_bool = Some(r.to_boolean()); - } else if r.is_null() { - out.result = Some(String::new()); - } - } - Ok(()) - })(); - if let Err(e) = result { - out.error = Some(e.to_string()); - } - if let Some(h) = ctx.remove_data::() { - let h = h.into_inner(); - out.notes = h.notes(); - out.effects = h.effects; - out.console = h.console; - } - clip_result(&mut out); - out -} +use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; + +mod globals; +mod host; +mod instances; +mod model; +mod nodes; +mod run; + +pub(crate) use host::{HNode, XHost}; +pub use model::{MAX_ALERTS, MAX_CONSOLE, MAX_EFFECTS, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome}; +pub(crate) use model::{MAX_LINE, MAX_PAYLOAD, clip, clip_result, reset_names}; +pub(crate) use nodes::{child_named, descendants_named, instances, resolve_som}; +#[cfg(test)] +pub(crate) use run::run_within_limits; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use run::{ENGINE_START_LIMIT, warm_up}; +pub(crate) use run::{run_here, run_within}; +pub use run::{run_xfa, run_xfa_within}; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs new file mode 100644 index 0000000000..971cf12fc1 --- /dev/null +++ b/crates/js/src/xfa/globals.rs @@ -0,0 +1,376 @@ +use super::host::host; +use super::instances::instance_manager; +use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names}; +use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; +use super::{ + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, js_string, platform, printd, printf, printx, s, scand, + text, +}; + +// ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── + +/// Records effect `e` on the host, against the run's limits, as [`XHost::push`] does. +fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { + host(ctx)?.borrow_mut().push(e); + Ok(()) +} + +/// `xfa.host.messageBox(message, title, icon, buttons)`: records the message with its title, +/// icon and buttons, its text and title cut to [`MAX_LINE`] characters, and returns 1. +fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = crate::globals::message_box_message(args, ctx)?; + effect(ctx, XfaEffect::MessageBox(m))?; + // Always OK for now: the viewer can't ask the user and run the script on with the answer + // yet (#4291). + Ok(JsValue::from(1.0)) +} + +/// `app.alert(cMsg, nIcon, nType, cTitle)` in an XFA form's script: the same message box, its +/// arguments in `app.alert`'s order (or one object with those keys). +fn app_alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = crate::globals::alert_message(args, ctx)?; + effect(ctx, XfaEffect::MessageBox(m))?; + // The OK button, as for `messageBox` (#4291). + Ok(JsValue::from(1.0)) +} + +/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when +/// the list is empty. +fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let list = text(&arg(args, 0), ctx)?; + effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.print(…)`: asks to print; its arguments are ignored. +fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Print)?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.beep()`: asks to beep. +fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Beep)?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.gotoURL(url)`: asks to open the URL. +fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let u = text(&arg(args, 0), ctx)?; + effect(ctx, XfaEffect::LaunchUrl(u))?; + Ok(JsValue::undefined()) +} + +/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM +/// expression resolved from the current node; an expression that names no node is passed on +/// as written. +fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let target = arg(args, 0); + let som = match target.as_object() { + Some(_) if node_index(&target, ctx).is_ok() => { + let i = node_index(&target, ctx)?; + host(ctx)?.borrow().nodes.get(i).map(|n| n.som.clone()).unwrap_or_default() + } + _ => { + let expr = text(&target, ctx)?; + let h = host(ctx)?.borrow(); + let found = resolve_som(&h, h.current, &expr); + found.first().and_then(|i| h.nodes.get(*i)).map(|n| n.som.clone()).unwrap_or(expr) + } + }; + effect(ctx, XfaEffect::SetFocus(som))?; + Ok(JsValue::undefined()) +} + +/// `app.execMenuItem(name)`: the effect for the request the shared menu table gives the name +/// (Save opens Save As too); an unknown name does nothing. +fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let item = text(&arg(args, 0), ctx)?; + match crate::menu_item(&item) { + Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?, + Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?, + Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?, + _ => {} + } + Ok(JsValue::undefined()) +} + +pub(super) fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Recalculate)?; + Ok(JsValue::undefined()) +} + +pub(super) fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + effect(ctx, XfaEffect::Relayout)?; + Ok(JsValue::undefined()) +} + +fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let m = clip(text(&arg(args, 0), ctx)?); + host(ctx)?.borrow_mut().print(m); + Ok(JsValue::undefined()) +} + +fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::null()) +} + +fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) +} + +fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) +} + +fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) +} + +fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(0.0)) +} + +fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { + let current = host(ctx)?.borrow().current; + resolver(ctx, current, many) +} + +/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, +/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, +/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. +pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { + let rw = Attribute::CONFIGURABLE; + let form = node_object(ctx, 0)?; + let this_node = node_object(ctx, current)?; + let host_obj = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(message_box), js_string!("messageBox"), 4) + .function(NativeFunction::from_fn_ptr(reset_data), js_string!("resetData"), 1) + .function(NativeFunction::from_fn_ptr(print), js_string!("print"), 8) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) + .function(NativeFunction::from_fn_ptr(goto_url), js_string!("gotoURL"), 2) + .function(NativeFunction::from_fn_ptr(set_focus), js_string!("setFocus"), 1) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 4) + .function(NativeFunction::from_fn_ptr(noop), js_string!("openList"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("exportData"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("importData"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("pageDown"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("pageUp"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("getFocus"), 0) + .property(js_string!("currentPage"), JsValue::from(doc.page as f64), rw) + .property(js_string!("numPages"), JsValue::from(doc.page_count as f64), rw) + .property(js_string!("name"), s("Acrobat"), Attribute::READONLY) + .property(js_string!("appType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("version"), s("11"), Attribute::READONLY) + .property(js_string!("variation"), s("Full"), Attribute::READONLY) + .property(js_string!("language"), s("en"), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("calculationsEnabled"), JsValue::from(true), rw) + .property(js_string!("validationsEnabled"), JsValue::from(true), rw) + .property(js_string!("title"), s(&doc.file_name), rw) + .build(); + let layout = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(layout_page), js_string!("page"), 1) + .function(NativeFunction::from_fn_ptr(layout_page0), js_string!("absPage"), 1) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("pageCount"), 0) + .function(NativeFunction::from_fn_ptr(layout_page_count), js_string!("absPageCount"), 0) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayout"), 0) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("relayoutPageArea"), 0) + .function(NativeFunction::from_fn_ptr(zero), js_string!("h"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("w"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("x"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("y"), 2) + .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) + .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) + .build(); + let ev = ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&event.activity), Attribute::all()) + .property(js_string!("newText"), s(&event.new_text), Attribute::all()) + .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) + .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) + .property(js_string!("change"), s(""), Attribute::all()) + .property(js_string!("target"), this_node.clone(), Attribute::all()) + .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) + .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) + .property(js_string!("shift"), JsValue::from(false), Attribute::all()) + .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) + .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) + .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) + .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) + .build(); + let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); + let template = sink(ctx)?; + let resolve_one = xfa_resolve(ctx, false)?; + let resolve_many = xfa_resolve(ctx, true)?; + let xfa = ObjectInitializer::new(ctx) + .property(js_string!("form"), form.clone(), rw) + .property(js_string!("host"), host_obj, rw) + .property(js_string!("layout"), layout, rw) + .property(js_string!("event"), ev.clone(), rw) + .property(js_string!("datasets"), datasets, rw) + .property(js_string!("record"), form.clone(), rw) + .property(js_string!("template"), template, rw) + .property(js_string!("resolveNode"), resolve_one, rw) + .property(js_string!("resolveNodes"), resolve_many, rw) + .function(NativeFunction::from_fn_ptr(recalculate), js_string!("recalculate"), 1) + .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) + .build(); + ctx.register_global_property(js_string!("xfa"), xfa, rw)?; + ctx.register_global_property(js_string!("event"), ev, rw)?; + ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; + let app = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(app_alert), js_string!("alert"), 4) + .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1) + .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) + .function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("setTimeOut"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("setInterval"), 2) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clearInterval"), 1) + .property(js_string!("viewerType"), s("Exchange-Pro"), Attribute::READONLY) + .property(js_string!("viewerVariation"), s("Full"), Attribute::READONLY) + .property(js_string!("viewerVersion"), JsValue::from(24.0), Attribute::READONLY) + .property(js_string!("platform"), s(platform()), Attribute::READONLY) + .property(js_string!("language"), s("ENU"), Attribute::READONLY) + .build(); + ctx.register_global_property(js_string!("app"), app, rw)?; + let console = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(console_println), js_string!("println"), 1) + .function(NativeFunction::from_fn_ptr(noop), js_string!("show"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("hide"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("clear"), 0) + .build(); + ctx.register_global_property(js_string!("console"), console, rw)?; + let util = ObjectInitializer::new(ctx) + .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) + .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) + .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) + .build(); + ctx.register_global_property(js_string!("util"), util, rw)?; + // Unqualified names: the current node's children, its siblings, then each ancestor's + // children, the nearest first. Instance managers as `_name`. + let scopes: Vec = { + let h = host(ctx)?.borrow(); + let mut out = vec![current]; + let mut at = h.nodes.get(current).and_then(|n| n.parent); + let mut guard = 0; + while let Some(p) = at + && guard < MAX_DEPTH + { + out.push(p); + at = h.nodes.get(p).and_then(|n| n.parent); + guard += 1; + } + out + }; + // Names that stay what JavaScript (or this host) means by them: a field called `Date` + // is reached as `this.parent.Date` or by resolveNode. + const RESERVED: &[&str] = &[ + "xfa", + "event", + "app", + "console", + "util", + "this", + "__xfa_this", + "__xfa_src", + "eval", + "arguments", + "globalThis", + "window", + "undefined", + "NaN", + "Infinity", + "Object", + "Function", + "Array", + "Number", + "String", + "Boolean", + "Symbol", + "Date", + "RegExp", + "Error", + "TypeError", + "RangeError", + "SyntaxError", + "Math", + "JSON", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Promise", + "Proxy", + "Reflect", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURIComponent", + "decodeURIComponent", + "encodeURI", + "decodeURI", + "escape", + "unescape", + "BigInt", + "ArrayBuffer", + "DataView", + "Intl", + ]; + let mut defined: Vec = RESERVED.iter().map(ToString::to_string).collect(); + for scope in scopes { + let kids: Vec<(usize, String, bool)> = { + let h = host(ctx)?.borrow(); + h.nodes + .get(scope) + .map(|n| n.children.iter().filter_map(|&c| h.nodes.get(c).map(|k| (c, k.name.clone(), k.repeatable && !k.gone))).collect()) + .unwrap_or_default() + }; + for (c, name, repeatable) in kids { + if !is_identifier(&name) || defined.contains(&name) { + continue; + } + let obj = node_object(ctx, c)?; + ctx.register_global_property(JsString::from(name.as_str()), obj, rw)?; + if repeatable { + let im = instance_manager(ctx, scope, &name)?; + ctx.register_global_property(JsString::from(format!("_{name}").as_str()), im, rw)?; + } + defined.push(name); + } + } + Ok(()) +} + +fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') + && !matches!( + name, + "var" + | "let" + | "const" + | "function" + | "return" + | "if" + | "else" + | "for" + | "while" + | "new" + | "null" + | "true" + | "false" + | "undefined" + | "typeof" + ) +} diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs new file mode 100644 index 0000000000..ec222a4ca8 --- /dev/null +++ b/crates/js/src/xfa/host.rs @@ -0,0 +1,286 @@ +use super::model::{ + MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value, + effect_key, payload, +}; +use super::{Context, HashMap, JsResult, Message, RefCell, error}; + +/// One node of the flattened form, shared by the JavaScript and FormCalc engines. +pub(crate) struct HNode { + pub(crate) name: String, + pub(crate) som: String, + pub(crate) kind: XfaKind, + pub(crate) value: String, + pub(crate) numeric: bool, + pub(crate) presence: String, + pub(crate) access: String, + pub(crate) repeatable: bool, + pub(crate) occur_min: usize, + pub(crate) occur_max: Option, + pub(crate) index: usize, + pub(crate) parent: Option, + pub(crate) children: Vec, + /// Removed by `removeInstance`: skipped everywhere. + pub(crate) gone: bool, + /// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows. + pub(crate) formatted: Option, +} + +/// The form as a script sees and changes it, with the effects it records. +pub(crate) struct XHost { + pub(crate) nodes: Vec, + pub(crate) effects: Vec, + pub(crate) console: Vec, + pub(crate) doc: XfaDoc, + pub(super) added: usize, + /// Index of `this`. + pub(crate) current: usize, + /// Where the effect setting a property of an object is in `effects`, by (property, SOM): + /// setting it again replaces that effect (the last value wins) unless something that + /// depends on order (rows added or removed, a reset) came after it. + set_at: HashMap<(u8, String), usize>, + /// Effects before this index are behind an order-dependent effect. + barrier: usize, + alerts: usize, + dropped_effects: usize, + clipped_values: usize, + dropped_alerts: usize, + dropped_console: usize, + /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. + kept: usize, + dropped_bytes: usize, +} + +impl XHost { + /// The host for a script on `root` (the template's root subform), with `this` at the node + /// named by `target`. `xfa.form` is a synthetic root whose child is the root subform. + pub(crate) fn new(root: XfaNode, target: &str, doc: &XfaDoc) -> XHost { + let form = XfaNode { name: "form".into(), kind: Some(XfaKind::Form), children: vec![root], ..Default::default() }; + let mut nodes = Vec::new(); + flatten(&form, None, &mut nodes, 0); + let current = nodes.iter().position(|n| n.som == target && n.kind != XfaKind::Form).unwrap_or(1.min(nodes.len().saturating_sub(1))); + XHost { + nodes, + effects: Vec::new(), + console: Vec::new(), + doc: doc.clone(), + added: 0, + current, + set_at: HashMap::new(), + barrier: 0, + alerts: 0, + dropped_effects: 0, + clipped_values: 0, + dropped_alerts: 0, + dropped_console: 0, + kept: 0, + dropped_bytes: 0, + } + } + + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped_bytes = self.dropped_bytes.saturating_add(1); + false + } + } + } + + /// Record an effect: values, presence and access set again on the same object replace the + /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] + /// bytes of text) effects are counted and dropped. + pub(crate) fn push(&mut self, e: XfaEffect) { + let e = self.clip_effect(e); + if self.room(payload(&e)) { + self.record(e); + } + } + + /// Room for `e`, which holds `bytes` of text, charged now: a change to the snapshot is made + /// only when the effect that applies it will be kept, so a script never sees a change its + /// effects leave out. An effect that replaces an earlier one needs no new slot. A drop is + /// counted when there is no room. + pub(super) fn reserve(&mut self, e: &XfaEffect, bytes: usize) -> bool { + let replaces = effect_key(e).and_then(|k| self.set_at.get(&k)).is_some_and(|&at| at >= self.barrier && at < self.effects.len()); + if !replaces && self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return false; + } + self.room(bytes) + } + + /// Set the presence or access of node `i` to what `e` (a [`XfaEffect::SetPresence`] or + /// [`XfaEffect::SetAccess`]) says, in the snapshot only when the effect is kept. + pub(crate) fn set_state(&mut self, i: usize, e: XfaEffect) { + if !self.reserve(&e, payload(&e)) { + return; + } + if let Some(n) = self.nodes.get_mut(i) { + match &e { + XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(), + XfaEffect::SetAccess { access, .. } => n.access = access.clone(), + _ => {} + } + } + self.record(e); + } + + /// `e` with its text cut to the lengths an effect keeps. + fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect { + match e { + XfaEffect::MessageBox(m) => XfaEffect::MessageBox(Message { text: clip(m.text), title: m.title.map(clip), ..m }), + XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), + XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), + XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), + XfaEffect::SetValue { som, value } => { + let (value, cut) = clip_value(value); + if cut { + self.clipped_values += 1; + } + XfaEffect::SetValue { som, value } + } + other => other, + } + } + + /// Keep `e`, whose bytes are already charged, against the count limits. + pub(super) fn record(&mut self, e: XfaEffect) { + if let Some(key) = effect_key(&e) { + if let Some(&at) = self.set_at.get(&key) + && at >= self.barrier + && let Some(slot) = self.effects.get_mut(at) + { + *slot = e; + return; + } + if self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return; + } + self.set_at.insert(key, self.effects.len()); + self.effects.push(e); + return; + } + if matches!(e, XfaEffect::MessageBox(_)) { + if self.alerts >= MAX_ALERTS { + self.dropped_alerts += 1; + return; + } + self.alerts += 1; + } + if self.effects.len() >= MAX_EFFECTS { + self.dropped_effects += 1; + return; + } + if matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. } | XfaEffect::ResetData(_)) { + self.barrier = self.effects.len() + 1; + } + self.effects.push(e); + } + + pub(crate) fn print(&mut self, line: String) { + if self.console.len() >= MAX_CONSOLE { + self.dropped_console += 1; + } else if self.room(line.len()) { + self.console.push(line); + } + } + + /// What the limits left out. + pub(crate) fn notes(&self) -> Vec { + let mut out = Vec::new(); + if self.dropped_effects > 0 { + out.push(format!("the script made more than {MAX_EFFECTS} changes; {} more were left out", self.dropped_effects)); + } + if self.dropped_alerts > 0 { + out.push(format!("the script showed more than {MAX_ALERTS} messages; {} more were left out", self.dropped_alerts)); + } + if self.dropped_console > 0 { + out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); + } + if self.dropped_bytes > 0 { + out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); + } + if self.clipped_values > 0 { + out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); + } + out + } + + /// Give node `i` the value `text` when it is a field or exclusion group (nothing else + /// holds a value; writing one into a subform would put text where another viewer expects + /// a data group). Returns whether it was. + pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { + let Some(n) = self.nodes.get(i) else { return false }; + if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { + return false; + } + let som = n.som.clone(); + // Cut here, not only in the effect: later reads in the script see what is saved, and + // the snapshot stays bounded however often a long value is written. + let (text, cut) = clip_value(text); + if cut { + self.clipped_values += 1; + } + // The snapshot's copy and the effect's are charged together, before either is kept: + // past the byte budget or the effect cap the write is left out of both. + let effect = XfaEffect::SetValue { som, value: text.clone() }; + if !self.reserve(&effect, text.len().saturating_add(payload(&effect))) { + return true; + } + if let Some(n) = self.nodes.get_mut(i) { + n.value = text; + n.formatted = None; + } + self.record(effect); + true + } +} + +pub(super) type Shared = RefCell; + +/// The form host of the script running in `ctx`. +pub(super) fn host(ctx: &Context) -> JsResult<&Shared> { + ctx.get_data::().ok_or_else(|| error("the XFA script host is not set up")) +} + +/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`] +/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits. +fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, depth: usize) -> Option { + if depth > MAX_DEPTH || out.len() >= MAX_NODES { + return None; + } + let idx = out.len(); + out.push(HNode { + name: n.name.clone(), + som: n.som.clone(), + kind: n.kind.unwrap_or(XfaKind::Subform), + value: n.value.clone(), + numeric: n.numeric, + presence: if n.presence.is_empty() { "visible".into() } else { n.presence.clone() }, + access: if n.access.is_empty() { "open".into() } else { n.access.clone() }, + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + parent, + children: Vec::new(), + gone: false, + formatted: n.formatted.clone(), + }); + let mut kids = Vec::new(); + for c in &n.children { + if let Some(ci) = flatten(c, Some(idx), out, depth + 1) { + kids.push(ci); + } + } + if let Some(h) = out.get_mut(idx) { + h.children = kids; + } + Some(idx) +} diff --git a/crates/js/src/xfa/instances.rs b/crates/js/src/xfa/instances.rs new file mode 100644 index 0000000000..c099c5abe8 --- /dev/null +++ b/crates/js/src/xfa/instances.rs @@ -0,0 +1,279 @@ +use super::host::{HNode, XHost, host}; +use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect}; +use super::nodes::{instances, node_object}; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, s, text}; + +pub(super) fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { + let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); + let arr = JsArray::from_iter(vals, ctx); + let arr_obj: JsObject = arr.into(); + fn item(_: &JsValue, args: &[JsValue], arr: &JsObject, ctx: &mut Context) -> JsResult { + let i = arg(args, 0).to_number(ctx)?; + if !i.is_finite() || i < 0.0 { + return Ok(JsValue::null()); + } + let v = JsArray::from_object(arr.clone())?.get(i as u64, ctx)?; + if v.is_undefined() { + return Ok(JsValue::null()); + } + let idx = v.to_number(ctx)? as usize; + node_object(ctx, idx) + } + let item_fn = function(ctx, NativeFunction::from_copy_closure_with_captures(item, arr_obj)); + Ok(ObjectInitializer::new(ctx) + .property(js_string!("length"), JsValue::from(items.len() as f64), Attribute::READONLY) + .property(js_string!("item"), item_fn, Attribute::all()) + .build() + .into()) +} + +/// The instance manager of the subform named `name` under `parent`. +pub(super) fn instance_manager(ctx: &mut Context, parent: usize, name: &str) -> JsResult { + let (count, min, max) = { + let h = host(ctx)?.borrow(); + let live = instances(&h, parent, name); + let first = live.first().and_then(|i| h.nodes.get(*i)); + (live.len(), first.map_or(0, |n| n.occur_min), first.and_then(|n| n.occur_max)) + }; + let key = ObjectInitializer::new(ctx) + .property(js_string!("parent"), JsValue::from(parent as f64), Attribute::READONLY) + .property(js_string!("name"), s(name), Attribute::READONLY) + .build(); + fn target_of(key: &JsObject, ctx: &mut Context) -> JsResult<(usize, String)> { + let p = key.get(js_string!("parent"), ctx)?.to_number(ctx)? as usize; + let n = text(&key.get(js_string!("name"), ctx)?, ctx)?; + Ok((p, n)) + } + fn add_instance(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + match add_instance_of(ctx, p, &n)? { + Some(i) => node_object(ctx, i), + None => Ok(JsValue::null()), + } + } + fn insert_instance(this: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + add_instance(this, args, key, ctx) + } + fn remove_instance(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let i = arg(args, 0).to_number(ctx)?; + if i.is_finite() && i >= 0.0 { + remove_instance_of(ctx, p, &n, i as usize)?; + } + Ok(JsValue::undefined()) + } + fn set_instances(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let want = arg(args, 0).to_number(ctx)?; + if !want.is_finite() || want < 0.0 { + return Ok(JsValue::undefined()); + } + host(ctx)?.borrow_mut().set_instances(p, &n, want as usize); + Ok(JsValue::undefined()) + } + fn move_instance(_: &JsValue, _: &[JsValue], _: &JsObject, _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) + } + fn count_get(_: &JsValue, _: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let (p, n) = target_of(key, ctx)?; + let c = instances(&host(ctx)?.borrow(), p, &n).len(); + Ok(JsValue::from(c as f64)) + } + fn count_set(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + set_instances(&JsValue::undefined(), args, key, ctx) + } + let count_g = function(ctx, NativeFunction::from_copy_closure_with_captures(count_get, key.clone())); + let count_s = function(ctx, NativeFunction::from_copy_closure_with_captures(count_set, key.clone())); + let add = function(ctx, NativeFunction::from_copy_closure_with_captures(add_instance, key.clone())); + let insert = function(ctx, NativeFunction::from_copy_closure_with_captures(insert_instance, key.clone())); + let remove = function(ctx, NativeFunction::from_copy_closure_with_captures(remove_instance, key.clone())); + let set = function(ctx, NativeFunction::from_copy_closure_with_captures(set_instances, key.clone())); + let mv = function(ctx, NativeFunction::from_copy_closure_with_captures(move_instance, key)); + let _ = count; + let o = ObjectInitializer::new(ctx) + .property(js_string!("min"), JsValue::from(min as f64), Attribute::READONLY) + .property(js_string!("max"), max.map_or(JsValue::from(-1.0), |m| JsValue::from(m as f64)), Attribute::READONLY) + .property(js_string!("addInstance"), add, Attribute::all()) + .property(js_string!("insertInstance"), insert, Attribute::all()) + .property(js_string!("removeInstance"), remove, Attribute::all()) + .property(js_string!("setInstances"), set, Attribute::all()) + .property(js_string!("moveInstance"), mv, Attribute::all()) + .property(js_string!("className"), s("instanceManager"), Attribute::READONLY) + .build(); + use boa_engine::property::PropertyDescriptor; + o.define_property_or_throw(js_string!("count"), PropertyDescriptor::builder().get(count_g).set(count_s).configurable(true), ctx)?; + Ok(o.into()) +} + +/// Re-derive the SOM of `idx` and everything under it from its parent and index. +fn renumber(h: &mut XHost, idx: usize, depth: usize) { + if depth > MAX_DEPTH { + return; + } + let (parent_som, name, index, kids) = { + let Some(n) = h.nodes.get(idx) else { return }; + let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default(); + (parent_som, n.name.clone(), n.index, n.children.clone()) + }; + let som = child_som(parent_som, &name, index); + if let Some(n) = h.nodes.get_mut(idx) { + n.som = som; + } + for k in kids { + renumber(h, k, depth + 1); + } +} + +/// The SOM of instance `index` of the node `name` under a parent at `parent_som`. +fn child_som(parent_som: String, name: &str, index: usize) -> String { + if name.starts_with('#') || name.is_empty() { + parent_som + } else if parent_som.is_empty() { + format!("{name}[{index}]") + } else { + format!("{parent_som}.{name}[{index}]") + } +} + +/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index. +fn clone_subtree(h: &mut XHost, src: usize, parent: Option, depth: usize) -> Option { + if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES { + return None; + } + let (template, kids) = { + let n = h.nodes.get(src)?; + ( + HNode { + name: n.name.clone(), + som: String::new(), + kind: n.kind, + value: String::new(), + numeric: n.numeric, + presence: n.presence.clone(), + access: n.access.clone(), + repeatable: n.repeatable, + occur_min: n.occur_min, + occur_max: n.occur_max, + index: n.index, + parent, + children: Vec::new(), + gone: false, + // The copy holds no value, so nothing is shown in its picture either. + formatted: None, + }, + n.children.clone(), + ) + }; + let idx = h.nodes.len(); + h.nodes.push(template); + let mut copied = Vec::new(); + for k in kids { + // Nested repeating subforms copy their first instance only. + let skip = h.nodes.get(k).is_some_and(|c| c.gone || c.repeatable && c.index > 0); + if skip { + continue; + } + if let Some(ci) = clone_subtree(h, k, Some(idx), depth + 1) { + copied.push(ci); + } + } + if let Some(n) = h.nodes.get_mut(idx) { + n.children = copied; + } + Some(idx) +} + +fn add_instance_of(ctx: &mut Context, parent: usize, name: &str) -> JsResult> { + let mut h = host(ctx)?.borrow_mut(); + Ok(h.add_instance(parent, name)) +} + +fn remove_instance_of(ctx: &mut Context, parent: usize, name: &str, i: usize) -> JsResult<()> { + let mut h = host(ctx)?.borrow_mut(); + h.remove_instance(parent, name, i); + Ok(()) +} + +impl XHost { + /// A new instance of the subform `name` under `parent`, after the last one; `None` when the + /// subform doesn't repeat, is at its maximum, or the run added its limit. + pub(crate) fn add_instance(&mut self, parent: usize, name: &str) -> Option { + let h = self; + let live = instances(h, parent, name); + let &first = live.first()?; + let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1))); + if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED || h.nodes.len() >= MAX_NODES { + return None; + } + // The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy. + let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default(); + let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default(); + let som = child_som(parent_som, &name, live.len()); + if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) { + return None; + } + let new = clone_subtree(h, first, Some(parent), 0)?; + let last = live.last().copied().unwrap_or(first); + if let Some(n) = h.nodes.get_mut(new) { + n.index = live.len(); + } + // Keep instances together: right after the last one. + if let Some(p) = h.nodes.get_mut(parent) { + let pos = p.children.iter().position(|&c| c == last).map_or(p.children.len(), |i| i + 1); + p.children.insert(pos, new); + } + renumber(h, new, 0); + h.added += 1; + h.record(XfaEffect::AddInstance { som }); + Some(new) + } + + /// Add or remove instances of the subform `name` under `parent` until there are `want`. + /// Bounded: adding stops at the cap or the maximum, removing at the floor, and a step that + /// changes nothing ends the loop. + pub(crate) fn set_instances(&mut self, parent: usize, name: &str, want: usize) { + let want = want.min(MAX_ADDED); + for _ in 0..=MAX_ADDED { + let have = instances(self, parent, name).len(); + if have < want { + if self.add_instance(parent, name).is_none() { + break; + } + } else if have > want && have > 0 { + self.remove_instance(parent, name, have - 1); + if instances(self, parent, name).len() == have { + break; + } + } else { + break; + } + } + } + + /// Remove instance `i` of the subform `name` under `parent` (never below the minimum, or + /// below one). + pub(crate) fn remove_instance(&mut self, parent: usize, name: &str, i: usize) { + let h = self; + let live = instances(h, parent, name); + let Some(&gone) = live.get(i) else { return }; + let min = h.nodes.get(gone).map_or(0, |n| n.occur_min).max(1); + if live.len() <= min { + return; + } + let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default(); + if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) { + return; + } + if let Some(n) = h.nodes.get_mut(gone) { + n.gone = true; + } + // Later instances move down one. + for (k, &inst) in live.iter().enumerate().skip(i + 1) { + if let Some(n) = h.nodes.get_mut(inst) { + n.index = k - 1; + } + renumber(h, inst, 0); + } + h.record(XfaEffect::RemoveInstance { som }); + } +} diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs new file mode 100644 index 0000000000..04ec0f8349 --- /dev/null +++ b/crates/js/src/xfa/model.rs @@ -0,0 +1,230 @@ +use super::{HashSet, Message}; + +/// What kind of template object a node is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XfaKind { + /// `xfa.form` itself. + Form, + Subform, + Field, + ExclGroup, + Draw, + Area, +} + +impl XfaKind { + pub(crate) fn class_name(self) -> &'static str { + match self { + XfaKind::Form => "form", + XfaKind::Subform => "subform", + XfaKind::Field => "field", + XfaKind::ExclGroup => "exclGroup", + XfaKind::Draw => "draw", + XfaKind::Area => "area", + } + } +} + +/// One object of the form, as the caller snapshots it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaNode { + pub name: String, + /// The SOM expression naming this instance (`form[0].head[0].familyName[0]`). + pub som: String, + pub kind: Option, + /// Fields: the raw value (ISO dates, on values for buttons); empty when null. + pub value: String, + /// Numeric fields give numbers to scripts. + pub numeric: bool, + /// `visible`, `invisible`, `hidden` or `inactive`. + pub presence: String, + /// `open`, `readOnly`, `protected` or `nonInteractive`. + pub access: String, + /// Subforms: may have several instances (`occur max` other than 1). + pub repeatable: bool, + pub occur_min: usize, + pub occur_max: Option, + /// This instance's index among its same-named siblings. + pub index: usize, + pub children: Vec, + /// Fields: the value shown in the field's display picture, what `formattedValue` reads; + /// `None` when the picture doesn't change it (the raw value is read then). + pub formatted: Option, +} + +/// The event a script runs for. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaEvent { + /// `click`, `calculate`, `validate`, `initialize`, `change`, `exit`, … + pub activity: String, + /// The SOM of `this`. + pub target: String, + pub new_text: String, + pub prev_text: String, +} + +/// About the document. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaDoc { + pub file_name: String, + /// The current page, 0-based. + pub page: usize, + pub page_count: usize, +} + +/// Something a script changed or asked for. +#[derive(Clone, Debug, PartialEq)] +pub enum XfaEffect { + SetValue { + som: String, + value: String, + }, + SetPresence { + som: String, + presence: String, + }, + SetAccess { + som: String, + access: String, + }, + /// A new instance of a repeating subform, named by its own SOM (`…row[3]`). + AddInstance { + som: String, + }, + /// The instance at this SOM is removed. + RemoveInstance { + som: String, + }, + /// `xfa.host.messageBox(…)`, or `app.alert(…)` in an XFA form's script. + MessageBox(Message), + /// `xfa.host.resetData()`: the named fields, or every field when empty. + ResetData(Vec), + /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. + ShowPanel(crate::Panel), + Print, + SaveAs, + LaunchUrl(String), + SetFocus(String), + Beep, + /// `xfa.form.recalculate(true)`. + Recalculate, + /// `xfa.form.remerge()`, `xfa.layout.relayout()`. + Relayout, +} + +/// What running a script did. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XfaOutcome { + pub effects: Vec, + pub console: Vec, + /// The script's error, if it failed. + pub error: Option, + /// The script's completion value (a calculate script's result; a validate script's verdict). + pub result: Option, + /// The completion value as a boolean, when it was one. + pub result_bool: Option, + /// Limits the script ran into (too many effects, message boxes or console lines): what + /// was left out, for the caller to report. + pub notes: Vec, + /// The script ran past its time limit and was left running on its own thread (the engine + /// can't interrupt it): the caller should run no more of this form's scripts. + pub abandoned: bool, +} + +/// Most nodes a snapshot may hold. +pub(super) const MAX_NODES: usize = 200_000; + +/// Most instances a script may add in one run. +pub(crate) const MAX_ADDED: usize = 1_000; + +/// Deepest tree followed. +pub(super) const MAX_DEPTH: usize = 64; + +/// Most effects one run may produce (after values set again on the same object are merged). +pub const MAX_EFFECTS: usize = 10_000; + +/// Most message boxes one run may show. +pub const MAX_ALERTS: usize = 100; + +/// Most console lines one run may print. +pub const MAX_CONSOLE: usize = 1_000; + +/// Longest message or console line kept, in characters. +pub(crate) const MAX_LINE: usize = 4_096; + +/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names +/// and the values it sets. Past it, more are left out and noted. +pub(crate) const MAX_PAYLOAD: usize = 32 << 20; + +/// Longest value a script may put in a field (characters); longer ones are cut and noted. +pub(super) const MAX_VALUE: usize = 65_536; + +/// `m` cut to [`MAX_LINE`] characters. +pub(crate) fn clip(m: String) -> String { + match m.char_indices().nth(MAX_LINE) { + Some((at, _)) => format!("{}…", m.get(..at).unwrap_or_default()), + None => m, + } +} + +/// `v` cut to [`MAX_VALUE`] characters, and whether it was cut. +pub(crate) fn clip_value(v: String) -> (String, bool) { + match v.char_indices().nth(MAX_VALUE) { + Some((at, _)) => (v.get(..at).unwrap_or_default().to_string(), true), + None => (v, false), + } +} + +/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and +/// named once, at most as many as a snapshot holds nodes. +pub(crate) fn reset_names(list: &str) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if out.len() >= MAX_NODES { + break; + } + let name = clip(name.to_string()); + if seen.insert(name.clone()) { + out.push(name); + } + } + out +} + +/// The (property, SOM) an effect setting a property of an object is merged by, see +/// [`XHost::set_at`]. +pub(super) fn effect_key(e: &XfaEffect) -> Option<(u8, String)> { + match e { + XfaEffect::SetValue { som, .. } => Some((0, som.clone())), + XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), + XfaEffect::SetAccess { som, .. } => Some((2, som.clone())), + _ => None, + } +} + +/// Bytes of text `e` holds. +pub(super) fn payload(e: &XfaEffect) -> usize { + match e { + XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { + som.len().saturating_add(s.len()) + } + XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::MessageBox(m) => m.bytes(), + XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, + } +} + +/// A script's completion value (what a calculate script puts in its field) cut to +/// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. +pub(crate) fn clip_result(out: &mut XfaOutcome) { + if let Some(r) = out.result.take() { + let (r, cut) = clip_value(r); + if cut { + out.notes.push(format!("the script's result was longer than {MAX_VALUE} characters; it was cut")); + } + out.result = Some(r); + } +} diff --git a/crates/js/src/xfa/nodes.rs b/crates/js/src/xfa/nodes.rs new file mode 100644 index 0000000000..75943352e6 --- /dev/null +++ b/crates/js/src/xfa/nodes.rs @@ -0,0 +1,346 @@ +use super::globals::{recalculate, relayout}; +use super::host::{HNode, XHost, host}; +use super::instances::{instance_manager, list_object}; +use super::model::{MAX_DEPTH, XfaEffect}; +use super::{ + Attribute, Context, JsObject, JsProxy, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, as_number, error, function, + js_string, s, text, +}; + +// ── node objects ──────────────────────────────────────────────────────────────────────────── + +const IDX: &str = "__xfaNode"; + +pub(super) fn node_index(target: &JsValue, ctx: &mut Context) -> JsResult { + let o = target.as_object().ok_or_else(|| error("not an XFA node"))?; + let v = o.get(JsString::from(IDX), ctx)?; + let n = v.to_number(ctx)?; + if !n.is_finite() || n < 0.0 { + return Err(error("not an XFA node")); + } + Ok(n as usize) +} + +/// A Proxy standing for node `idx`. +pub(super) fn node_object(ctx: &mut Context, idx: usize) -> JsResult { + let target = ObjectInitializer::new(ctx).property(JsString::from(IDX), JsValue::from(idx as f64), Attribute::READONLY).build(); + let proxy = JsProxy::builder(target).get(node_get).set(node_set).has(node_has).build(ctx)?; + Ok(proxy.into()) +} + +/// An object that takes any property read or write without complaint (`this.border.fill…`, +/// `this.fillColor = …`): what a script sets there changes nothing here. +pub(super) fn sink(ctx: &mut Context) -> JsResult { + let target = ObjectInitializer::new(ctx).build(); + let proxy = JsProxy::builder(target).get(sink_get).set(sink_set).build(ctx)?; + Ok(proxy.into()) +} + +fn sink_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::undefined()); + } + match text(&key, ctx)?.as_str() { + "value" | "toString" | "valueOf" => Ok(s("")), + _ => sink(ctx), + } +} + +fn sink_set(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(true)) +} + +fn node_has(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(true)) +} + +fn value_js(h: &HNode) -> JsValue { + if h.value.is_empty() { + return JsValue::null(); + } + if h.numeric + && let Some(n) = as_number(&h.value) + { + return JsValue::from(n); + } + s(&h.value) +} + +/// `(idx, name)` of the child of `parent` named `name`, first instance. +pub(crate) fn child_named(h: &XHost, parent: usize, name: &str) -> Option { + let p = h.nodes.get(parent)?; + p.children.iter().copied().find(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)) +} + +/// Every live instance named `name` under `parent`, in order. +pub(crate) fn instances(h: &XHost, parent: usize, name: &str) -> Vec { + h.nodes + .get(parent) + .map(|p| p.children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone && n.name == name)).collect()) + .unwrap_or_default() +} + +/// Resolve a SOM expression from node `from`: `$`, `$form`, `$record`, `$host`, `xfa.form…`, +/// `parent`, `..name` (descendant search), `name[n]`, `name[*]`, `_name`. +pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec { + let expr = expr.trim(); + if expr.is_empty() { + return vec![from]; + } + let root = 0usize; + let mut cur: Vec = vec![from]; + let mut rest = expr; + let starts = + [("$form", root), ("xfa.form", root), ("$record", root), ("xfa.datasets.data", root), ("xfa.record", root), ("$", from), ("this", from)]; + for (prefix, at) in starts { + if let Some(r) = rest.strip_prefix(prefix) + && (r.is_empty() || r.starts_with('.') || r.starts_with('[')) + { + cur = vec![at]; + rest = r; + break; + } + } + let mut depth = 0; + while !rest.is_empty() && depth < MAX_DEPTH { + depth += 1; + let descend = rest.starts_with(".."); + rest = rest.trim_start_matches('.'); + let end = rest.find(['.', '[']).unwrap_or(rest.len()); + let (name, after) = rest.split_at(end); + let name = name.trim(); + let (index, after) = match after.strip_prefix('[') { + Some(a) => { + let close = a.find(']').unwrap_or(a.len()); + let idx = a.get(..close).map(str::trim).unwrap_or(""); + (Some(idx.to_string()), a.get(close + 1..).unwrap_or("")) + } + None => (None, after), + }; + rest = after; + let mut next = Vec::new(); + for &c in &cur { + match name { + "" => {} + "parent" => { + if let Some(p) = h.nodes.get(c).and_then(|n| n.parent) { + next.push(p); + } + } + _ => { + let mut found: Vec = if descend { descendants_named(h, c, name, 0) } else { instances(h, c, name) }; + // A relative name a node has no child for is looked for among its siblings + // and in each enclosing container, nearest first (so a field's script can + // say `resolveNode("otherField")`). + if found.is_empty() && depth == 1 && !descend { + let mut at = h.nodes.get(c).and_then(|n| n.parent); + let mut guard = 0; + while let Some(p) = at + && guard < MAX_DEPTH + { + found = instances(h, p, name); + if !found.is_empty() { + break; + } + at = h.nodes.get(p).and_then(|n| n.parent); + guard += 1; + } + } + match index.as_deref() { + Some("*") => next.extend(found), + Some(i) => { + if let Some(k) = i.parse::().ok().and_then(|i| found.get(i)) { + next.push(*k); + } + } + None => next.extend(found.first()), + } + } + } + } + // Each node once: `row[*].parent.row[*]` would otherwise multiply the list by the + // number of rows at every step. + let mut seen = std::collections::HashSet::with_capacity(next.len()); + next.retain(|i| seen.insert(*i)); + cur = next; + if cur.is_empty() { + break; + } + } + cur +} + +/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document +/// order, to [`MAX_DEPTH`]. +pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec { + let mut out = Vec::new(); + if depth > MAX_DEPTH { + return out; + } + let Some(n) = h.nodes.get(from) else { return out }; + for &c in &n.children { + if let Some(k) = h.nodes.get(c) + && !k.gone + { + if k.name == name { + out.push(c); + } else { + out.extend(descendants_named(h, c, name, depth + 1)); + } + } + } + out +} + +/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows +/// it), attributes, relatives, methods, instance managers and children by name. +fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(&arg(args, 0), ctx)?; + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::undefined()); + } + let key = text(&key, ctx)?; + let snapshot = { + let h = host(ctx)?.borrow(); + let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::undefined()) }; + (n.name.clone(), n.som.clone(), n.kind, n.value.clone(), n.presence.clone(), n.access.clone(), n.index, n.parent, n.children.clone()) + }; + let (name, som, kind, value, presence, access, index, parent, children) = snapshot; + Ok(match key.as_str() { + IDX => JsValue::from(idx as f64), + "rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js), + "formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)), + "name" => s(&name), + "somExpression" => s(&som), + "className" => s(kind.class_name()), + "index" | "instanceIndex" => JsValue::from(index as f64), + "presence" => s(&presence), + "access" => s(&access), + "isNull" => JsValue::from(value.is_empty()), + "mandatory" => s("disabled"), + "parent" => match parent { + Some(p) => node_object(ctx, p)?, + None => JsValue::null(), + }, + "nodes" => { + let live: Vec = { + let h = host(ctx)?.borrow(); + children.iter().copied().filter(|&c| h.nodes.get(c).is_some_and(|n| !n.gone)).collect() + }; + list_object(ctx, &live)? + } + "all" => { + let live = match parent { + Some(p) => instances(&host(ctx)?.borrow(), p, &name), + None => vec![idx], + }; + list_object(ctx, &live)? + } + "instanceManager" => match parent { + Some(p) => instance_manager(ctx, p, &name)?, + None => JsValue::undefined(), + }, + "resolveNode" => resolver(ctx, idx, false)?, + "resolveNodes" => resolver(ctx, idx, true)?, + "recalculate" => function(ctx, NativeFunction::from_fn_ptr(recalculate)).into(), + "remerge" => function(ctx, NativeFunction::from_fn_ptr(relayout)).into(), + "getAttribute" + | "setAttribute" + | "execEvent" + | "execInitialize" + | "execCalculate" + | "execValidate" + | "getElement" + | "setElement" + | "isPropertySpecified" + | "loadXML" + | "saveXML" + | "clone" => function(ctx, NativeFunction::from_fn_ptr(noop)).into(), + "toString" | "valueOf" | "toJSON" => function(ctx, NativeFunction::from_fn_ptr(node_to_string)).into(), + "length" => JsValue::undefined(), + "border" | "ui" | "font" | "caption" | "fillColor" | "borderColor" | "fontColor" | "margin" | "para" | "assist" | "validate" | "format" + | "items" | "bind" | "keep" | "h" | "w" | "x" | "y" | "minH" | "maxH" | "minW" | "maxW" | "layout" | "model" | "oneOfChild" | "locale" + | "relevant" | "colSpan" | "dataNode" | "defaultValue" | "editValue" | "selectedIndex" | "event" | "calculate" | "traversal" | "extras" + | "desc" => sink(ctx)?, + other => { + if let Some(child) = other.strip_prefix('_') { + let exists = child_named(&host(ctx)?.borrow(), idx, child).is_some(); + return if exists { instance_manager(ctx, idx, child) } else { Ok(JsValue::undefined()) }; + } + let found = child_named(&host(ctx)?.borrow(), idx, other); + match found { + Some(c) => node_object(ctx, c)?, + None => JsValue::undefined(), + } + } + }) +} + +fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(&arg(args, 0), ctx)?; + let key = arg(args, 1); + if key.as_symbol().is_some() { + return Ok(JsValue::from(true)); + } + let key = text(&key, ctx)?; + let value = arg(args, 2); + let value = if value.is_null() || value.is_undefined() { String::new() } else { text(&value, ctx)? }; + let mut h = host(ctx)?.borrow_mut(); + let Some(n) = h.nodes.get(idx) else { return Ok(JsValue::from(true)) }; + let som = n.som.clone(); + match key.as_str() { + "rawValue" | "value" | "formattedValue" => { + // Not a field: the property doesn't exist, as in Acrobat, and nothing is recorded. + h.set_value(idx, value); + } + "presence" => { + let p = match value.as_str() { + "hidden" | "invisible" | "inactive" | "visible" => value.clone(), + _ => return Ok(JsValue::from(true)), + }; + h.set_state(idx, XfaEffect::SetPresence { som, presence: p }); + } + "access" => { + let a = match value.as_str() { + "open" | "readOnly" | "protected" | "nonInteractive" => value.clone(), + _ => return Ok(JsValue::from(true)), + }; + h.set_state(idx, XfaEffect::SetAccess { som, access: a }); + } + _ => {} + } + Ok(JsValue::from(true)) +} + +fn node_to_string(this: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { + let idx = node_index(this, ctx)?; + Ok(s(&host(ctx)?.borrow().nodes.get(idx).map(|n| n.value.clone()).unwrap_or_default())) +} + +pub(super) fn noop(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::undefined()) +} + +/// `resolveNode(som)` / `resolveNodes(som)` bound to node `from`. +pub(super) fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { + let key = ObjectInitializer::new(ctx) + .property(js_string!("from"), JsValue::from(from as f64), Attribute::READONLY) + .property(js_string!("many"), JsValue::from(many), Attribute::READONLY) + .build(); + fn resolve(_: &JsValue, args: &[JsValue], key: &JsObject, ctx: &mut Context) -> JsResult { + let from = key.get(js_string!("from"), ctx)?.to_number(ctx)? as usize; + let many = key.get(js_string!("many"), ctx)?.to_boolean(); + let expr = text(&arg(args, 0), ctx)?; + let found = resolve_som(&host(ctx)?.borrow(), from, &expr); + if many { + return list_object(ctx, &found); + } + match found.first() { + Some(i) => node_object(ctx, *i), + None => Ok(JsValue::null()), + } + } + Ok(function(ctx, NativeFunction::from_copy_closure_with_captures(resolve, key)).into()) +} diff --git a/crates/js/src/xfa/run.rs b/crates/js/src/xfa/run.rs new file mode 100644 index 0000000000..f951e02a54 --- /dev/null +++ b/crates/js/src/xfa/run.rs @@ -0,0 +1,170 @@ +use super::globals::install; +use super::host::{Shared, XHost}; +use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result}; +use super::{Attribute, Context, JsResult, Limits, RefCell, Source, js_string, refuse, s, text}; + +/// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. +pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + if let Some(why) = refuse(script) { + return failed(why); + } + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::scope(|scope| { + let spawned = std::thread::Builder::new() + .name("pdfcraft-xfa-js".into()) + .stack_size(crate::SCRIPT_STACK) + .spawn_scoped(scope, || run_here(script, event, doc, root.clone(), limits, &|| {})); + match spawned { + Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), + Err(e) => failed(format!("the script engine could not start: {e}")), + } + }) + } + #[cfg(target_arch = "wasm32")] + run_here(script, event, doc, root.clone(), limits, &|| {}) +} + +/// [`run_xfa`] with a time limit: a script still running `timeout` after the engine is ready +/// (loops inside nested function calls can run for hours within the engine's per-frame loop +/// limit) is left on its thread, which ends when the engine's own limits stop it, and an +/// outcome with `abandoned` set comes back at once. In the browser build there are no +/// threads: the script runs to its limits. +pub fn run_xfa_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome { + if let Some(why) = refuse(script) { + return XfaOutcome { error: Some(why), ..Default::default() }; + } + let (script, event, doc) = (script.to_string(), event.clone(), doc.clone()); + run_within("pdfcraft-xfa-js", timeout, move |started| run_here(&script, &event, &doc, root, limits, started)) +} + +/// Longest the script engine may take to be ready for a script (its thread and context, and +/// for the first script in a process the engine's own set-up) before the script is abandoned. +/// It doesn't count against the script's time: on a busy machine the start alone can take +/// longer than the tightest limit, and abandoning a script turns the form's scripts off. +pub(crate) const ENGINE_START_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); + +/// Run `script` on its own thread (with the script stack) and wait at most `timeout` for its +/// outcome, counted from when it calls `started` (once the engine is ready); past that the +/// thread is abandoned and the outcome says so. On wasm there are no threads: the script runs +/// here. +pub(crate) fn run_within(name: &str, timeout: std::time::Duration, script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static) -> XfaOutcome { + run_within_limits(name, ENGINE_START_LIMIT, timeout, script) +} + +/// [`run_within`] with the engine's start-up limit given. +pub(crate) fn run_within_limits( + name: &str, + start_limit: std::time::Duration, + timeout: std::time::Duration, + script: impl FnOnce(&dyn Fn()) -> XfaOutcome + Send + 'static, +) -> XfaOutcome { + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::mpsc::RecvTimeoutError; + use std::time::Instant; + enum Step { + Started, + Done(XfaOutcome), + } + let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; + let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name(name.into()).stack_size(crate::SCRIPT_STACK).spawn(move || { + // The receiver is gone when the caller stopped waiting: nothing to report then. + let started = tx.clone(); + let outcome = script(&move || { + let _ = started.send(Step::Started); + }); + let _ = tx.send(Step::Done(outcome)); + }); + if let Err(e) = spawned { + return failed(format!("the script engine could not start: {e}")); + } + // Wait for the engine within its start-up limit, then for the script within its own. + let (mut running, mut limit) = (false, start_limit); + let mut deadline = Instant::now().checked_add(limit); + loop { + let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); + match rx.recv_timeout(left) { + Ok(Step::Done(o)) => return o, + Ok(Step::Started) => { + // A second call changes nothing: the script's time has already begun. + if !running { + (running, limit) = (true, timeout); + deadline = Instant::now().checked_add(limit); + } + } + Err(RecvTimeoutError::Timeout) if running => { + return abandoned(format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())); + } + Err(RecvTimeoutError::Timeout) => { + return abandoned(format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())); + } + Err(RecvTimeoutError::Disconnected) => return failed("the script stopped with an internal error".into()), + } + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = (name, start_limit, timeout); + script(&|| {}) + } +} + +/// The first script in a process sets the engine up (its built-ins, parser and compiler come +/// into use): a trivial script takes that on, so a form's script is never timed with it. +pub(crate) fn warm_up() { + static WARM: std::sync::Once = std::sync::Once::new(); + // `call_once_force`: a poisoned `Once` (a panic while warming up) only means warming up again. + WARM.call_once_force(|_| { + let mut ctx = Context::default(); + let _ = ctx.eval(Source::from_bytes(b"(function () { return eval('0'); }).call({})")); + }); +} + +/// `started` is called once the engine is ready: a time limit on the script runs from then. +pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { + warm_up(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, + }; + ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); + ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); + let host_state = XHost::new(root, &event.target, doc); + let current = host_state.current; + ctx.insert_data(RefCell::new(host_state)); + let mut out = XfaOutcome::default(); + let result = (|| -> JsResult<()> { + install(&mut ctx, event, doc, current)?; + // Ready for the script: its time starts now, the set-up above not charged to it. + started(); + ctx.register_global_property(js_string!("__xfa_src"), s(script), Attribute::CONFIGURABLE)?; + // Direct eval inside a function called on the node: `this` is the node and the + // completion value is the script's last expression (what a calculate script yields). + let r = ctx.eval(Source::from_bytes(b"(function () { return eval(__xfa_src); }).call(__xfa_this)"))?; + if !r.is_undefined() { + // Best effort: a value that can't be made text (a symbol, say) is no failure. + out.result = Some(text(&r, &mut ctx).unwrap_or_default()); + if r.is_boolean() { + out.result_bool = Some(r.to_boolean()); + } else if r.is_null() { + out.result = Some(String::new()); + } + } + Ok(()) + })(); + if let Err(e) = result { + out.error = Some(e.to_string()); + } + if let Some(h) = ctx.remove_data::() { + let h = h.into_inner(); + out.notes = h.notes(); + out.effects = h.effects; + out.console = h.console; + } + clip_result(&mut out); + out +} diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index c2c5ee3cc3..28ce53994d 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -1,8 +1,9 @@ //! One script engine for a whole form event. Every script of the event runs on one thread //! (with the script stack), each in a fresh JavaScript context on a fresh copy of the form as //! the caller holds it, so scripts can't see each other's variables or objects and a script -//! that failed halfway leaves nothing behind; what a script changed reaches the next one only -//! through the caller, which applies the effects and sends the form as it then is. Before, +//! that failed halfway leaves no state behind in the engine; what a script changed (up to its +//! error, when it failed) reaches the next one only through the caller, which applies the +//! effects and sends the form as it then is. Before, //! every script also got a thread of its own and the caller rebuilt the form for each: a form //! with hundreds of calculations spent its open-time budget on that. //! @@ -17,6 +18,18 @@ use crate::xfa::{XfaDoc, XfaEvent, XfaNode, XfaOutcome}; #[cfg(test)] pub(crate) static START_DELAY_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +std::thread_local! { + static ENGINES_STARTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many script engine threads the sessions made on this thread have started, for +/// diagnostics and tests: an event's scripts share one, so opening a form with hundreds of +/// calculations starts one. Per thread, so a caller counting its own events is not disturbed +/// by forms on other threads. Always 0 on wasm, where the scripts run on the caller's thread. +pub fn engines_started() -> u64 { + ENGINES_STARTED.with(std::cell::Cell::get) +} + /// What one script run needs. #[derive(Clone, Debug)] pub struct Script { @@ -57,7 +70,7 @@ fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome { #[cfg(not(target_arch = "wasm32"))] mod native { use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; - use std::time::Instant; + use std::time::{Duration, Instant}; use super::{Script, run_script}; use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome}; @@ -82,11 +95,20 @@ mod native { worker: Option, /// A script ran past its limit: its thread is left to it and nothing more runs. dead: Option, + /// How long the engine has to be ready for a script ([`ENGINE_START_LIMIT`] by default). + start_limit: Duration, } impl XfaSession { pub fn new(doc: XfaDoc) -> XfaSession { - XfaSession { doc, worker: None, dead: None } + XfaSession { doc, worker: None, dead: None, start_limit: ENGINE_START_LIMIT } + } + + /// Multiply the time the engine has to be ready for a script by `factor` (a script's + /// own time is [`Script::timeout`]). + pub fn with_time_scale(mut self, factor: u32) -> XfaSession { + self.start_limit = ENGINE_START_LIMIT.saturating_mul(factor); + self } /// Why nothing more runs, once a script was abandoned. @@ -114,11 +136,12 @@ mod native { } }); spawned.map_err(|e| format!("the script engine could not start: {e}"))?; - match from.recv_timeout(ENGINE_START_LIMIT) { + super::ENGINES_STARTED.with(|n| n.set(n.get().saturating_add(1))); + match from.recv_timeout(self.start_limit) { Ok(Reply::Ready) => {} Ok(_) => return Err("the script engine answered out of turn".into()), Err(RecvTimeoutError::Timeout) => { - return Err(format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64())); + return Err(format!("the script engine took longer than {:.0} s to start", self.start_limit.as_secs_f64())); } Err(RecvTimeoutError::Disconnected) => return Err("the script engine stopped with an internal error".into()), } @@ -128,8 +151,9 @@ mod native { } /// Run one script, waiting at most its `timeout` from when the engine is ready for it - /// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready); past that it is - /// abandoned on its thread and the session is dead. + /// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready, unless + /// [`XfaSession::with_time_scale`] changed it); past that it is abandoned on its thread + /// and the session is dead. pub fn run(&mut self, script: Script) -> XfaOutcome { let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; @@ -137,6 +161,7 @@ mod native { return abandoned(why.clone()); } let timeout = script.timeout; + let start_limit = self.start_limit; let worker = match self.worker() { Ok(w) => w, Err(why) => return failed(why), @@ -147,7 +172,7 @@ mod native { return failed("the script engine stopped with an internal error".into()); } // The engine's start, then the script's own time. - let (mut running, mut limit) = (false, ENGINE_START_LIMIT); + let (mut running, mut limit) = (false, start_limit); let mut deadline = Instant::now().checked_add(limit); loop { let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); @@ -166,7 +191,7 @@ mod native { let why = if running { format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64()) } else { - format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64()) + format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64()) }; self.dead = Some(why.clone()); // Dropping the sender ends the thread's loop once the script is done. @@ -198,6 +223,11 @@ mod native { XfaSession { doc } } + /// No engine to start on wasm: nothing to scale. + pub fn with_time_scale(self, _factor: u32) -> XfaSession { + self + } + pub fn dead(&self) -> Option<&str> { None } diff --git a/crates/measure/README.md b/crates/measure/README.md index 653cfa8437..95a04a2b64 100644 --- a/crates/measure/README.md +++ b/crates/measure/README.md @@ -4,9 +4,9 @@ Layer L4 implements distance, connected-line perimeter and simple polygon area m Measurements are ordinary Line, PolyLine or Polygon annotations with a rectilinear Measure dictionary and a normal appearance containing the numeric caption. Their scale is stored with the annotation, so changing a drawing scale does not change earlier measurements. Moving, restyling, replying to and deleting them uses the existing comment workflow. -Scale::calibrate derives a conversion from two points and a known real-world distance. set_scale appends a named rectangular page viewport, preserving existing viewports. scale_at selects the last viewport containing the first measurement vertex. Default measurements account for UserUnit; display-coordinate conversion accounts for crop boxes and page rotation. +Scale::calibrate derives a conversion from two points and a known real-world distance. Scale::ratio takes a scale as a plan writes it, `1 mm = 50 mm` at 1:50: its page side is in pt, in, mm or cm and is converted to points, so the stored /X, /D and /A factors stay per user-space unit and the /R string keeps the units as entered. Scales read from files are used as stored. set_scale appends a named rectangular page viewport, preserving existing viewports. scale_at selects the last viewport containing the first measurement vertex. Default measurements account for UserUnit; display-coordinate conversion accounts for crop boxes and page rotation. -The desktop Measure objects panel exposes all three tools, live values and coordinate deltas, 45-degree constraints, vertex cancellation, snapping, calibration and CSV export. Automation provides measure_distance, measure_perimeter, measure_area, measure_info, measure_list, measure_scale, measure_snap and measure_export. Tool coordinates and explicit units-per-point scales use physical display points. +The desktop Measure objects panel exposes all three tools, live values and coordinate deltas, 45-degree constraints, vertex cancellation, snapping, calibration and CSV export. Automation provides measure_distance, measure_perimeter, measure_area, measure_info, measure_list, measure_scale, measure_snap and measure_export. The panel's drawing scale and measure_scale's `page_distance`/`page_unit` give the page side in a unit, by default the real-world one. Tool coordinates and explicit units-per-point scales use physical display points. Snapping interprets painted paths and nested Form XObjects, including graphics-state transforms. Cubic paths are flattened to a maximum deviation of 0.2 user units. Raster edges, glyph outlines, clipping and optional-content visibility are not interpreted. Extraction stops at 20,000 segments, 40,000 endpoint and midpoint targets, 16 MiB of decoded content, 4,096 streams, 32 nested forms or 256 nested `q`; intersection searches use at most 256 nearby segments. Extraction is lenient: a content stream that can't be decoded is skipped and counted, and the rest of the page still snaps. The UI and automation report these limits. diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index aac6663c04..d52d31dcc6 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -110,6 +110,19 @@ impl Scale { } Self::new(real_distance / length, unit, precision) } + /// A scale as a plan writes it: `page` `page_unit` on the page is `real` `unit` in the real + /// world ("1 mm = 50 mm" at 1:50). `a` and `b` are the user-space ends of one display point, + /// as for [`Scale::calibrate`]. The page side is converted to points, so the stored factors + /// stay per user-space unit; the ratio string keeps the units as entered. + pub fn ratio(a: Point, b: Point, page: f64, page_unit: PageUnit, real: f64, unit: &str, precision: u8) -> Result { + if !page.is_finite() || page <= 0.0 || !real.is_finite() || real <= 0.0 { + return Err(invalid("both sides of a scale ratio must be finite and positive")); + } + let mut out = Self::calibrate(a, b, real / (page * page_unit.points()), unit, precision)?; + out.ratio = format!("{} {} = {} {unit}", ratio_number(page), page_unit.label(), ratio_number(real)); + out.validate()?; + Ok(out) + } pub fn validate(&self) -> Result<()> { for f in [self.x, self.y, self.distance_factor, self.area_factor] { if !f.is_finite() || !(1e-12..=1e12).contains(&f) { @@ -202,6 +215,44 @@ impl Scale { Ok(out) } } +/// The unit of the page side of a scale ratio. The PDF keeps scales per user-space unit +/// (ISO 32000-2 §12.9), so a ratio given in one of these is converted to points. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PageUnit { + Pt, + In, + Mm, + Cm, +} +impl PageUnit { + pub const ALL: [Self; 4] = [Self::Pt, Self::In, Self::Mm, Self::Cm]; + pub fn label(self) -> &'static str { + match self { + Self::Pt => "pt", + Self::In => "in", + Self::Mm => "mm", + Self::Cm => "cm", + } + } + pub fn from_label(s: &str) -> Option { + Self::ALL.into_iter().find(|u| u.label() == s.trim()) + } + /// Points in one of this unit. + pub fn points(self) -> f64 { + match self { + Self::Pt => 1.0, + Self::In => 72.0, + Self::Mm => 72.0 / 25.4, + Self::Cm => 72.0 / 2.54, + } + } +} +/// A side of a scale ratio as people write it: at most six decimals, no trailing zeros. +fn ratio_number(v: f64) -> String { + let fixed = format!("{v:.6}"); + let short = fixed.trim_end_matches('0').trim_end_matches('.'); + if short == "0" || short.len() > 24 { format!("{v:e}") } else { short.into() } +} pub(crate) fn check_points(points: &[Point]) -> Result<()> { if points.len() > MAX_POINTS || points.iter().flatten().any(|v| !v.is_finite() || v.abs() > MAX_COORD) { return Err(invalid("measurement geometry must be finite, within 1e9 user units, and have at most 2048 vertices")); @@ -397,10 +448,20 @@ pub struct Unsupported { pub struct Listing { pub measurements: Vec, pub unsupported: Vec, - /// The annotation limit stopped enumeration early. + /// The annotation or retained-size limit stopped enumeration early. pub truncated: bool, } const MAX_ANNOTATIONS: usize = 100_000; +/// Points and text one listing keeps, as counted by [`retained`]: about a thousand +/// measurements of [`MAX_POINTS`] points each. +const MAX_LISTED_BYTES: usize = 32 << 20; +/// The bytes a listed measurement keeps in points and text. +fn retained(m: &Measurement) -> usize { + let s = &m.scale; + [m.id.len(), m.author.len(), m.label.len(), s.unit.len(), s.area_unit.len(), s.ratio.len()] + .into_iter() + .fold(m.points.len().saturating_mul(std::mem::size_of::()), usize::saturating_add) +} fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result { let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?; let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" }; @@ -420,6 +481,8 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us pub fn list(doc: &Document) -> Listing { let mut out = Listing::default(); let mut count = 0usize; + let mut kept = 0usize; + let mut seen = std::collections::HashSet::new(); for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() { let Some(o) = p.dict.get(b"Annots") else { continue }; let o = doc.resolve(o); @@ -430,6 +493,12 @@ pub fn list(doc: &Document) -> Listing { out.truncated = true; return out; } + // One annotation object under several entries or pages is one measurement. + if let Some(r) = a.as_ref() + && !seen.insert(r) + { + continue; + } let o = doc.resolve(a); let Some(d) = o.as_dict() else { continue }; let kind = match d.name(b"IT") { @@ -439,13 +508,29 @@ pub fn list(doc: &Document) -> Listing { _ => continue, }; match read_measurement(doc, d, kind, page, index) { - Ok(m) => out.measurements.push(m), + Ok(m) => { + kept = kept.saturating_add(retained(&m)); + if kept > MAX_LISTED_BYTES { + out.truncated = true; + return out; + } + out.measurements.push(m); + } Err(e) => out.unsupported.push(Unsupported { page, index, reason: e.to_string() }), } } } out } +/// Most viewports [`set_scale`] gives a page, and most [`scale_at`] reads. +const MAX_VIEWPORTS: usize = 1024; + +#[cfg(test)] +thread_local! { + /// Numbers read from viewport `/BBox` arrays. + pub(crate) static VIEWPORT_NUMBERS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Last containing viewport wins, using the first point of the measurement (ISO 32000-2 §12.9). /// Outside every viewport the page measures true size in inches. pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result { @@ -459,12 +544,17 @@ pub fn scale_at_or(doc: &Document, page_index: usize, at: Point, per_point: f64, if let Some(o) = p.dict.get(b"VP") { let o = doc.resolve(o); let arr = o.as_array().ok_or_else(|| invalid("invalid page viewports"))?; - for viewport in arr.iter().rev() { + // The last viewports win; a page holds at most MAX_VIEWPORTS of its own. + for viewport in arr.iter().rev().take(MAX_VIEWPORTS) { let o = doc.resolve(viewport); let Some(d) = o.as_dict() else { continue }; if let Some(bbox) = d.get(b"BBox") { let o = doc.resolve(bbox); - let b: Option> = o.as_array().and_then(|a| a.iter().map(|v| doc.resolve(v).as_f64()).collect()); + let b: Option> = o.as_array().filter(|a| a.len() == 4).and_then(|a| { + #[cfg(test)] + VIEWPORT_NUMBERS_READ.with(|n| n.set(n.get() + a.len())); + a.iter().map(|v| doc.resolve(v).as_f64()).collect() + }); if let Some(b) = b.filter(|b| b.len() == 4 && b.iter().all(|v| v.is_finite())) && at[0] >= b[0].min(b[2]) && at[0] <= b[0].max(b[2]) @@ -490,7 +580,7 @@ pub fn set_scale(doc: &mut Document, page_index: usize, bbox: [f64; 4], name: &s Some(o) => doc.resolve(o).as_array().cloned().ok_or_else(|| invalid("invalid page viewports"))?, None => Vec::new(), }; - if list.len() >= 1024 { + if list.len() >= MAX_VIEWPORTS { return Err(invalid("page has too many measurement viewports")); } let mut d = Dict::new(); diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 71ba78f4ac..930ffe3053 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -214,13 +214,14 @@ impl Walker<'_> { self.geometry.truncated = true; break; } - // An undecodable part is skipped; the rest of the page still snaps. + // An undecodable part is skipped; the parts before it still snap. match s.decoded_within(remaining) { Ok(data) => { joined.extend(data); - joined.push(b'\n'); + // A space, as the renderer joins pieces: a trailing comment runs on. + joined.push(b' '); } - Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1), + Err(_) => self.decode_failed(remaining), } } } @@ -235,11 +236,17 @@ impl Walker<'_> { match stream.decoded_within(remaining) { Ok(data) => self.ops(&data, resources, initial, depth), Err(_) => { - self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); + self.decode_failed(remaining); Ok(()) } } } + /// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes, + /// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work. + fn decode_failed(&mut self, remaining: usize) { + self.geometry.unreadable = self.geometry.unreadable.saturating_add(1); + self.bytes = self.bytes.saturating_add(remaining); + } fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> { self.bytes = self.bytes.saturating_add(data.len()); self.streams = self.streams.saturating_add(1); @@ -249,6 +256,8 @@ impl Walker<'_> { let mut current = None; let mut closures = Vec::new(); let mut path = Geometry::default(); + // Read on the first `Do` and kept: resolving a direct dictionary copies it. + let mut xobjects = None; let point = |m: Matrix, x: f64, y: f64| { let (x, y) = m.apply(x, y); [x, y] @@ -383,7 +392,11 @@ impl Walker<'_> { } "Do" => { if let Some(name) = op.name(0) { - let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)); + let xobjects = xobjects.get_or_insert_with(|| { + #[cfg(test)] + XOBJECT_DICT_READS.with(|n| n.set(n.get() + 1)); + resources.get(b"XObject").map(|o| self.doc.resolve(o)) + }); if let Some(o) = xobjects.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(name)) { let r = o.as_ref(); if r.is_some_and(|r| self.seen.contains(&r)) { @@ -419,6 +432,12 @@ impl Walker<'_> { Ok(()) } } +#[cfg(test)] +thread_local! { + /// Times a content stream's `/XObject` resources were read. + pub(crate) static XOBJECT_DICT_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + pub fn geometry(doc: &Document, page_index: usize) -> Result { let p = crate::page(doc, page_index)?; let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o)); diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 6af32632fa..5b277e5546 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -80,7 +80,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() { let (r, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..])); d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap(); - pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap(); + pdfcraft_annot::set_style(&mut d, 0, 0, pdfcraft_annot::StyleChange::new(Some([1.0, 0.0, 0.0]), None, Some(2.0), None), &meta).unwrap(); let (_, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.int(b"VendorKey"), Some(42)); let ap = d.resolve(dict.get(b"AP").unwrap()); @@ -363,3 +363,101 @@ fn user_unit_is_read_as_acrobat_reads_it() { close(view[1], 72.0); } } + +#[test] +fn a_scale_ratio_converts_its_page_side_to_points() { + // #2752: a plan drawn at 1:50 is "1 mm = 50 mm", so a line 10 mm long on the page is 500 mm. + // The page side used to be points: 1 pt = 50 mm read the line as 1417 mm (50 × 72 / 25.4). + let mm = 72.0 / 25.4; + let line = [[20.0, 30.0], [20.0 + 10.0 * mm, 30.0]]; + let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], 1.0, PageUnit::Mm, 50.0, "mm", 2).unwrap(); + close(reading(Kind::Distance, &line, &scale).unwrap().value, 500.0); + assert_eq!(reading(Kind::Distance, &line, &scale).unwrap().label, "500.00 mm"); + assert_eq!(scale.ratio, "1 mm = 50 mm"); + // The PDF stores the factors per point (ISO 32000-2 §12.9): X converts user units to mm. + let dict = scale.dictionary().unwrap(); + let x = dict.get(b"X").unwrap().as_array().unwrap()[0].as_dict().unwrap(); + close(x.get(b"C").and_then(Object::as_f64).unwrap(), 50.0 / mm); + assert_eq!(Scale::read(&fixture("", "", &[]), &Object::Dict(dict)).unwrap(), scale); + // Every page unit converts: one of it on the page is one metre. + for (unit, points) in [(PageUnit::Pt, 1.0), (PageUnit::In, 72.0), (PageUnit::Mm, mm), (PageUnit::Cm, 10.0 * mm)] { + assert_eq!(PageUnit::from_label(unit.label()), Some(unit)); + let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], 2.0, unit, 2.0, "m", 3).unwrap(); + close(reading(Kind::Distance, &[[0.0, 0.0], [0.0, 3.0 * points]], &scale).unwrap().value, 3.0); + assert_eq!(scale.ratio, format!("2 {} = 2 m", unit.label())); + } + assert_eq!(PageUnit::from_label("ft"), None); + // On a UserUnit 2 page one display point spans half a user unit; the ratio still holds on screen. + let mut d = fixture("", "/UserUnit 2", &[]); + let (a, b) = (view_to_user(&d, 0, [0.0, 0.0]).unwrap(), view_to_user(&d, 0, [1.0, 0.0]).unwrap()); + let scale = Scale::ratio(a, b, 1.0, PageUnit::Cm, 0.5, "m", 2).unwrap(); + let ends = [view_to_user(&d, 0, [10.0, 10.0]).unwrap(), view_to_user(&d, 0, [10.0 + 4.0 * 10.0 * mm, 10.0]).unwrap()]; + close(reading(Kind::Distance, &ends, &scale).unwrap().value, 2.0); + // Saved and reopened, the viewport keeps the factors and the ratio as written. + set_scale(&mut d, 0, [0.0, 0.0, 612.0, 792.0], "plan", &scale).unwrap(); + let d = Document::open(Arc::new(write_full(&d, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(scale_at(&d, 0, ends[0]).unwrap(), scale); + // Points per unit keep meaning points, and both sides must be finite and positive. + assert_eq!(Scale::calibrate([0.0, 0.0], [1.0, 0.0], 50.0, "mm", 2).unwrap().ratio, "1 pt = 50 mm"); + for (page, real) in [(0.0, 1.0), (-1.0, 1.0), (f64::NAN, 1.0), (f64::INFINITY, 1.0), (1.0, 0.0), (1.0, f64::NAN), (1e-300, 1e300)] { + assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}"); + } +} +/// A content stream reads its `/XObject` resources once, not again (as a copy of a direct +/// dictionary) for every `Do`. +#[test] +fn xobject_resources_are_read_once_per_stream() { + let d = fixture(&"/Missing Do\n".repeat(10_000), "", &[]); + snap::XOBJECT_DICT_READS.with(|c| c.set(0)); + let paths = snap::geometry(&d, 0).unwrap(); + assert!(paths.segments.is_empty()); + let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get); + assert!(reads <= 1, "{reads} reads of the page's XObject resources"); +} +/// Finding the scale under a point reads only well-formed viewport boxes, and only as many +/// viewports as a page can be given, however many a file lists or however long their boxes. +#[test] +fn scale_lookup_reads_viewports_within_bounds() { + let vp = vec!["5 0 R"; 5_000].join(" "); + let long_box = vec!["0"; 2_000].join(" "); + let d = fixture("", &format!("/VP [{vp}]"), &["<< /Type /Viewport /BBox 6 0 R >>", &format!("[{long_box}]")]); + VIEWPORT_NUMBERS_READ.with(|c| c.set(0)); + let scale = scale_at(&d, 0, [10.0, 10.0]).unwrap(); + assert_eq!(scale.unit, "in"); + let read = VIEWPORT_NUMBERS_READ.with(std::cell::Cell::get); + assert!(read <= 4 * 1024, "{read} viewport numbers read"); +} + +#[test] +fn listing_skips_repeated_refs_and_caps_what_it_keeps() { + let mut d = fixture("", "", &[]); + let points: Vec = (0..MAX_POINTS).map(|i| [i as f64 * 0.1, 0.0]).collect(); + add(&mut d, &new(Kind::Perimeter, points), &Meta::default()).unwrap(); + let (r, mut annotation) = annot(&d, 0, 0).unwrap(); + let page = pdfcraft_model::pages(&d)[0].obj; + let set_annots = |d: &mut Document, list: Vec| d.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(list))).unwrap(); + // One annotation referenced 1000 times is one measurement. + set_annots(&mut d, vec![Object::Ref(r); 1000]); + assert_eq!(list(&d).measurements.len(), 1); + // Many small annotations sharing one 2048-point vertex array stop at the retained budget. + let vertices = d.add(annotation.get(b"Vertices").unwrap().clone()); + annotation.set(b"Vertices".to_vec(), Object::Ref(vertices)); + set_annots(&mut d, vec![Object::Dict(annotation); 1200]); + let listing = list(&d); + assert!(listing.truncated); + assert!(listing.measurements.iter().map(retained).sum::() <= MAX_LISTED_BYTES); + assert!(!listing.measurements.is_empty()); +} +#[test] +fn a_stream_over_the_byte_budget_uses_it_up_once() { + // 50 references to one Flate stream that inflates past the 16 MiB budget decode once. + let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]); + let hex: String = big.raw.iter().map(|b| format!("{b:02x}")).collect(); + let bomb = String::from_utf8(pdfcraft_testkit::stream_obj("/Filter [/ASCIIHexDecode /FlateDecode]", hex)).unwrap(); + let contents = format!("[4 0 R{}]", " 5 0 R".repeat(50)); + let d = fixture_with(&contents, "0 0 m 10 0 l S", "", &[bomb.as_str()]); + let g = snap::geometry(&d, 0).unwrap(); + assert_eq!(g.unreadable, 1); + assert!(g.truncated); + assert_eq!(g.segments.len(), 1, "the parts before it still snap"); +} diff --git a/crates/measure/tests/snap_property.rs b/crates/measure/tests/snap_property.rs index a05b507a72..a38bb80aaf 100644 --- a/crates/measure/tests/snap_property.rs +++ b/crates/measure/tests/snap_property.rs @@ -175,3 +175,44 @@ fn high_curvature_geometry_hits_segment_cap() { let geometry = check_snap_invariants(&stress_curve); assert!(geometry.truncated, "high-curvature geometry did not report its extraction cap ({} segments)", geometry.segments.len()); } + +fn two_piece_pdf(first: &[u8], second: &[u8]) -> Vec { + let mut objects = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] /Resources << >> >>".to_vec(), + ]; + for content in [first, second] { + let mut object = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(); + object.extend_from_slice(content); + object.extend_from_slice(b"\nendstream"); + objects.push(object); + } + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn snap_at_box_corner(first: &[u8]) -> Option { + let pdf = two_piece_pdf(first, b"0 0 1 RG 15 95 150 30 re S\n"); + let doc = Document::open(Arc::new(pdf)).unwrap(); + snap::geometry(&doc, 0).unwrap().snap([15.0, 105.0], 4.0, SnapOptions::default()).unwrap() +} + +#[test] +fn trailing_comment_in_previous_piece_hides_next_piece_first_line() { + assert!(snap_at_box_corner(b"q % tail comment, no end of line").is_none(), "snapped to a rectangle the renderer comments out"); + assert!(snap_at_box_corner(b"q % tail comment\n").is_some(), "control: the rectangle is drawn"); +} diff --git a/crates/model/README.md b/crates/model/README.md index bfc6e42e3b..09a0603913 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -17,6 +17,9 @@ L3/L4 crates need: The page-tree walk keeps one cursor per ancestor and shares inherited attribute owners. Repeated child references are skipped before copying attributes, with first depth-first occurrence order preserved. Returned page dictionaries still own their effective attributes. +A walk copies at most `MAX_INHERITED_COPIES` (1 GiB, approximate heap bytes) of inherited +values into the leaves; past it the walk stops, as it does past its leaf cap, and the pages +after that point are not listed. `organize`, `annot` and `forms` still carry their own small walkers; they move here as the model grows (ADR-0004). diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 38bf36f222..886684c6d7 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -82,14 +82,36 @@ pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] { } /// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep -/// trees are cut off rather than followed. +/// trees are cut off rather than followed, as is a tree whose inherited values would take more +/// than [`MAX_INHERITED_COPIES`] bytes to copy into its leaves. pub fn pages(doc: &Document) -> Vec { PageTree::new(doc).collect() } +/// Approximate bytes of inherited values copied into leaf dictionaries by one page walk. +pub const MAX_INHERITED_COPIES: usize = 1 << 30; + +/// Approximate heap bytes of `o`: one [`Object`] per node plus string, name and key bytes. +/// Values nested deeper than the parser allows weigh as too large. +fn weight(o: &Object, depth: usize) -> usize { + if depth > 256 { + return usize::MAX; + } + let own = std::mem::size_of::(); + match o { + Object::String(s) => own.saturating_add(s.bytes.len()), + Object::Name(n) => own.saturating_add(n.len()), + Object::Array(a) => a.iter().fold(own, |n, v| n.saturating_add(weight(v, depth + 1))), + Object::Dict(d) => d.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))), + Object::Stream(s) => s.dict.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))), + _ => own, + } +} + // Retain the owner of each inherited value rather than deep-cloning direct Resources on -// every edge. Only the final Page dictionaries need owned copies of those values. -type Inherited = [Option>; 4]; +// every edge. Only the final Page dictionaries need owned copies of those values. Each owner +// carries the value's weight, measured once where the value is declared. +type Inherited = [Option<(std::sync::Arc, usize)>; 4]; struct Children { owner: std::sync::Arc, @@ -112,16 +134,23 @@ struct PageTree<'a> { // One cursor per ancestor, never one entry per child. This also preserves the first // depth-first occurrence's attributes when malformed trees share a child. pending: Vec, + // Weight left for copying inherited values into leaves. A large direct value on an + // ancestor of many leaves would otherwise be copied once per leaf. + budget: usize, } impl<'a> PageTree<'a> { fn new(doc: &'a Document) -> Self { + Self::with_budget(doc, MAX_INHERITED_COPIES) + } + + fn with_budget(doc: &'a Document, budget: usize) -> Self { let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))); let pending = top .into_iter() .map(|r| Children { owner: std::sync::Arc::new(Object::Array(vec![Object::Ref(r)])), next: 0, inherited: Default::default() }) .collect(); - Self { doc, seen: Default::default(), pending } + Self { doc, seen: Default::default(), pending, budget } } } @@ -149,8 +178,8 @@ impl Iterator for PageTree<'_> { let Some(d) = obj.as_dict() else { continue }; let mut inherited = parent.inherited.clone(); for (key, owner) in INHERITABLE.iter().zip(&mut inherited) { - if d.contains(key) { - *owner = Some(obj.clone()); + if let Some(value) = d.get(key) { + *owner = Some((obj.clone(), weight(value, 0))); } } let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); @@ -165,8 +194,15 @@ impl Iterator for PageTree<'_> { let mut dict = d.clone(); for (key, owner) in INHERITABLE.iter().zip(&inherited) { if !dict.contains(key) - && let Some(value) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(key)) + && let Some((owner, weight)) = owner + && let Some(value) = owner.as_dict().and_then(|d| d.get(key)) { + // Past the budget the walk is cut off, like a tree too large to follow. + let Some(left) = self.budget.checked_sub(*weight) else { + self.pending.clear(); + return None; + }; + self.budget = left; dict.set(key.to_vec(), value.clone()); } } @@ -209,6 +245,29 @@ mod tests { Document::open(Arc::new(out)).unwrap() } + #[test] + fn inherited_copies_stop_at_a_budget() { + let mut d = doc(0); + let root = ObjRef { num: 2, generation: 0 }; + let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone(); + let kids: Vec<_> = (0..200).map(|_| Object::Ref(d.add(leaf.clone()))).collect(); + let mut resources = Dict::new(); + resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 8192])); + let resources = Object::Dict(resources); + let each = weight(&resources, 0); + d.update_dict(root, |dict| { + dict.set(b"Kids".to_vec(), Object::Array(kids)); + dict.set(b"Resources".to_vec(), resources); + }) + .unwrap(); + let budget = 1 << 20; + let pages: Vec = PageTree::with_budget(&d, budget).collect(); + let copied: usize = pages.iter().filter_map(|p| p.dict.get(b"Resources")).map(|r| weight(r, 0)).sum(); + assert!(copied <= budget, "{copied} bytes of inherited values copied into {} pages", pages.len()); + assert_eq!(pages.len(), budget / each); + assert_eq!(super::pages(&d).len(), 200); + } + #[test] fn repeated_children_do_not_queue_resource_copies() { let mut d = doc(0); diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml index 46bb3a1e0e..750e78b96d 100644 --- a/crates/ocr/Cargo.toml +++ b/crates/ocr/Cargo.toml @@ -13,5 +13,8 @@ workspace = true [dependencies] pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] } -rten = { version = "0.26.0", default-features = false, features = ["rten_format"] } +rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] } thiserror.workspace = true + +[dev-dependencies] +tempfile = "3.27" diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index affa4ab163..7f58270e1a 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -15,8 +15,8 @@ use std::path::{Path, PathBuf}; pub use pdfcraft_fonts::helvetica_width; /// The model files, as named in ATTRIBUTION.toml. -pub const DETECTION_MODEL: &str = "text-detection.rten"; -pub const RECOGNITION_MODEL: &str = "text-recognition.rten"; +pub const DETECTION_MODEL: &str = "text-detection.onnx"; +pub const RECOGNITION_MODEL: &str = "text-recognition.onnx"; /// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its /// output classes follow this order. The ocrs crate's own copy has an `E` where the training had @@ -48,10 +48,13 @@ pub struct Models { } impl Models { - /// The models in `dir`, if both files are there. + /// A complete model pair in `dir`, preferring current ONNX files over legacy RTEN files. + /// An incomplete update never mixes formats or hides an intact legacy pair. pub fn in_dir(dir: &Path) -> Option { - let m = Models { detection: dir.join(DETECTION_MODEL), recognition: dir.join(RECOGNITION_MODEL) }; - (m.detection.is_file() && m.recognition.is_file()).then_some(m) + [(DETECTION_MODEL, RECOGNITION_MODEL), ("text-detection.rten", "text-recognition.rten")].into_iter().find_map(|(detection, recognition)| { + let m = Models { detection: dir.join(detection), recognition: dir.join(recognition) }; + (m.detection.is_file() && m.recognition.is_file()).then_some(m) + }) } /// Look for the models: `$PDFCRAFT_MODELS`, then where the release packages install them @@ -321,6 +324,63 @@ mod tests { assert_eq!(w.up, [-10.0, 0.0]); } + /// The current ONNX pair wins when an installation also retains both legacy models. + #[test] + fn model_discovery_prefers_the_complete_onnx_pair() { + let dir = tempfile::tempdir().unwrap(); + for file in ["text-detection.onnx", "text-recognition.onnx"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + let expected = Some(Models { detection: dir.path().join("text-detection.onnx"), recognition: dir.path().join("text-recognition.onnx") }); + assert_eq!(Models::in_dir(dir.path()), expected, "a complete ONNX pair is sufficient"); + for file in ["text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), expected, "the complete ONNX pair takes precedence over legacy models"); + } + + /// An interrupted ONNX update still permits an intact legacy pair in the same directory. + #[test] + fn model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete() { + for current in ["text-detection.onnx", "text-recognition.onnx"] { + let dir = tempfile::tempdir().unwrap(); + for file in [current, "text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }), + "{current} alone must not hide a complete legacy pair" + ); + } + } + + /// Detection and recognition must come from one complete format pair, never a mixed update. + #[test] + fn model_discovery_refuses_mixed_format_pairs() { + for pair in [["text-detection.onnx", "text-recognition.rten"], ["text-detection.rten", "text-recognition.onnx"]] { + let dir = tempfile::tempdir().unwrap(); + for file in pair { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), None, "{pair:?} is not a complete model pair"); + } + } + + /// Existing source builds and PDFCRAFT_MODELS overrides retain their complete RTEN pair. + #[test] + fn model_discovery_keeps_legacy_only_installations() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(Models::in_dir(dir.path()), None); + std::fs::write(dir.path().join("text-detection.rten"), b"model").unwrap(); + assert_eq!(Models::in_dir(dir.path()), None, "one legacy model is incomplete"); + std::fs::write(dir.path().join("text-recognition.rten"), b"model").unwrap(); + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }) + ); + } + /// #103: every release package's layout puts the models where the installed app looks. /// Mirrors packaging/{windows/package.ps1,macos/package.sh,linux/package.sh,freebsd/package.sh}. #[test] diff --git a/crates/optimize/Cargo.toml b/crates/optimize/Cargo.toml index 184ee9697b..d1a759d468 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -12,12 +12,16 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +pdfcraft-filters = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-annot = { workspace = true } thiserror.workspace = true # JPEG decoding (zune-jpeg) and encoding, and bicubic resampling; already in the tree. image = { version = "0.25", default-features = false, features = ["jpeg"] } +# The same JPEG decoder, used directly to read samples untransformed (/ColorTransform 0). +zune-jpeg = "0.5" [dev-dependencies] pdfcraft-render = { workspace = true } +proptest.workspace = true diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 32915af6bc..9f96b17218 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -11,28 +11,102 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going ``` - **Presets:** Standard (Reduce File Size's choices: images above 225 ppi to 150 ppi, JPEG - medium) and Mobile (above 144 ppi to 96 ppi, JPEG high), as in Acrobat's Settings menu. + medium) and Mobile (above 144 ppi to 96 ppi, JPEG high), as in Acrobat's Settings menu. In + both, black-and-white images are averaged down to 300 ppi above 450 ppi, and stored as CCITT + Group 4 whenever that is smaller. - **Merge image fragments** (`fragments`, on by default): some PDF printers draw a page picture as thousands of images one pixel row high. Runs of upright 8-bit grey or RGB images in one colour space, sharing their left and right edges and stacked edge to edge (at least four, drawn with nothing else between them), become one lossless image at their finest resolution, which the image settings then resample; the strips are dropped by Clean Up. A real page printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet: - side-by-side tiles, rotated strips, masks. + side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary + policy, so trailing comments, split operands and graphics state keep their meaning after merging. - **Images:** the effective resolution of each image is measured where pages draw it (the CTM - through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be + through form XObjects, up to 12 deep; the smallest over all uses). + Page-local `/UserUnit` scaling is included, so large-format pages are measured in physical inches. + If a use can't be measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. - An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces, - image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT. + An image is replaced only if the result is smaller. Left alone by these colour and grey + settings: CMYK and other colour spaces, image masks, decode arrays, more than 8 bits, JPEG 2000, + JBIG2 and CCITT (black-and-white images, CCITT among them, are below). A JPEG is read with + its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one + stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left + alone, since readers differ on which wins. Samples are read with `/DecodeParms` given by + reference too, the numbers inside included (`Document::with_direct_parms`), here and below: + without them a predictor's tag bytes would shift every row of an image written back. +- **Monochrome images** (`Settings::mono`, `MonoSettings::REDUCE` by default: to 300 ppi above + 450 ppi, CCITT Group 4). Black-and-white images are a 1-bit image with one colour component + (grey, indexed, a separation) or an image mask, stored with no filter, with general-purpose ones + or with CCITT fax after any of those (decoded by `Document::decoded_image_within`, no further + than its rows can need; byte-aligned Group 3 with end-of-line codes but none before the first + row, not as T.4 sends them, is read as PdfCraft draws it, which at `/K 0` may be other rows than + were coded, and an image stored again keeps them). Packed to one bit first, unless a colour-key `/Mask` is in the image's + own values: eight-bit grey of only 0 and 255; eight-bit `DeviceRGB` of only black and white, + which becomes `DeviceGray` (not calibrated RGB or RGB with a profile, nor one drawn or listed + where `/DefaultGray` or `/DefaultRGB` is in force, which would then draw it otherwise, nor one + referred to from anywhere but the page and form resources the optimizer reads, where they may + be: an annotation's appearance or a pattern drawing it, directly or through resources or a form + it shares with a page, or another image's `/Alternates`; nor one whose soft mask has a + `/Matte`); and an indexed image of 2, 4 or 8 bits that uses only palette entries + 0 and 1, which keeps its palette, whatever its two colours. A decode array stays when it means + the same at one bit (grey's, or RGB's if the same for each component, as grey's), and an indexed + image's default one goes, as the default at one bit; otherwise the image is left alone. The grey + or colour settings take a packed image instead when they make it smaller still, its soft mask + counted (resampled with it there, kept as it is here), as they can a dithered scan by + downsampling it to JPEG. + - **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to + `target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a + tie, so a dark line half a new pixel wide stays: black in grey and the painted part of an image + mask (a 0, or a 1 with an inverting decode array), full ink in a separation (a 1, or a 0), and + entry 0 of an indexed image, whatever colour its palette gives it (the palette isn't read yet). + Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, + most of whose new pixels would cover both black and white, so that taking the sample covering + more would turn its tones solid (a 50% screen black, a 25% one white); and not one wider than + a fax row (`pdfcraft_filters::MAX_CCITT_COLUMNS`), as the averaging keeps a counter for each + column. On a 600 ppi text page, Group 4 at 300 ppi is half the size. + - **Compression:** CCITT Group 4 (`pdfcraft_filters::encode_g4`), or 1-bit Flate when that is + smaller, which only a resampled image or one packed from more bits may be: a 1-bit image that + is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; + Group 3, or Group 4 inside another filter such as `ASCII85Decode`, is stored again as Group 4 + alone when that is smaller. Damaged Group 3 with end-of-line codes is stored as the decoder + recovers it, a bad row picked up again at the next code, as it reports no damage there; without + them, damage stops the decoding and the image is left alone. With Retain, a resampled image + keeps its kind of compression (CCITT, or Flate) and nothing else changes; an image of more bits + is then left to the grey or colour settings. Every change is kept only when it is smaller as + stored. + - Apart from the downsampling it is lossless: the samples keep their meaning, so the colour + space, decode array and `/Mask` stay, but for packed RGB, which becomes grey with one decode + pair. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit + art Flate can be smaller. Not yet: inline images, an image's own `/SMask` or `/Mask` + image (only images drawn on a page are visited), and Group 4 for rows wider than 32,767 + samples. + + - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when + smaller. No lossy symbol substitution is used. Export reads embedded images and bounded + shared globals. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded - bytes). Streams with `/DecodeParms`, images and object streams are left alone. + bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed + streams, content and images alike, are decoded and stored with Flate instead (Acrobat's "In + streams that use LZW encoding, use Flate instead"): a predictor carries over to Flate without + `EarlyChange`, filters applied before the LZW one go with it and those after it keep their + bytes. A stream whose LZW data doesn't decode cleanly is left as it is. Not yet: inline images. The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories, then this, then merges identical objects and writes a full, compressed save (`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a -progress bar). JPEG decoding and -encoding and resampling come from the `image` crate. +progress bar). JPEG decoding comes from `zune-jpeg` (the decoder behind `image` and the +renderer), JPEG encoding and resampling from the `image` crate. + +## Target PDF version: partial compatibility + +The target selects the header version, clears the catalog `/Version`, controls object +streams (disabled for PDF 1.4), and checks encryption requirements. It does **not** +detect or convert content newer than the target: optional content, JPEG 2000 images, +16-bit images, OpenType fonts and PDF 2.0 features can remain. A PDF 1.4 header +therefore does not guarantee compatibility with PDF 1.4 readers or validators. +`optimize.compatibility-level` remains partial until content compatibility is checked. diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index b5967eee09..c069de2f95 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -18,7 +18,7 @@ //! drops them. use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample}; use crate::{OptimizeError, Report, Stage}; @@ -29,6 +29,19 @@ pub const MIN_FRAGMENTS: usize = 4; /// Most pixels a merged image may have (memory). const MAX_PIXELS: u64 = 80_000_000; +/// Fragment groups a page's search for runs may read per content operator, and at least. A run +/// that is not one picture is searched again from each of its groups, so without a limit images +/// drawn over one another take time quadratic in their number; past it, the rest of the page is +/// left as it is. +const SCAN_PER_OP: usize = 16; +const MIN_SCAN: usize = 1 << 16; + +#[cfg(test)] +thread_local! { + /// Fragment groups read, for the test that keeps a page's search linear in its content. + static GROUP_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// One fragment: its image, its box in the coordinates the run is drawn in, and its size. struct Fragment { image: ObjRef, @@ -40,6 +53,8 @@ struct Fragment { /// The ops `q`, one or more `cm`, `Do`, `Q` starting at `i`: the index after them, the matrix /// and the name drawn. fn group_at(ops: &[Op], i: usize) -> Option<(usize, Matrix, Vec)> { + #[cfg(test)] + GROUP_READS.with(|c| c.set(c.get().saturating_add(1))); if !ops.get(i)?.is("q") { return None; } @@ -143,12 +158,16 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 // Pixel rows per unit of height: the finest fragment's. let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); let height = ((top - bottom) * density).round(); - if !(height.is_finite() && height >= 1.0) || (width as u64) * (height as u64) > MAX_PIXELS { + if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) { return None; } + // In range and whole, so the cast is exact. let height = height as u32; - let row = width as usize * n; - let mut out = vec![255u8; row * height as usize]; + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return None; + } + let row = (width as usize).checked_mul(n)?; + let mut out = vec![255u8; row.checked_mul(height as usize)?]; for f in run { let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); @@ -157,7 +176,7 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 } let obj = doc.get(f.image); let Object::Stream(s) = &*obj else { return None }; - let px = pixels(s, n)?; + let px = pixels(doc, s, n)?; let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? }; out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?); } @@ -213,7 +232,9 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) -> /// Merge the fragment runs of one page; returns how many fragments were merged. fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result { let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) }; - let parsed = parse(&content_bytes(doc, &contents)); + // A page whose content isn't all known is left alone: rewriting it would drop the rest. + let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) }; + let parsed = parse(&data); if parsed.skipped > 0 { return Ok(0); } @@ -222,7 +243,13 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & let ops = parsed.ops; let mut out: Vec = Vec::with_capacity(ops.len()); let (mut i, mut merged, mut k) = (0, 0, 0); + let budget = ops.len().saturating_mul(SCAN_PER_OP).max(MIN_SCAN); + let mut scanned = 0usize; while i < ops.len() { + if scanned > budget { + out.extend(ops.get(i..).unwrap_or_default().iter().cloned()); + break; + } // The longest run of fragment groups from here with one colour space. let mut run = Vec::new(); let mut kind: Option<(Object, usize)> = None; @@ -241,10 +268,12 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: & run.push(Fragment { image, rect: [e, f, e + a, f + d], width, height }); j = next; } + scanned = scanned.saturating_add(run.len()).saturating_add(1); // Not one picture as a whole: the rows drawn one under the other from the start may be. if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) { run = drawn_in_sequence(&ops, i, &xobjects, doc); j = run_end(&ops, i, run.len()); + scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2); } let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else { // Not a run: keep this op and look again from the next one. @@ -297,3 +326,53 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, pro } Ok(()) } + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + + use super::*; + + #[test] + fn a_merged_height_beyond_u32_is_refused_before_allocating() { + // Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be + // 2^16 × 2^48 pixels, whose product wraps to 0 in u64. + let image = ObjRef { num: 1, generation: 0 }; + let edge = 1.0 - (-48f64).exp2(); + let run = [ + Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 }, + Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 }, + ]; + assert!(merge(&Document::new_empty(), &run, 3).is_none()); + } + + /// Many images drawn on top of each other are not one picture; looking for runs among them reads + /// a bounded number of groups per operator, not the rest of the page from every group. + #[test] + fn rejected_runs_read_a_bounded_number_of_groups() { + let mut doc = Document::new_empty(); + let mut image = Dict::new(); + for (k, v) in + [("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] + { + image.set(k.as_bytes().to_vec(), v); + } + image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0]))); + let groups = 4000; + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(groups).as_bytes()))); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Contents".to_vec(), Object::Ref(content)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + let page = doc.add(Object::Dict(page)); + GROUP_READS.with(|c| c.set(0)); + let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true); + assert_eq!(merged.ok(), Some(0)); + let reads = GROUP_READS.with(|c| c.get()); + assert!(reads <= 40 * 4 * groups, "{reads} group reads"); + } +} diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 8cfa82674a..9338061978 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -2,43 +2,39 @@ use std::collections::{HashMap, HashSet}; -use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_content::{Matrix, Pieces}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; -use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; +use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage}; /// Images with more pixels than this are left alone (memory). const MAX_PIXELS: u64 = 80_000_000; /// The resources of a page, inherited from the page tree if the page has none. pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { + held_page_resources(doc, page).map(|(resources, _)| resources) +} + +/// [`page_resources`], and the object holding them: the page or the page-tree node they are +/// written in (inherited from), or the resources dictionary when it is an object of its own. +fn held_page_resources(doc: &Document, page: ObjRef) -> Option<(Dict, ObjRef)> { let mut node = Some(page); for _ in 0..64 { - let d = doc.get(node?).as_dict()?.clone(); + let n = node?; + let d = doc.get(n).as_dict()?.clone(); if let Some(r) = d.get(b"Resources") { - return doc.resolve(r).as_dict().cloned(); + return Some((doc.resolve(r).as_dict().cloned()?, r.as_ref().unwrap_or(n))); } node = d.reference(b"Parent"); } None } -pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { - match &*doc.resolve(contents) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|c| match &*doc.resolve(c) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b'\n'); - v - } - _ => Vec::new(), - }) - .collect(), - _ => Vec::new(), - } +/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream +/// doesn't decode or the budget runs out: the content is not all known. +pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option> { + let pieces = budget.decode_contents(doc, contents).into_iter().collect::, _>>().ok()?; + Some(Pieces::join(&pieces).data().to_vec()) } /// Forms nested deeper than this aren't followed. @@ -52,23 +48,68 @@ struct Walk<'a> { doc: &'a Document, /// The forms being walked right now, outermost first: a form drawing one of these is a cycle. path: Vec, - /// Forms with their own resources already walked, by the linear part of their matrix. Drawing - /// one again at the same scale (only moved) can't change any image's resolution. - walked: HashSet<(ObjRef, [u64; 4])>, + /// Forms with their own resources already walked, by the linear part of their matrix and + /// whether default colour spaces were in force. Drawing one again at the same scale (only + /// moved) can't change any image's resolution. + walked: HashSet<(ObjRef, [u64; 4], bool)>, /// Forms walked at least once. seen: HashSet, rewalks: usize, + /// Decoded bytes left for the page being walked. + budget: DecodeBudget, /// False once a use may have been missed (depth or work limit). complete: bool, out: HashMap, + /// Images drawn or listed at least once where `/DefaultGray` or `/DefaultRGB` is in force, by + /// object number: a reference with another generation reaches the same image. + defaults: HashSet, + /// The references the walk followed to reach its images, and the pages and page-tree nodes + /// whose resources it read. + read: Read, +} + +/// Where a walk read a reference: under a name in an `/XObject` dictionary (an image or a form), +/// as resources' `/XObject` dictionary, or as the `/Resources` of a page, node or form. +#[derive(Clone, PartialEq, Eq, Hash)] +enum Entry { + Name(Vec), + XObject, + Resources, +} + +/// The references a walk followed, by object number: (the object referred to, the object holding +/// the reference, where in it), and the pages and page-tree nodes whose resources it read. +#[derive(Default)] +struct Read { + entries: HashSet<(u32, u32, Entry)>, + roots: HashSet, } impl Walk<'_> { /// Walk one content stream, recording each image's displayed resolution (pixels per inch, the - /// smaller of the two axes) and descending into form XObjects. - fn content(&mut self, data: &[u8], resources: &Dict, ctm: Matrix) { + /// smaller of the two axes) and descending into form XObjects. `holder` is the object holding + /// `resources`. `defaults`: an enclosing stream's resources remap a device colour space. + fn content(&mut self, data: &[u8], resources: &Dict, holder: ObjRef, ctm: Matrix, defaults: bool) { let doc = self.doc; + let defaults = defaults || remaps_device_spaces(doc, resources); let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let xholder = match resources.get(b"XObject").and_then(Object::as_ref) { + Some(x) => { + self.read.entries.insert((x.num, holder.num, Entry::XObject)); + x + } + None => holder, + }; + // Every entry counts as read, drawn here or not: content using these resources (a Type3 + // glyph without its own, say) draws it under the same defaults. + for (name, v) in xobjects.iter() { + if let Some(r) = v.as_ref() { + self.read.entries.insert((r.num, xholder.num, Entry::Name(name.to_vec()))); + if defaults { + self.defaults.insert(r.num); + } + } + } let mut stack = vec![ctm]; for op in pdfcraft_content::parse(data).ops { let top = stack.last().copied().unwrap_or(ctm); @@ -99,8 +140,11 @@ impl Walk<'_> { let e = self.out.entry(r).or_insert(f64::INFINITY); *e = e.min(ppi); } + if defaults { + self.defaults.insert(r.num); + } } - Some(b"Form") => self.form(r, s, resources, top), + Some(b"Form") => self.form(r, s, (resources, holder), top, defaults), _ => {} } } @@ -109,8 +153,37 @@ impl Walk<'_> { } } - /// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`. - fn form(&mut self, r: ObjRef, s: &Stream, resources: &Dict, ctm: Matrix) { + /// Read the resources of page `p` and of each page-tree node above it, each of them a root: a + /// node's resources are its inheriting pages' (every page is walked), or no page's. + fn resources_above(&mut self, p: ObjRef) { + let doc = self.doc; + let mut node = Some(p); + for _ in 0..64 { + let Some(n) = node else { return }; + // A node met before had its own nodes above it read then. + if !self.read.roots.insert(n.num) { + return; + } + let Some(d) = doc.get(n).as_dict().cloned() else { return }; + if let Some(r) = d.get(b"Resources") + && let Some(resources) = doc.resolve(r).as_dict().cloned() + { + let holder = match r.as_ref() { + Some(res) => { + self.read.entries.insert((res.num, n.num, Entry::Resources)); + res + } + None => n, + }; + self.content(&[], &resources, holder, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false); + } + node = d.reference(b"Parent"); + } + } + + /// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`, with + /// the object holding them. + fn form(&mut self, r: ObjRef, s: &Stream, (resources, holder): (&Dict, ObjRef), ctm: Matrix, defaults: bool) { if self.path.contains(&r) { return; } @@ -125,7 +198,7 @@ impl Walk<'_> { // A form without resources uses its caller's, so the same form can draw other images. if own.is_some() { let [a, b, c, d, ..] = inner.0; - if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()])) { + if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()], defaults)) { return; } } @@ -136,28 +209,174 @@ impl Walk<'_> { } self.rewalks += 1; } + if !self.budget.enter_form() { + self.complete = false; + return; + } + let Ok(data) = self.budget.decode(s) else { + self.complete = false; + return; + }; self.path.push(r); - self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner); + match &own { + Some(own) => { + let own_holder = match s.dict.get(b"Resources").and_then(Object::as_ref) { + Some(res) => { + self.read.entries.insert((res.num, r.num, Entry::Resources)); + res + } + None => r, + }; + self.content(&data, own, own_holder, inner, defaults); + } + None => self.content(&data, resources, holder, inner, defaults), + } self.path.pop(); } } +/// Whether `resources` remap a device colour space an image may be moved between, so that the +/// same samples would be drawn otherwise: `/DefaultGray` or `/DefaultRGB` (ISO 32000-2 §8.6.5.6). +fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool { + let spaces = resources.get(b"ColorSpace").map(|c| doc.resolve(c)); + spaces.as_deref().and_then(Object::as_dict).is_some_and(|c| c.contains(b"DefaultGray") || c.contains(b"DefaultRGB")) +} + /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. /// -/// If forms nest too deeply or are drawn too many times to measure every use, every image is -/// given `INFINITY` (never resampled), since its largest use may be among those missed. +/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within +/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be +/// among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { - let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() }; + walk_pages(doc, pages).0 +} + +/// [`effective_resolutions`], the images drawn or listed where `/DefaultGray` or `/DefaultRGB` is +/// in force, by object number (all of them when a use may have been missed), and the references +/// followed to reach them. +fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap, HashSet, Read) { + let mut walk = Walk { + doc, + path: Vec::new(), + walked: HashSet::new(), + seen: HashSet::new(), + rewalks: 0, + budget: DecodeBudget::page(), + complete: true, + out: HashMap::new(), + defaults: HashSet::new(), + read: Read::default(), + }; for p in pages { - let Some(d) = doc.get(*p).as_dict().cloned() else { continue }; - let Some(contents) = d.get(b"Contents") else { continue }; - let res = page_resources(doc, *p).unwrap_or_default(); - walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + let Some(dict) = doc.get(*p).as_dict().cloned() else { continue }; + let page = pdfcraft_model::Page { obj: *p, dict }; + walk.resources_above(*p); + let Some(contents) = page.dict.get(b"Contents") else { continue }; + let (res, holder) = held_page_resources(doc, *p).unwrap_or_else(|| (Dict::default(), *p)); + walk.budget = DecodeBudget::page(); + let Some(data) = content_bytes(doc, contents, &mut walk.budget) else { + walk.complete = false; + continue; + }; + // Content coordinates are in page units; image resolution is in physical inches. + let unit = page.user_unit(doc); + walk.content(&data, &res, holder, Matrix([unit, 0.0, 0.0, unit, 0.0, 0.0]), false); } if !walk.complete { walk.out.values_mut().for_each(|v| *v = f64::INFINITY); + walk.defaults.extend(walk.out.keys().map(|r| r.num)); } - walk.out + (walk.out, walk.defaults, walk.read) +} + +/// Whether image `r` is one [`one_bit`] may make grey: eight-bit `DeviceRGB`, not in an image codec. +fn device_rgb(doc: &Document, r: ObjRef) -> bool { + let Object::Stream(s) = &*doc.get(r) else { return false }; + let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); + let codec = + s.filters().iter().any(|(f, _)| matches!(f, pdfcraft_filters::Filter::Dct | pdfcraft_filters::Filter::Jpx | pdfcraft_filters::Filter::Jbig2)); + s.dict.int(b"BitsPerComponent") == Some(8) + && !codec + && cs.as_deref().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceRGB" | b"RGB")) +} + +/// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary +/// too, but not those in objects referred to. +fn each_ref(o: &Object, mut f: impl FnMut(ObjRef)) { + // Direct objects nest finitely (the parser builds trees), so this ends. + let mut stack = vec![o]; + while let Some(o) = stack.pop() { + match o { + Object::Ref(r) => f(*r), + Object::Dict(d) => stack.extend(d.iter().map(|(_, v)| v)), + Object::Array(a) => stack.extend(a), + Object::Stream(s) => stack.extend(s.dict.iter().map(|(_, v)| v)), + _ => {} + } + } +} + +/// The images among `images` (by object number) that may be drawn beyond what the walk read: +/// some object the document uses refers to the image, or to an object on the way to it from a +/// page (an `/XObject` dictionary, resources, a form), other than as the page and form resources +/// the walk read do. So an annotation's appearance, a pattern, a Type3 glyph or a soft mask's +/// group drawing it, directly or through resources or a form it shares with a page, counts, as +/// other default colour spaces may be in force there, and so does any other reference (another +/// image's `/Alternates`, say). Objects nothing reaches from the trailer don't count. +fn used_elsewhere(doc: &Document, images: &HashSet, read: &Read) -> HashSet { + if images.is_empty() { + return HashSet::new(); + } + let targets: HashSet = read.entries.iter().map(|(target, ..)| *target).collect(); + // References to each object the walk followed, by the object holding them. + let mut held: HashMap> = HashMap::new(); + let mut queue: Vec = Vec::new(); + let mut visited = HashSet::new(); + for (_, v) in doc.trailer().iter() { + each_ref(v, |r| queue.push(r.num)); + } + while let Some(num) = queue.pop() { + if !visited.insert(num) { + continue; + } + let obj = doc.get(ObjRef::new(num, doc.generation(num))); + each_ref(&obj, |r| { + if targets.contains(&r.num) { + *held.entry(r.num).or_default().entry(num).or_default() += 1; + } + if !visited.contains(&r.num) { + queue.push(r.num); + } + }); + } + let mut seen: HashMap<(u32, u32), usize> = HashMap::new(); + for (target, holder, _) in &read.entries { + *seen.entry((*target, *holder)).or_default() += 1; + } + // Covered: every reference to it is one the walk followed, held by a page or node, or by an + // object covered in turn. Everything the walk reached starts covered, and whatever has a + // reference it didn't follow, or is held by an object taken out, is taken out, until nothing + // more is. Content beyond what the walk read has to refer to an object on its way to the + // image, which takes out everything after it; a cycle the walk read whole stays covered. + let mut holds: HashMap> = HashMap::new(); + for (target, holders) in &held { + for h in holders.keys() { + holds.entry(*h).or_default().push(*target); + } + } + let mut covered: HashSet = held.keys().copied().collect(); + let passes = |t: u32, covered: &HashSet| { + held.get(&t) + .is_some_and(|holders| holders.iter().all(|(h, n)| seen.get(&(t, *h)) == Some(n) && (read.roots.contains(h) || covered.contains(h)))) + }; + let mut check: Vec = covered.iter().copied().collect(); + while let Some(t) = check.pop() { + if covered.contains(&t) && !passes(t, &covered) { + covered.remove(&t); + check.extend(holds.get(&t).into_iter().flatten().copied()); + } + } + images.iter().copied().filter(|i| !covered.contains(i)).collect() } /// Colour components of a colour space PdfCraft resamples: gray (1) or RGB (3). @@ -228,18 +447,174 @@ fn is_dct(s: &Stream) -> Option { } } -/// 8-bit samples of an image (`n` components), decoded. -pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { - let (w, h) = (s.dict.int(b"Width")? as usize, s.dict.int(b"Height")? as usize); - if is_dct(s)? { - let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?; - // Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them). - let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() }; - (v.len() == w * h * n).then_some(v) - } else { - let v = s.decoded().ok()?; - (v.len() >= w * h * n).then(|| v[..w * h * n].to_vec()) +#[cfg(test)] +thread_local! { + /// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding. + static DECODED_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Counts `n` decoded pixels (tests only). +#[inline] +fn count_decoded(_n: u64) { + #[cfg(test)] + DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n))); +} + +/// 8-bit samples of an image (`n` components), decoded, with `/DecodeParms` given by reference +/// too ([`Document::with_direct_parms`]): read without them, a predictor's tag bytes would shift +/// every row, and the image would be written back wrong. `None` for a JPEG whose colour transform +/// readers may disagree about ([`ycc_encoded`]), and for data that decodes past what the samples +/// can need ([`allowance`]). +pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { + let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; } + let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?; + if is_dct(s)? { + // The colour transform matters to three components only (§7.4.8). CMYK and YCCK JPEGs + // never get here: the colour space check excludes them. The JPEG's own size must be the + // dictionary's: `jpeg_samples` refuses another from its header, before any pixels. + let ycc = n == 3 && ycc_encoded(doc, s)?; + let v = jpeg_samples(&s.raw, usize::try_from(w).ok()?, usize::try_from(h).ok()?, n, ycc)?; + count_decoded(u64::from(w) * u64::from(h)); + (v.len() == bytes).then_some(v) + } else { + let v = doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?; + Some(v.get(..bytes)?.to_vec()) + } +} + +/// How far an image of `bytes` samples in `rows` rows is decoded: a filter stage may produce up +/// to three bytes for each of them and of a PNG predictor's bytes a row (ASCIIHex text under +/// Flate, its two digits a byte broken into lines or spaced). More can only be a decompression +/// bomb, and the image is left alone. +fn allowance(bytes: usize, rows: u32) -> Option { + bytes.checked_add(usize::try_from(rows).ok()?)?.checked_mul(3)?.checked_add(4096) +} + +/// Whether the samples of a three-component JPEG are YCbCr, to be converted to RGB (ISO 32000-2 +/// §7.4.8). An Adobe APP14 marker's transform flag says so, overriding the filter's +/// /ColorTransform; without a marker /ColorTransform does, 1 (YCbCr) by default. +/// +/// `None`, so the image is left as it is, when readers may disagree: the marker and +/// /ColorTransform contradict each other (PdfCraft's renderer lets /ColorTransform 0 win where +/// the standard lets the marker win), either holds a value other than 0 or 1, or the parameters +/// or the JPEG's segments can't be read. +fn ycc_encoded(doc: &Document, s: &Stream) -> Option { + let entry = color_transform(doc, s)?; + match adobe_transform(&s.raw)? { + Some(t @ (0 | 1)) => entry.is_none_or(|c| c == i64::from(t)).then_some(t == 1), + Some(_) => None, + None => match entry { + None | Some(1) => Some(true), + Some(0) => Some(false), + Some(_) => None, + }, + } +} + +/// The /ColorTransform of a JPEG stream's filter parameters: `Some(None)` without one. `None` +/// if it isn't an integer, or the parameters aren't a dictionary paired with the filter as +/// §7.4 pairs them (a dictionary for a filter name, a one-element array for a one-filter array). +fn color_transform(doc: &Document, s: &Stream) -> Option> { + let Some(parms) = s.dict.get(b"DecodeParms") else { return Some(None) }; + let parms = doc.resolve(parms); + let parms = match (s.dict.get(b"Filter"), &*parms) { + (Some(Object::Array(_)), Object::Array(a)) if a.len() == 1 => doc.resolve(a.first()?), + (Some(Object::Name(_)), _) | (_, Object::Null) => parms.clone(), + _ => return None, + }; + match &*parms { + Object::Null => Some(None), + Object::Dict(d) => match d.get(b"ColorTransform").map(|c| doc.resolve(c)).as_deref() { + None => Some(None), + Some(Object::Int(c)) => Some(Some(*c)), + Some(_) => None, + }, + _ => None, + } +} + +/// Markers and fill bytes [`adobe_transform`] reads before the first scan, at most. Encoders +/// write a few dozen segments. +const MAX_HEADER_STEPS: usize = 4096; + +/// The transform flag of a JPEG's Adobe APP14 segment (0: samples stored as they are, +/// 1: YCbCr, 2: YCCK), the last one if there are several, as decoders read them; `Some(None)` +/// without one. `None` if the segments before the first scan can't be read, which includes a +/// stray byte between segments and a header of more than [`MAX_HEADER_STEPS`] steps. +/// +/// Each step reads one marker or fill byte and jumps over its segment, so the work is bounded +/// by the step count, not by the size of the stream. That matters: Optimize asks again for every +/// group of fragments it tries, and the same image can be drawn many times. +fn adobe_transform(data: &[u8]) -> Option> { + if data.get(..2)? != [0xFF, 0xD8] { + return None; + } + let mut flag = None; + let mut i = 2usize; + // Reading past the end returns. + for _ in 0..MAX_HEADER_STEPS { + // Decoders skip stray bytes between segments, but a stream can hold megabytes of them + // and each would be a step: the header counts as unreadable and the image is left as + // it is. + if *data.get(i)? != 0xFF { + return None; + } + let marker = *data.get(i.checked_add(1)?)?; + match marker { + // A fill byte before the marker. + 0xFF => i = i.checked_add(1)?, + // The first scan (or the end): no header segments after it. + 0xDA | 0xD9 => return Some(flag), + // Markers without a segment: TEM, RSTn, SOI. + 0x01 | 0xD0..=0xD8 => i = i.checked_add(2)?, + _ => { + let len = usize::from(u16::from_be_bytes([*data.get(i.checked_add(2)?)?, *data.get(i.checked_add(3)?)?])); + // The length counts its own two bytes: anything shorter is malformed. + let end = i.checked_add(2)?.checked_add(len)?; + let segment = data.get(i.checked_add(4)?..end)?; + if marker == 0xEE + && let Some(rest) = segment.strip_prefix(b"Adobe") + { + // Version, flags0 and flags1 (two bytes each), then the transform flag. + flag = Some(*rest.get(6)?); + } + i = end; + } + } + } + None +} + +/// 8-bit samples of a JPEG `w` × `h` with `n` components (grey or RGB): YCbCr converted to RGB +/// if `ycc`, otherwise the samples as stored. `None` if the JPEG doesn't match (size or number +/// of components), is too large, or its decoder reads the samples otherwise (for example +/// components named R, G and B, which decoders take as stored, while `ycc` says YCbCr). +fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option> { + use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + if (w.checked_mul(h)? as u64) > MAX_PIXELS { + return None; + } + let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); + dec.decode_headers().ok()?; + if dec.dimensions()? != (w, h) || usize::from(dec.info()?.components) != n { + return None; + } + let out = match (n, ycc, dec.input_colorspace()?) { + (1, _, ColorSpace::Luma) => ColorSpace::Luma, + (3, true, ColorSpace::YCbCr) => ColorSpace::RGB, + // As stored: no conversion either way. The decoder reports an Adobe marker's 0 as CMYK + // until it decodes, and then reads three such components as RGB. + (3, false, ColorSpace::YCbCr) => ColorSpace::YCbCr, + (3, false, ColorSpace::RGB | ColorSpace::CMYK) => ColorSpace::RGB, + _ => return None, + }; + dec.set_options(options.jpeg_set_out_colorspace(out)); + let px = dec.decode().ok()?; + (px.len() == w.checked_mul(h)?.checked_mul(n)?).then_some(px) } pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option> { @@ -258,11 +633,17 @@ fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { Some(out) } -/// Whether [`pixels`] reads `s` as it is drawn: a codec it decodes and no decode array that -/// changes anything. `None` when the colour space isn't grey or RGB. +/// Whether [`pixels`] reads `s` as it is drawn: a codec it decodes, no decode array that +/// changes anything, and for an RGB JPEG a colour transform readers agree on. `None` when the +/// colour space isn't grey or RGB. pub(crate) fn decodable(doc: &Document, s: &Stream) -> Option { let n = components(doc, s.dict.get(b"ColorSpace")?)?; - Some(is_dct(s).is_some() && s.dict.get(b"Decode").is_none_or(|d| is_identity_decode(doc, d, n))) + let dct = is_dct(s); + Some( + dct.is_some() + && s.dict.get(b"Decode").is_none_or(|d| is_identity_decode(doc, d, n)) + && (n != 3 || dct == Some(false) || ycc_encoded(doc, s).is_some()), + ) } /// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes @@ -274,7 +655,9 @@ fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { } /// A new image stream from 8-bit samples: the original dictionary with the new size and filter. -/// A decode array is dropped: only images without one, or with the default one, get here. +/// A decode array is dropped: only images without one, or with the default one, get here. So are +/// the filter parameters: the samples are decoded, and a JPEG written here is plain YCbCr with no +/// Adobe marker, which is what a missing /ColorTransform means. fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Compression, was_dct: bool) -> Option { let mut d = old.clone(); for k in [&b"Filter"[..], b"DecodeParms", b"Decode", b"Length", b"DL"] { @@ -316,7 +699,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us return None; } let was_dct = is_dct(s)?; - let (w, h) = (d.int(b"Width")? as u32, d.int(b"Height")? as u32); + let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; } @@ -335,7 +718,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us } else { (w, h) }; - let px = pixels(s, n)?; + let px = pixels(doc, s, n)?; let px = if (nw, nh) != (w, h) { resample(&px, n, w, h, nw, nh)? } else { px }; let new = image_stream(d, &px, n, nw, nh, settings.compression, was_dct)?; // A soft mask must keep the image's dimensions: resample it with the image (lossless). @@ -347,8 +730,8 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us if m.dict.int(b"BitsPerComponent") != Some(8) || m.dict.contains(b"Matte") || m.dict.contains(b"Decode") { return None; } - let (mw, mh) = (m.dict.int(b"Width")? as u32, m.dict.int(b"Height")? as u32); - let mpx = pixels(m, 1)?; + let (mw, mh) = (u32::try_from(m.dict.int(b"Width")?).ok()?, u32::try_from(m.dict.int(b"Height")?).ok()?); + let mpx = pixels(doc, m, 1)?; let mpx = resample(&mpx, 1, mw, mh, nw, nh)?; smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?)); } @@ -362,6 +745,354 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us (after < before).then_some((new, smask)) } +/// Whether colour space `cs` has one colour component, so a 1-bit image in it has one bit per +/// pixel: grey, a separation, an indexed space or a DeviceN of one colourant. Of names, only +/// DeviceGray counts, with `/G`, its abbreviation, which readers take as grey too: another name +/// is no colour space on its own (`/Indexed` and `/CalGray` need their parameters), so it can only +/// name a resource, which might be RGB. +fn one_component(doc: &Document, cs: &Object) -> bool { + let cs = doc.resolve(cs); + match &*cs { + Object::Name(n) => matches!(n.as_slice(), b"DeviceGray" | b"G"), + Object::Array(a) => match a.first().and_then(Object::as_name) { + Some(b"CalGray" | b"Separation" | b"Indexed" | b"I") => true, + Some(b"ICCBased") => a.get(1).is_some_and(|icc| matches!(&*doc.resolve(icc), Object::Stream(s) if s.dict.int(b"N") == Some(1))), + Some(b"DeviceN") => a.get(1).is_some_and(|names| doc.resolve(names).as_array().is_some_and(|n| n.len() == 1)), + _ => false, + }, + _ => false, + } +} + +/// A pixel's samples as a 1 or a 0, or `None` for a colour that is neither. +type Pixel = fn(&[u8]) -> Option; + +/// The samples of a black-and-white image, as rows of 1-bit samples each starting on a new byte, +/// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit +/// image. These count: +/// - one bit per pixel in one colour component, or an image mask, stored with no filter, with +/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image_within`], no further +/// than its rows can need); +/// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values): +/// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array +/// maps 0 and 255 at eight bits as it maps 0 and 1 at one bit, so it stays; +/// - eight-bit `DeviceRGB` whose every pixel is black or white, which becomes `DeviceGray`. A +/// decode array the same for all three components becomes that one pair; another stops it. +/// Calibrated RGB and RGB with a profile are left out, as the profile could make them other +/// than black and white; so is one drawn where `/DefaultGray` or `/DefaultRGB` is or may be in +/// force (`defaults`), which would draw it otherwise as grey, and one whose soft mask has a +/// `/Matte` of three components; +/// - an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1, which stay 0 and +/// 1 at one bit, whatever their colours, so the colour space stays. A decode array other than +/// the default stops it: in index units it would mean something else at one bit. +fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Vec, u32, u32, Option)> { + let d = &s.dict; + // Through u32 first, as everywhere here, so the product can't overflow. + let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let (wu, hu) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + let stride = wu.div_ceil(8); + let bits = d.int(b"BitsPerComponent"); + let mask = matches!(d.get(b"ImageMask"), Some(Object::Bool(true))); + if (mask && bits.is_none_or(|b| b == 1)) || (bits == Some(1) && d.get(b"ColorSpace").is_some_and(|cs| one_component(doc, cs))) { + // No further than its rows can need: a fax image's code (the filters before its decoder + // produce it) takes at most about a byte a pixel and each row's end, as redaction allows. + let len = stride.checked_mul(hu)?; + let max = match s.filters().last() { + Some((pdfcraft_filters::Filter::CcittFax, _)) => wu.checked_mul(hu)?.checked_add(hu.checked_mul(4)?)?.checked_add(4096)?, + _ => allowance(len, h)?, + }; + let mut data = doc.decoded_image_within(s, max).ok()?; + if data.len() < len { + return None; + } + data.truncate(len); + return Some((data, w, h, None)); + } + let colour_key = d.get(b"Mask").is_some_and(|m| doc.resolve(m).as_array().is_some()); + if !pack || mask || colour_key { + return None; + } + let cs = d.get(b"ColorSpace")?; + // The decode array's numbers, each given directly or by reference, as integers or reals; `None` + // when it has something else. + let decode = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { + None => None, + Some(Object::Array(v)) => Some(v.iter().map(|e| doc.resolve(e).as_f64()).collect::>>()?), + Some(_) => return None, + }; + let mut packed = d.clone(); + packed.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + // Components a pixel, bits a component, and a pixel's samples as 1 or 0. + let (n, bpc, white): (usize, usize, Pixel) = match &*doc.resolve(cs) { + Object::Array(a) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?; + match decode.as_deref() { + None => {} + Some([d0, d1]) if *d0 == 0.0 && *d1 == f64::from((1u32 << bpc) - 1) => { + packed.remove(b"Decode"); + } + Some(_) => return None, + } + (1, usize::try_from(bpc).ok()?, |v| match v { + [0] => Some(false), + [1] => Some(true), + _ => None, + }) + } + Object::Name(name) if matches!(name.as_slice(), b"DeviceRGB" | b"RGB") && bits == Some(8) => { + let matte = |m: &Object| matches!(&*doc.resolve(m), Object::Stream(m) if m.dict.contains(b"Matte")); + if defaults || d.get(b"SMask").is_some_and(matte) { + return None; + } + match decode.as_deref() { + None => {} + Some(v @ [d0, d1, ..]) if v.len() == 6 && v.chunks(2).all(|pair| pair == [*d0, *d1]) => { + let number = |n: f64| if n.fract() == 0.0 { Object::Int(n as i64) } else { Object::Real(n) }; + packed.set(b"Decode".to_vec(), Object::Array(vec![number(*d0), number(*d1)])); + } + Some(_) => return None, + } + packed.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + (3, 8, |v| match v { + [0, 0, 0] => Some(false), + [255, 255, 255] => Some(true), + _ => None, + }) + } + _ if bits == Some(8) && one_component(doc, cs) => (1, 8, |v| match v { + [0] => Some(false), + [255] => Some(true), + _ => None, + }), + _ => return None, + }; + let row_len = wu.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8); + // Through its parameters given by reference too: without a PNG predictor's tag bytes taken + // off, rows of 0 and 255 (or palette entries 0 and 1) would still pass below, each shifted. + let samples = doc.with_direct_parms(s).decoded_within(allowance(row_len.checked_mul(hu)?, h)?).ok()?; + Some((packed_rows(&samples, row_len, wu, hu, (n, bpc, white))?, w, h, Some(packed))) +} + +/// `h` rows of `w` pixels, each row `row_len` bytes of `n` samples a pixel at `bpc` bits (8, or +/// a divisor of 8 with `n` 1), as rows of 1-bit samples each starting on a new byte: 1 where +/// `white` says the pixel is white. `None` when a pixel is neither, `samples` is short, or the +/// rows have another shape: no pixels in a row, other sample sizes, or too few bytes for their +/// pixels. +fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, white): (usize, usize, Pixel)) -> Option> { + let stride = w.div_ceil(8); + let shape = (n > 0 && bpc == 8) || (n == 1 && matches!(bpc, 1 | 2 | 4)); + if stride == 0 || !shape || row_len < w.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8) { + return None; + } + // From here no row is empty and x·n·bpc stays within one, and the output is no larger than + // the samples it is read from. + let samples = samples.get(..row_len.checked_mul(h)?)?; + let mut out = vec![0u8; stride.checked_mul(h)?]; + for (row, out_row) in samples.chunks(row_len).zip(out.chunks_mut(stride)) { + for x in 0..w { + let on = if bpc == 8 { + white(row.get(x * n..x * n + n)?)? + } else { + // Samples narrower than a byte, the first in its high bits; bpc divides 8. + let at = x * bpc; + white(&[(row.get(at / 8)? >> (8 - bpc - at % 8)) & ((1u8 << bpc) - 1)])? + }; + if on { + *out_row.get_mut(x / 8)? |= 0x80 >> (x % 8); + } + } + } + Some(out) +} + +/// Resolve polarity only for explicitly understood samples. Indexed palettes and custom +/// colour spaces stay at their original resolution rather than guessing which entry is dark. +fn dark_is_one(doc: &Document, d: &Dict) -> Option { + let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()).filter(|v| v.is_finite()); + let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { + Some(Object::Array(a)) if a.len() == 2 => number(a.first()).zip(number(a.get(1)))?, + None => (0.0, 1.0), + _ => return None, + }; + if d0 == d1 { + return None; + } + if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { + return Some(d1 < d0); + } + match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() { + Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0), + _ => None, + } +} + +/// `w` × `h` 1-bit rows (each starting on a new byte) averaged down to `nw` × `nh`, no larger: +/// each new pixel takes the sample that covers more of its area, and the dark one when the two tie +/// (1 if `dark_is_one`, see [`dark_is_one`]), so a dark line half a new pixel wide stays. `None` +/// when the rows are short or the new size isn't smaller in both directions or empty. Also says +/// how many new pixels cover both samples. +#[cfg(test)] +pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option<(Vec, u64)> { + downsample_bits_checked(data, w, h, nw, nh, dark_is_one).map(|(out, mixed, _)| (out, mixed)) +} + +fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option<(Vec, u64, bool)> { + if nw == 0 || nh == 0 || nw > w || nh > h { + return None; + } + let (stride, nstride) = (usize::try_from(w).ok()?.div_ceil(8), usize::try_from(nw).ok()?.div_ceil(8)); + let data = data.get(..stride.checked_mul(usize::try_from(h).ok()?)?)?; + let mut out = vec![0u8; nstride.checked_mul(usize::try_from(nh).ok()?)?]; + // In units of 1/nw of a source column, source column x spans [x·nw, (x+1)·nw) and new column i + // spans [i·w, (i+1)·w); rows likewise with h and nh. A new pixel's area is then w·h. + let (w, h, nw, nh) = (u64::from(w), u64::from(h), u64::from(nw), u64::from(nh)); + let overlap = |a0: u64, a1: u64, b0: u64, b1: u64| a1.min(b1).saturating_sub(a0.max(b0)); + // Per source column, the area its 1 samples cover in the new row. + let mut ones = vec![0u64; usize::try_from(w).ok()?]; + let mut mixed = 0u64; + let mut previous_mixed = vec![false; usize::try_from(nw).ok()?]; + let mut local_tone = false; + for j in 0..nh { + let mut left_mixed = false; + let mut above_left_mixed = false; + ones.fill(0); + let (y0, y1) = (j * h, (j + 1) * h); + for y in y0 / nh..y1.div_ceil(nh).min(h) { + let weight = overlap(y0, y1, y * nh, (y + 1) * nh); + let row = data.get(usize::try_from(y).ok()? * stride..)?; + for (x, sum) in ones.iter_mut().enumerate() { + if row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0) { + *sum += weight; + } + } + } + let out_row = out.get_mut(usize::try_from(j).ok()? * nstride..)?; + for i in 0..nw { + let (x0, x1) = (i * w, (i + 1) * w); + let covered: u64 = (x0 / nw..x1.div_ceil(nw).min(w)) + .map(|x| overlap(x0, x1, x * nw, (x + 1) * nw) * usize::try_from(x).ok().and_then(|x| ones.get(x)).copied().unwrap_or(0)) + .sum(); + // Against what the 0 samples cover: covered is at most w·h, which can't overflow + // (w·h < 2^64 for u32 sizes). + let zeros = w * h - covered; + let is_mixed = covered > 0 && zeros > 0; + mixed += u64::from(is_mixed); + let column = usize::try_from(i).ok()?; + let above = *previous_mixed.get(column)?; + local_tone |= is_mixed && left_mixed && above && above_left_mixed; + *previous_mixed.get_mut(column)? = is_mixed; + left_mixed = is_mixed; + above_left_mixed = above; + if covered > zeros || (dark_is_one && covered == zeros) { + let i = usize::try_from(i).ok()?; + *out_row.get_mut(i / 8)? |= 0x80 >> (i % 8); + } + } + } + Some((out, mixed, local_tone)) +} + +/// A black-and-white image (see [`one_bit`]) resampled and stored again per `settings`, if that is +/// smaller as stored; `ppi` is its effective resolution, and `defaults` says it is drawn where +/// `/DefaultGray` or `/DefaultRGB` is or may be in force. Also says whether it was resampled. +/// +/// - **Resampled** ([`downsample_bits_checked`]) when its resolution is above `above_ppi`, to `target_ppi`, +/// unless it has a soft mask, whose own size would then have to follow, or is a halftone or a +/// dither: most of its new pixels would cover both black and white, and taking the sample that +/// covers more would turn its tones solid (a 50% screen black, a 25% one white). +/// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only +/// a resampled image or one packed from more bits may be: a 1-bit image that is only stored again +/// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored +/// again like any other. With `Retain`, a resampled image keeps its kind of compression +/// (CCITT, or Flate), and nothing else changes: an image of more bits stays as it is, for the +/// grey or colour settings. +/// +/// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for +/// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default +/// decode array, which goes as the default at one bit. +fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings) -> Option<(Stream, bool)> { + let d = &s.dict; + let downsample = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && !d.contains(b"SMask"); + let retain = settings.compression == MonoCompression::Retain; + let fax = doc.fax_params(s); + // Its kind of compression, which Retain keeps, behind any other filters. + let ccitt = fax.is_some(); + // Group 4 alone is only worth decoding to resample it. Group 3, or Group 4 behind + // ASCII85Decode, say, can be smaller stored again as Group 4 alone. + let g4_alone = fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1; + if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !downsample { + return None; + } + let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?; + let packed_from_more = packed.is_some(); + let k = settings.target_ppi / ppi; + // `downsample_bits` keeps a counter for each column: no more columns than a fax row may have, + // so a hostile strip of 80 million can't make it 640 MB. + let narrow = w <= pdfcraft_filters::MAX_CCITT_COLUMNS; + // At most `w` × `h`, as k < 1: no overflow. + let (nw, nh) = if downsample && narrow && k < 1.0 { + (((f64::from(w) * k).round() as u32).max(1), ((f64::from(h) * k).round() as u32).max(1)) + } else { + (w, h) + }; + let polarity = if defaults { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) }; + let averaged = if (nw, nh) != (w, h) && polarity.is_some() { + // The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair. + let (out, mixed, local_tone) = downsample_bits_checked(&data, w, h, nw, nh, polarity?)?; + // A halftone or dither: most new pixels cover both samples. Text covers both only along + // its edges. + // A dense 2x2 patch of mixed output samples can encode a local photo even when + // white margins dominate the page. Conservatively retain the original image. + (!local_tone && mixed.saturating_mul(2) <= u64::from(nw) * u64::from(nh)).then_some(out) + } else { + None + }; + let resampled = averaged.is_some(); + let (data, nw, nh) = match averaged { + Some(out) => (out, nw, nh), + None => (data, w, h), + }; + if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !resampled { + return None; + } + let mut dict = packed.unwrap_or_else(|| d.clone()); + for key in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { + dict.remove(key); + } + dict.set(b"Width".to_vec(), Object::Int(i64::from(nw))); + dict.set(b"Height".to_vec(), Object::Int(i64::from(nh))); + let g4 = if settings.compression == MonoCompression::CcittG4 || (retain && ccitt) { + pdfcraft_filters::encode_g4(&data, nw, nh).ok().map(|coded| { + let mut dict = dict.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(nw))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(nh))); + dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, coded) + }) + } else { + None + }; + let jbig = if settings.compression == MonoCompression::Jbig2 { + pdfcraft_filters::encode_jbig2(&data, nw, nh).ok().map(|coded| { + let mut dict = dict.clone(); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + Stream::from_raw(dict, coded) + }) + } else { + None + }; + let flate = ((resampled || packed_from_more) && !(retain && ccitt)).then(|| Stream::flate(dict, &data)); + // Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters. + let best = [g4, jbig, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?; + (best.0 < crate::stored_len(s)?).then_some((best.1, resampled)) +} + pub(crate) fn run( doc: &mut Document, pages: &[ObjRef], @@ -369,7 +1100,12 @@ pub(crate) fn run( report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool, ) -> Result<(), OptimizeError> { - let ppi = effective_resolutions(doc, pages); + let (ppi, mut defaults, read) = walk_pages(doc, pages); + // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise: one used + // where the walk didn't look may be drawn so. + let packs = settings.mono.compression != MonoCompression::Retain; + let rgb: HashSet = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect(); + defaults.extend(used_elsewhere(doc, &rgb, &read)); // Soft masks are processed with their image, not on their own. let masks: HashSet = ppi .keys() @@ -381,6 +1117,7 @@ pub(crate) fn run( let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect(); refs.sort_by_key(|(r, _)| r.num); let total = refs.len(); + let mut jbig2 = false; for (done, (r, ppi)) in refs.into_iter().enumerate() { if !progress(Stage::Images { done, total }) { return Err(OptimizeError::Cancelled); @@ -388,9 +1125,53 @@ pub(crate) fn run( let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; report.images += 1; - let Some(n) = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)) else { continue }; - let st = if n == 1 { &settings.gray } else { &settings.color }; - let Some((new, smask)) = process(doc, s, ppi, st, n) else { continue }; + // Packed from more bits a sample, it is lossless at one bit, but the grey or colour + // settings can make it smaller still (below). + let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1); + let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono) { + Some(m) if packed_from_more => (Some(m), None), + m => (None, m), + }; + if let Some((new, resampled)) = mono { + report.image_bytes_before += s.raw.len(); + report.image_bytes_after += new.raw.len(); + if resampled { + report.images_resampled += 1; + } else { + report.images_recompressed += 1; + } + jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); + doc.set(r, Object::Stream(new)); + continue; + } + let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); + let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n)); + // A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft + // mask resampled with it. + let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask { + Some((_, m)) => Some(m.raw.len()), + None => s.dict.reference(b"SMask").map(|m| match &*doc.get(m) { + Object::Stream(m) => m.raw.len(), + _ => 0, + }), + }; + let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0))); + let packed_wins = packed.as_ref().is_some_and(|(p, _)| { + processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b)) + }); + if let Some((new, resampled)) = packed.filter(|_| packed_wins) { + jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); + report.image_bytes_before += s.raw.len(); + report.image_bytes_after += new.raw.len(); + if resampled { + report.images_resampled += 1; + } else { + report.images_recompressed += 1; + } + doc.set(r, Object::Stream(new)); + continue; + } + let Some((new, smask)) = processed else { continue }; let resized = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height"); report.image_bytes_before += s.raw.len(); report.image_bytes_after += new.raw.len(); @@ -407,5 +1188,207 @@ pub(crate) fn run( if !progress(Stage::Images { done: total, total }) { return Err(OptimizeError::Cancelled); } + if jbig2 { + at_least_pdf_1_4(doc)?; + } Ok(()) } + +/// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4 +/// or more already (directly or by reference), gets `/Version /1.4` in its catalog (ISO 32000-2 +/// §7.7.2). A full save with object streams writes a 1.5 header anyway. +fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> { + let below = |v: &[u8]| v.len() < 3 || v < &b"1.4"[..]; + let Some(root) = doc.root() else { return Ok(()) }; + let version = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()); + let catalog = version.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec)); + if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) { + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.4")))?; + } + Ok(()) +} + +#[cfg(test)] +mod dimension_tests { + use super::*; + + #[test] + fn pixel_reader_rejects_hostile_dimensions_before_decoding() { + for (width, height, channels) in [(-1, 1, 1), (0, 1, 1), (i64::from(u32::MAX) + 101, 1, 1), (80_000_001, 1, 1), (2, 2, usize::MAX)] { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(width)); + dict.set(b"Height".to_vec(), Object::Int(height)); + let stream = Stream::from_raw(dict, vec![0; 4]); + assert!(pixels(&Document::new_empty(), &stream, channels).is_none(), "{width} x {height} x {channels}"); + } + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(2)); + dict.set(b"Height".to_vec(), Object::Int(2)); + let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); + assert_eq!(pixels(&Document::new_empty(), &stream, 1), Some(vec![1, 2, 3, 4])); + } + + /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. + #[test] + fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { + let data = jpeg(&vec![128; 1000 * 1000], 1, 1000, 1000, 50).unwrap_or_default(); + let stream = |w: i64, h: i64| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(w)); + dict.set(b"Height".to_vec(), Object::Int(h)); + dict.set(b"Filter".to_vec(), Object::name("DCTDecode")); + Stream::from_raw(dict, data.clone()) + }; + DECODED_PIXELS.with(|c| c.set(0)); + assert!(pixels(&Document::new_empty(), &stream(10, 10), 1).is_none()); + assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded"); + assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); + } +} + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{DecodeBudget, Dict, Document, MAX_PAGE_DECODED, Object, Stream}; + + #[test] + fn repeated_content_references_share_one_decode_budget() { + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let contents = Object::Array(vec![Object::Ref(stream); 300]); + let data = super::content_bytes(&doc, &contents, &mut DecodeBudget::page()); + assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300)); + } +} + +#[cfg(test)] +mod downsample_tests { + use super::{dark_is_one, downsample_bits}; + use pdfcraft_cos::{Dict, Document, Object}; + + /// [`downsample_bits`] the slow way: each new pixel's area against every source pixel's. + fn naive(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_one: bool) -> Vec { + let (stride, nstride) = ((w as usize).div_ceil(8), (nw as usize).div_ceil(8)); + let (w, h, nw, nh) = (u64::from(w), u64::from(h), u64::from(nw), u64::from(nh)); + let mut out = vec![0u8; nstride * nh as usize]; + for j in 0..nh { + for i in 0..nw { + let mut ones = 0; + for y in 0..h { + for x in 0..w { + let across = ((i + 1) * w).min((x + 1) * nw).saturating_sub((i * w).max(x * nw)); + let down = ((j + 1) * h).min((y + 1) * nh).saturating_sub((j * h).max(y * nh)); + if data[y as usize * stride + x as usize / 8] & (0x80 >> (x % 8)) != 0 { + ones += across * down; + } + } + } + if 2 * ones > w * h || (dark_one && 2 * ones == w * h) { + out[j as usize * nstride + i as usize / 8] |= 0x80 >> (i % 8); + } + } + } + out + } + + #[test] + fn each_new_pixel_takes_the_sample_covering_more_of_it() { + // 4 × 2 to 2 × 1: three 1s of four on the left, none on the right. + assert_eq!(downsample_bits(&[0b1100_0000, 0b1000_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); + // A tie gives the dark sample: 0, black in grey, so a black line half a new pixel wide + // stays (the second of four white columns is black); a white one that thin on black doesn't. + assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0b0100_0000])); + assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0])); + // With 1 the dark sample (an inverting decode array, a separation), that line is a 1. + assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, true).map(|(out, _)| out), Some(vec![0b1000_0000])); + assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, true).map(|(out, _)| out), Some(vec![0b1100_0000])); + // The same size gives the same bits; padding bits are 0. + assert_eq!(downsample_bits(&[0b1010_1111], 3, 1, 3, 1, false).map(|(out, _)| out), Some(vec![0b1010_0000])); + // 3 to 2 across: the middle column counts half to each new one. + assert_eq!(downsample_bits(&[0b1100_0000], 3, 1, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); + } + + /// The dark sample: 0 in grey and of an image mask, 1 in a separation, each the other way round + /// with an inverting decode array; 0 for an indexed image, whatever its decode array. + #[test] + fn the_dark_sample_follows_the_colour_space_and_decode_array() { + let doc = Document::new_empty(); + let image = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d + }; + let inverted = ("Decode", Object::Array(vec![1.into(), 0.into()])); + let grey = ("ColorSpace", Object::name("DeviceGray")); + let mask = ("ImageMask", Object::Bool(true)); + let separation = + ("ColorSpace", Object::Array(vec![Object::name("Separation"), Object::name("Spot"), Object::name("DeviceCMYK"), Object::Null])); + let indexed = ("ColorSpace", Object::Array(vec![Object::name("Indexed"), Object::name("DeviceGray"), 1.into(), Object::Null])); + for (entries, dark_one) in [ + (vec![grey.clone()], Some(false)), + (vec![grey, inverted.clone()], Some(true)), + (vec![mask.clone()], Some(false)), + (vec![mask, inverted.clone()], Some(true)), + (vec![separation.clone()], None), + (vec![separation, inverted.clone()], None), + (vec![indexed.clone()], None), + (vec![indexed, inverted], None), + (vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], None), + ] { + assert_eq!(dark_is_one(&doc, &image(&entries)), dark_one, "{entries:?}"); + } + } + + #[test] + fn what_it_cannot_resample_is_none() { + assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false).map(|(out, _)| out), None, "wider"); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 2, 3, false).map(|(out, _)| out), None, "taller"); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 0, 1, false).map(|(out, _)| out), None); + assert_eq!(downsample_bits(&[0; 1], 4, 2, 2, 1, false).map(|(out, _)| out), None, "short rows"); + assert_eq!(downsample_bits(&[], u32::MAX, u32::MAX, 1, 1, true).map(|(out, _)| out), None); + } + + proptest::proptest! { + /// Any bitmap, to any size no larger, with either sample the dark one, gives what the slow + /// way gives; all 1s stay all 1s. + #[test] + fn it_matches_the_slow_way( + w in 1u32..40, + h in 1u32..16, + nw_part in 0.0f64..1.0, + nh_part in 0.0f64..1.0, + seed in proptest::collection::vec(0u8..=255, 1..64), + dark_one: bool, + ) { + let (nw, nh) = (1 + (f64::from(w - 1) * nw_part) as u32, 1 + (f64::from(h - 1) * nh_part) as u32); + let len = (w as usize).div_ceil(8) * h as usize; + let data: Vec = seed.iter().cycle().take(len).copied().collect(); + proptest::prop_assert_eq!(downsample_bits(&data, w, h, nw, nh, dark_one).map(|(out, _)| out), Some(naive(&data, w, h, nw, nh, dark_one))); + let white = downsample_bits(&vec![0xFF; len], w, h, nw, nh, dark_one).map(|(out, _)| out).unwrap(); + proptest::prop_assert_eq!(white, naive(&vec![0xFF; len], w, h, nw, nh, dark_one)); + proptest::prop_assert!(naive(&vec![0xFF; len], w, h, nw, nh, dark_one).iter().any(|b| *b != 0)); + } + } +} + +#[cfg(test)] +mod pack_tests { + use super::{Pixel, packed_rows}; + + /// Rows of one pixel of one bit, 1 white: each packed as it is. + const WHITE_IS_1: Pixel = |v| Some(v == [1]); + + /// [`packed_rows`] packs rows it can read whole, and refuses any other shape, without reading + /// past a row or allocating more than its samples: no pixels in a row, no components, samples + /// of 3 bits, several components under 8 bits, rows shorter than their pixels, and a width far + /// past the samples. + #[test] + fn packed_rows_refuses_rows_it_cannot_read_whole() { + let samples = [0x80, 0x00, 0x80, 0x00]; + assert_eq!(packed_rows(&samples, 1, 1, 4, (1, 1, WHITE_IS_1)), Some(vec![0x80, 0x00, 0x80, 0x00])); + for (row_len, w, n, bpc) in [(1, 0, 1, 1), (1, 1, 0, 8), (1, 1, 1, 3), (1, 1, 2, 4), (1, 9, 1, 1), (1, usize::MAX / 8, 1, 1)] { + assert_eq!(packed_rows(&samples, row_len, w, 4, (n, bpc, WHITE_IS_1)), None, "row of {row_len} for {w} × {n} × {bpc}"); + } + } +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b67690..76e30da7ac 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -7,14 +7,19 @@ //! - **Images:** each image's effective resolution is measured where pages draw it (the //! smallest over its uses, through form XObjects); colour and grayscale images above a //! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate. -//! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode -//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default, -//! JPEG 2000, JBIG2, CCITT, other than 8 bits) are left alone. +//! A new image replaces the old one only if it is smaller. The colour and grey settings leave +//! alone images they can't decode faithfully (CMYK and other colour spaces, masks, decode arrays +//! other than the default, JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker +//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax, +//! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images +//! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a +//! target resolution, halftones and dithers aside, and are stored as CCITT Group 4 or lossless JBIG2, or 1-bit +//! Flate once resampled or packed, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted //! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or -//! ASCIIHex layer from streams that have one. +//! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead. //! //! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form //! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine @@ -30,7 +35,8 @@ mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; -use pdfcraft_cos::{Document, ObjRef, Object, Stream, serialize}; +use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize}; +use pdfcraft_filters::{Filter, Params}; pub use images::effective_resolutions; @@ -38,12 +44,62 @@ pub use images::effective_resolutions; pub enum OptimizeError { #[error("the document has no page tree")] NoPages, + #[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")] + IncompatibleEncryption { required: &'static str, chosen: &'static str }, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), #[error("the optimization was cancelled")] Cancelled, } +/// The target header version and object-stream policy for an optimized copy. +/// +/// Partial compatibility only: encryption requirements are checked, but content newer than +/// the target is neither detected nor converted. A lower header does not guarantee that +/// older readers can read the document. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Compatibility { + /// Keep the document's version (upgraded to 1.5 when object streams are written). + #[default] + Retain, + Pdf1_4, + Pdf1_5, + Pdf1_6, + Pdf1_7, + Pdf2_0, +} + +impl Compatibility { + pub const ALL: [Compatibility; 6] = + [Compatibility::Retain, Compatibility::Pdf1_4, Compatibility::Pdf1_5, Compatibility::Pdf1_6, Compatibility::Pdf1_7, Compatibility::Pdf2_0]; + + /// The name tools use (`"retain"`, `"1.4"`, `"1.5"`, `"1.6"`, `"1.7"`, `"2.0"`). + pub fn name(self) -> &'static str { + match self { + Compatibility::Retain => "retain", + Compatibility::Pdf1_4 => "1.4", + Compatibility::Pdf1_5 => "1.5", + Compatibility::Pdf1_6 => "1.6", + Compatibility::Pdf1_7 => "1.7", + Compatibility::Pdf2_0 => "2.0", + } + } + + pub fn from_name(name: &str) -> Option { + let trimmed = name.trim(); + let stripped = if trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("pdf ")) { &trimmed[4..] } else { trimmed }; + Compatibility::ALL.into_iter().find(|c| c.name().eq_ignore_ascii_case(stripped)) + } + + /// The three-character `%PDF-` header version, when a target version is chosen. + pub fn version(self) -> Option<&'static str> { + match self { + Compatibility::Retain => None, + _ => Some(self.name()), + } + } +} + /// Where an optimization is, reported to [`optimize_with_progress`]'s callback. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Stage { @@ -66,6 +122,37 @@ pub enum Compression { Retain, } +/// How black-and-white (1-bit) images are stored. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MonoCompression { + /// CCITT Group 4 (lossless), when smaller than the image as it is. + CcittG4, + /// JBIG2, lossless (a generic region, or symbols when the image's marks repeat), when smaller + /// than the image as it is. JBIG2Decode is PDF 1.4: an older document gets `/Version /1.4` in + /// its catalog. + Jbig2, + /// Keep each image's own compression. + Retain, +} + +/// How black-and-white images (1-bit, CCITT fax among them; and eight-bit grey and RGB of only black +/// and white, and indexed images that use only palette entries 0 and 1, packed to one bit), and +/// image masks, are resampled and stored. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MonoSettings { + /// Average images whose effective resolution is above `above_ppi` down to `target_ppi`. + pub downsample: bool, + pub target_ppi: f64, + pub above_ppi: f64, + pub compression: MonoCompression, +} + +impl MonoSettings { + /// Reduce File Size and the presets: to 300 ppi above 450 ppi, CCITT Group 4. Text in a scan + /// stays sharp at 300 ppi. + pub const REDUCE: MonoSettings = MonoSettings { downsample: true, target_ppi: 300.0, above_ppi: 450.0, compression: MonoCompression::CcittG4 }; +} + /// Acrobat's JPEG quality steps. pub const QUALITIES: [(&str, u8); 5] = [("Minimum", 25), ("Low", 45), ("Medium", 60), ("High", 80), ("Maximum", 95)]; @@ -89,10 +176,14 @@ impl ImageSettings { #[derive(Clone, Debug, PartialEq)] pub struct Settings { + /// Target PDF version (`Retain` by default). + pub compatibility: Compatibility, /// Put page pictures drawn as many stacked fragments back together first ([`fragments`]). pub merge_fragments: bool, pub color: ImageSettings, pub gray: ImageSettings, + /// Black-and-white images, and image masks. + pub mono: MonoSettings, pub discard_thumbnails: bool, pub discard_alternate_images: bool, pub discard_tags: bool, @@ -100,24 +191,41 @@ pub struct Settings { /// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or /// ASCIIHex layer from streams that have one. pub flate_unencoded: bool, + /// Clean Up: pack non-stream objects into compressed object streams with a cross-reference + /// stream (PDF 1.5+, §7.5.7–7.5.8). Always off when `compatibility` is [`Compatibility::Pdf1_4`]. + pub object_streams: bool, + /// Clean Up: decode LZW-compressed streams and store them with Flate instead. + pub lzw_to_flate: bool, /// Clean Up: remove links (and bookmark destinations) that point nowhere. pub remove_invalid_links: bool, /// Clean Up: remove named destinations nothing refers to. pub remove_unreferenced_dests: bool, } +impl Settings { + /// Whether `write_full` should pack non-stream objects into compressed object streams: + /// enabled when `object_streams` is set and the target version is not PDF 1.4 (§7.5.7). + pub fn effective_object_streams(&self) -> bool { + self.object_streams && self.compatibility != Compatibility::Pdf1_4 + } +} + impl Default for Settings { /// Acrobat's Reduce File Size choices. fn default() -> Self { Self { + compatibility: Compatibility::Retain, merge_fragments: true, color: ImageSettings::REDUCE, gray: ImageSettings::REDUCE, + mono: MonoSettings::REDUCE, discard_thumbnails: true, discard_alternate_images: true, discard_tags: false, discard_print_settings: false, flate_unencoded: true, + object_streams: true, + lzw_to_flate: true, remove_invalid_links: true, remove_unreferenced_dests: true, } @@ -178,6 +286,8 @@ pub struct Report { pub tags_removed: bool, pub print_settings: usize, pub streams_compressed: usize, + /// Streams whose LZW compression was replaced with Flate (Clean Up). + pub lzw_streams: usize, /// Links removed and bookmarks whose destination was cleared (Clean Up). pub invalid_links: usize, pub invalid_bookmarks: usize, @@ -186,6 +296,20 @@ pub struct Report { pub unused_xobjects: usize, } +/// The minimum PDF version required by `doc`'s active security handler, when it uses features +/// introduced after PDF 1.4 (crypt filters in 1.5, AES-128 in 1.6, AES-256 in 1.7). +fn required_encryption_version(doc: &Document) -> Option<&'static str> { + let h = doc.output_handler()?; + let d = h.dict(); + if d.v >= 5 || d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes256) { + Some("1.7") + } else if d.v == 4 { + if d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes128) { Some("1.6") } else { Some("1.5") } + } else { + None + } +} + /// Optimize `doc` in place. pub fn optimize(doc: &mut Document, settings: &Settings) -> Result { optimize_with_progress(doc, settings, &mut |_| true) @@ -195,6 +319,13 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result bool) -> Result { + // Lexical ordering is valid for these fixed versions with single-digit components. + if let Some(chosen) = settings.compatibility.version() + && let Some(required) = required_encryption_version(doc) + && chosen < required + { + return Err(OptimizeError::IncompatibleEncryption { required, chosen }); + } let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; if settings.merge_fragments { @@ -230,6 +361,16 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: } } let root = doc.root(); + if let Some(chosen) = settings.compatibility.version() { + doc.set_version(chosen); + if let Some(root) = root + && doc.get(root).as_dict().is_some_and(|c| c.contains(b"Version")) + { + doc.update_dict(root, |c| { + c.remove(b"Version"); + })?; + } + } if settings.discard_tags && let Some(root) = root && doc.get(root).as_dict().is_some_and(|c| c.contains(b"StructTreeRoot")) @@ -279,6 +420,19 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: if settings.remove_unreferenced_dests { report.unreferenced_dests = links::remove_unreferenced_dests(doc)?; } + // Before the unencoded-stream pass, which would otherwise take the ASCII layer off an + // `[/ASCII85Decode /LZWDecode]` stream first and count it twice. + if settings.lzw_to_flate { + for num in doc.object_numbers() { + let r = ObjRef { num, generation: doc.generation(num) }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED) { + doc.set(r, Object::Stream(replacement)); + report.lzw_streams += 1; + } + } + } if settings.flate_unencoded { for num in doc.object_numbers() { let r = ObjRef { num, generation: doc.generation(num) }; @@ -305,6 +459,101 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: Ok(report) } +/// Largest decoded LZW stream converted, as for a page's content: the data and its check are +/// held together, so a few hundred kilobytes of hostile LZW can't ask for gigabytes. +const MAX_LZW_DECODED: usize = MAX_PAGE_DECODED; + +/// `s` with its LZW compression replaced by Flate (Acrobat's "In streams that use LZW encoding, +/// use Flate instead"). The filters up to the last LZW one are decoded, strictly and with its +/// predictor; the result is Flate-encoded under the same predictor, without `EarlyChange`, and +/// the filters after it keep their encoded bytes. A stream that doesn't decode cleanly, or +/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes, is +/// left as it is. +fn lzw_to_flate(s: &Stream, max: usize) -> Option { + // An external file's data isn't in `raw`; cross-reference and object streams are rewritten + // on save, so converting them would change nothing in the file. + if s.dict.contains(b"F") || matches!(s.dict.name(b"Type"), Some(b"XRef" | b"ObjStm")) { + return None; + } + let names: Vec<&[u8]> = match s.dict.get(b"Filter")? { + Object::Name(n) => vec![n.as_slice()], + Object::Array(a) => a.iter().map(Object::as_name).collect::>()?, + _ => return None, + }; + // `/Crypt` was applied when the object was loaded, and dropping it would change how the + // stream is encrypted. + if names.contains(&&b"Crypt"[..]) { + return None; + } + let lzw = names.iter().rposition(|n| matches!(*n, b"LZWDecode" | b"LZW"))?; + // One entry per filter, each a dictionary or null; anything else (an indirect reference, an + // array of the wrong length) would be read as defaults, so the data might not decode as + // written. + let parms: Vec> = match s.dict.get(b"DecodeParms") { + None | Some(Object::Null) => vec![None; names.len()], + Some(Object::Dict(d)) if names.len() == 1 => vec![Some(d)], + Some(Object::Array(a)) if a.len() == names.len() => a + .iter() + .map(|p| match p { + Object::Dict(d) => Some(Some(d)), + Object::Null => Some(None), + _ => None, + }) + .collect::>()?, + _ => return None, + }; + let chain = s.filters(); + let stages = chain.get(..=lzw)?; + let (_, params) = stages.last()?; + // The decoded stages' parameters must be direct integers: `filters` reads anything else (a + // reference, 12.5, a name) as the default, and the dictionary carried over would then + // describe the new data differently from how it was encoded. + let direct = |p: &Option<&Dict>| p.is_none_or(|d| d.iter().all(|(_, v)| matches!(v, Object::Int(_)))); + if !parms.get(..=lzw)?.iter().all(direct) { + return None; + } + let data = pdfcraft_filters::decode(stages, &s.raw, max).ok()?; + let flate = Params { early_change: 1, ..params.clone() }; + let raw = pdfcraft_filters::encode(&Filter::Flate, &flate, &data).ok()?; + // The predictor's encoder must give back exactly what its decoder read (a short last row, + // unusual parameters), or the stream would change. The limit counts a PNG predictor's tag + // bytes too, at most one per row of at least one byte. + if pdfcraft_filters::decode(&[(Filter::Flate, flate)], &raw, data.len().saturating_mul(2).saturating_add(1)).ok()? != data { + return None; + } + // Without a predictor the other parameters say nothing to Flate. + let first = parms.get(lzw).copied().flatten().filter(|_| params.predictor > 1).map(|d| { + let mut d = d.clone(); + d.remove(b"EarlyChange"); + Object::Dict(d) + }); + let rest_names = names.get(lzw + 1..)?; + let rest_parms = parms.get(lzw + 1..)?; + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + if rest_names.is_empty() { + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + match first { + Some(p) => dict.set(b"DecodeParms".to_vec(), p), + None => { + dict.remove(b"DecodeParms"); + } + } + } else { + let filters = std::iter::once(Object::name("FlateDecode")).chain(rest_names.iter().map(|n| Object::Name(n.to_vec()))); + dict.set(b"Filter".to_vec(), Object::Array(filters.collect())); + let parms: Vec = std::iter::once(first.unwrap_or(Object::Null)) + .chain(rest_parms.iter().map(|p| p.map_or(Object::Null, |d| Object::Dict(d.clone())))) + .collect(); + if parms.iter().all(|p| matches!(p, Object::Null)) { + dict.remove(b"DecodeParms"); + } else { + dict.set(b"DecodeParms".to_vec(), Object::Array(parms)); + } + } + Some(Stream::from_raw(dict, raw)) +} + /// `s` without its outer ASCII85 or ASCIIHex layer (`[/ASCII85Decode /FlateDecode]` becomes /// `[/FlateDecode]`), when that is smaller. Only the outer layer is decoded: strictly, and within /// the stored size, which ASCII text never falls below, so the inner encoded bytes stay exact. @@ -328,10 +577,27 @@ fn strip_ascii_wrapper(s: &Stream) -> Option { /// Bytes the stream object takes in the file: its dictionary with `/Length` set as the writer /// sets it, then its data. -fn stored_len(s: &Stream) -> Option { +pub(crate) fn stored_len(s: &Stream) -> Option { let mut dict = s.dict.clone(); dict.set(b"Length".to_vec(), Object::Int(i64::try_from(s.raw.len()).ok()?)); let mut out = Vec::new(); serialize(&Object::Dict(dict), &mut out); out.len().checked_add(s.raw.len()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_lzw_stream_that_decodes_past_the_limit_is_left_as_it_is() { + let data = b"0 0 m 612 792 l S\n".repeat(64); + let raw = pdfcraft_filters::encode(&Filter::Lzw, &Params::default(), &data).unwrap(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("LZWDecode")); + let s = Stream::from_raw(dict, raw); + assert!(lzw_to_flate(&s, data.len() - 1).is_none()); + let converted = lzw_to_flate(&s, data.len()).unwrap(); + assert_eq!(converted.decoded_strict().unwrap(), data); + } +} diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs index 744881df74..cbd64eb928 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -150,11 +150,15 @@ fn referenced_names(doc: &Document) -> HashSet> { while let Some(o) = stack.pop() { match o { Object::Dict(d) => { - if let Some(n) = d.get(b"Dest").and_then(dest_name) { + // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name + // string, and an unresolved ref has no name, so the destination it uses would + // look unreferenced and be removed. A GoToR or GoToE /D names a destination in + // another document, so it does not count as a use of this one's. + if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { out.insert(n); } - if matches!(d.name(b"S"), Some(b"GoTo" | b"GoToR" | b"GoToE")) - && let Some(n) = d.get(b"D").and_then(dest_name) + if d.name(b"S") == Some(b"GoTo") + && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) { out.insert(n); } diff --git a/crates/optimize/tests/jpeg_color_transform.rs b/crates/optimize/tests/jpeg_color_transform.rs new file mode 100644 index 0000000000..c4dfabc9d9 --- /dev/null +++ b/crates/optimize/tests/jpeg_color_transform.rs @@ -0,0 +1,418 @@ +//! Optimize keeps a JPEG's colours whatever its colour transform says (#1201). +//! +//! A JPEG with three or four components can store its samples as they are or converted to YCbCr +//! (YCCK). An Adobe APP14 marker in the JPEG says which; without one, the filter's +//! `/ColorTransform` does (ISO 32000-2 §7.4.8, default 1 for three components). Each image here +//! is drawn by PdfCraft's own renderer before and after Optimize, and the two must show the same +//! colours, up to what a JPEG re-encode loses. + +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +use pdfcraft_optimize::{OptimizeError, Settings, Stage, optimize, optimize_with_progress}; + +/// A photo-like RGB picture: colourful gradients with a little texture, so the colour transform +/// matters everywhere and a medium-quality JPEG is smaller than a maximum-quality one. +fn photo(w: u32, h: u32) -> Vec { + let mut px = Vec::with_capacity((w * h * 3) as usize); + for y in 0..h { + for x in 0..w { + let t = ((x ^ y) & 7) as u8; + px.push((x * 255 / w) as u8 ^ t); + px.push((y * 255 / h) as u8); + px.push((255 - (x + y) * 127 / (w + h)) as u8 ^ (t << 2)); + } + } + px +} + +fn rgb_jpeg(px: &[u8], w: u32, h: u32) -> Vec { + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 100).encode(px, w, h, image::ExtendedColorType::Rgb8).unwrap(); + out +} + +/// The JPEG's segments before its first scan: (marker, start of the marker, end of the segment). +fn segments(jpeg: &[u8]) -> Vec<(u8, usize, usize)> { + assert_eq!(&jpeg[..2], &[0xFF, 0xD8]); + let (mut i, mut out) = (2, Vec::new()); + loop { + assert_eq!(jpeg[i], 0xFF, "a marker at {i}"); + let marker = jpeg[i + 1]; + let end = i + 2 + u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize; + out.push((marker, i, end)); + if marker == 0xDA { + return out; + } + i = end; + } +} + +/// `jpeg` with an Adobe APP14 segment whose transform flag is `transform` (0: samples stored as +/// they are, 1: YCbCr, 2: YCCK). +fn with_adobe(jpeg: &[u8], transform: u8) -> Vec { + assert!(segments(jpeg).iter().all(|s| s.0 != 0xEE), "no Adobe segment yet"); + let mut v = jpeg[..2].to_vec(); + v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, transform]); + v.extend_from_slice(&jpeg[2..]); + v +} + +/// `jpeg` with its three components named R, G and B (in the frame and scan headers), which +/// JPEG decoders read as samples stored untransformed. +fn with_rgb_ids(jpeg: &[u8]) -> Vec { + let mut v = jpeg.to_vec(); + let mut ids = Vec::new(); + for (marker, i, _) in segments(jpeg) { + match marker { + 0xC0 => { + assert_eq!(v[i + 9], 3); + for k in 0..3 { + ids.push(v[i + 10 + 3 * k]); + v[i + 10 + 3 * k] = b"RGB"[k]; + } + } + 0xDA => { + for k in 0..v[i + 4] as usize { + let id = v[i + 5 + 2 * k]; + v[i + 5 + 2 * k] = b"RGB"[ids.iter().position(|&x| x == id).unwrap()]; + } + } + _ => {} + } + } + v +} + +/// A baseline JPEG `w` × `h` (multiples of 8) with four components, every 8 × 8 block one flat +/// colour `block(bx, by)` as stored, and an Adobe APP14 segment if `adobe` is given. +fn four_component_jpeg(w: u16, h: u16, adobe: Option, block: impl Fn(u16, u16) -> [u8; 4]) -> Vec { + fn put(bits: &mut Vec, value: u32, n: u32) { + bits.extend((0..n).rev().map(|i| value >> i & 1 == 1)); + } + let mut v = vec![0xFF, 0xD8]; + if let Some(t) = adobe { + v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, t]); + } + // Every quantiser 1: a flat block's DC coefficient is 8 × (sample − 128), exactly. + v.extend_from_slice(&[0xFF, 0xDB, 0, 67, 0]); + v.extend_from_slice(&[1; 64]); + v.extend_from_slice(&[0xFF, 0xC0, 0, 20, 8]); + v.extend_from_slice(&h.to_be_bytes()); + v.extend_from_slice(&w.to_be_bytes()); + v.push(4); + for id in 1..=4 { + v.extend_from_slice(&[id, 0x11, 0]); + } + // DC table: categories 0 to 11 with four-bit codes equal to the category. AC table: EOB, "0". + v.extend_from_slice(&[0xFF, 0xC4, 0, 31, 0x00, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend(0..12u8); + v.extend_from_slice(&[0xFF, 0xC4, 0, 20, 0x10, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend_from_slice(&[0xFF, 0xDA, 0, 14, 4, 1, 0, 2, 0, 3, 0, 4, 0, 0, 63, 0]); + let mut bits = Vec::new(); + let mut pred = [0i32; 4]; + for by in 0..h / 8 { + for bx in 0..w / 8 { + for (c, s) in block(bx, by).into_iter().enumerate() { + let dc = (i32::from(s) - 128) * 8; + let diff = dc - pred[c]; + pred[c] = dc; + let category = 32 - diff.unsigned_abs().leading_zeros(); + put(&mut bits, category, 4); + put(&mut bits, if diff < 0 { (diff + (1 << category) - 1) as u32 } else { diff as u32 }, category); + put(&mut bits, 0, 1); + } + } + } + bits.resize(bits.len().div_ceil(8) * 8, true); + for byte in bits.chunks(8).map(|b| b.iter().fold(0u8, |a, &x| a << 1 | u8::from(x))) { + v.push(byte); + if byte == 0xFF { + v.push(0); + } + } + v.extend_from_slice(&[0xFF, 0xD9]); + v +} + +/// An image XObject holding `jpeg`. +fn jpeg_image(doc: &mut Document, w: u32, h: u32, colour_space: &str, jpeg: Vec, parms: Parms) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name(colour_space)); + let ct = |c: i64| { + let mut p = Dict::new(); + p.set(b"ColorTransform".to_vec(), Object::Int(c)); + Object::Dict(p) + }; + match parms { + Parms::None => d.set(b"Filter".to_vec(), Object::name("DCTDecode")), + Parms::Dict(c) => { + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + d.set(b"DecodeParms".to_vec(), ct(c)); + } + Parms::Array(c) => { + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("DCTDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![ct(c)])); + } + } + doc.add(Object::Stream(Stream::from_raw(d, jpeg))) +} + +/// How an image's `/DecodeParms` gives its `/ColorTransform`. +#[derive(Clone, Copy, Debug)] +enum Parms { + None, + /// `/Filter /DCTDecode /DecodeParms << /ColorTransform c >>`. + Dict(i64), + /// `/Filter [/DCTDecode] /DecodeParms [<< /ColorTransform c >>]`. + Array(i64), +} + +/// A document with one page, `w` × `h` pt, whose content is `content` drawing `images`. +fn one_page(doc: &mut Document, w: u32, h: u32, images: &[(String, ObjRef)], content: &str) { + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + for (name, r) in images { + x.set(name.as_bytes().to_vec(), Object::Ref(*r)); + } + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let c = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), (w as i64).into(), (h as i64).into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(c)); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(r)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); +} + +/// A document whose one page shows `jpeg` (`w` × `h` px) at `ppi`. +fn showing(w: u32, h: u32, ppi: u32, colour_space: &str, jpeg: Vec, parms: Parms) -> (Document, ObjRef) { + let mut doc = Document::new_empty(); + let img = jpeg_image(&mut doc, w, h, colour_space, jpeg, parms); + let (pw, ph) = (w * 72 / ppi, h * 72 / ppi); + one_page(&mut doc, pw, ph, &[("Im".to_string(), img)], &format!("q {pw} 0 0 {ph} 0 0 cm /Im Do Q")); + (doc, img) +} + +fn stream(doc: &Document, r: ObjRef) -> Stream { + match &*doc.get(r) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + } +} + +/// The page as PdfCraft's renderer draws it, `scale` device pixels per point: RGB rows. +fn render(doc: &Document, scale: f32) -> (u32, u32, Vec) { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + (out.width, out.height, out.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect()) +} + +/// The mean difference per colour component between two renders of one page, leaving out a +/// two-pixel border (where the picture's edge is smoothed). +fn mean_difference(a: &(u32, u32, Vec), b: &(u32, u32, Vec)) -> f64 { + assert_eq!((a.0, a.1), (b.0, b.1), "renders of one page size"); + let (w, h) = (a.0 as usize, a.1 as usize); + let (mut sum, mut count) = (0u64, 0u64); + for y in 2..h - 2 { + for x in 2..w - 2 { + for c in 0..3 { + let i = (y * w + x) * 3 + c; + sum += u64::from(a.2[i].abs_diff(b.2[i])); + count += 1; + } + } + } + sum as f64 / count as f64 +} + +/// Re-encoding a JPEG at medium quality moves a component by about this much on average; a +/// colour transform applied or dropped wrongly moves it by tens. +const TOLERANCE: f64 = 4.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Outcome { + /// Re-encoded (smaller), as a plain YCbCr JPEG with no `/DecodeParms`. + Reencoded, + /// Left exactly as it was. + Untouched, +} + +#[test] +fn optimize_keeps_the_colours_of_an_rgb_jpeg_whatever_its_colour_transform() { + use Outcome::*; + let (w, h) = (128, 128); + let plain = rgb_jpeg(&photo(w, h), w, h); + // What the cases rely on: one JPEG read with and without its colour transform shows very + // different colours, so a transform lost or added by Optimize can't hide in the tolerance. + let transformed = render(&showing(w, h, 72, "DeviceRGB", plain.clone(), Parms::None).0, 1.0); + let as_stored = render(&showing(w, h, 72, "DeviceRGB", plain.clone(), Parms::Dict(0)).0, 1.0); + let apart = mean_difference(&transformed, &as_stored); + assert!(apart > 10.0 * TOLERANCE, "the fixture tells the two readings apart: {apart:.1}"); + + let mut failures = Vec::new(); + for (what, adobe, rgb_ids, parms, expect) in [ + ("no Adobe marker, no /ColorTransform", None, false, Parms::None, Reencoded), + ("no Adobe marker, /ColorTransform 1", None, false, Parms::Dict(1), Reencoded), + ("no Adobe marker, /ColorTransform 0", None, false, Parms::Dict(0), Reencoded), + ("no Adobe marker, /ColorTransform 0 in a filter array", None, false, Parms::Array(0), Reencoded), + ("Adobe YCbCr marker", Some(1), false, Parms::None, Reencoded), + ("Adobe YCbCr marker, /ColorTransform 1", Some(1), false, Parms::Dict(1), Reencoded), + ("Adobe untransformed marker", Some(0), false, Parms::None, Reencoded), + ("Adobe untransformed marker, /ColorTransform 0", Some(0), false, Parms::Dict(0), Reencoded), + // The marker overrides /ColorTransform in the standard, but readers differ (PdfCraft's + // renderer lets /ColorTransform 0 win): the image is left as it is. + ("Adobe YCbCr marker, /ColorTransform 0", Some(1), false, Parms::Dict(0), Untouched), + ("Adobe untransformed marker, /ColorTransform 1", Some(0), false, Parms::Dict(1), Untouched), + // Components named R, G and B: JPEG decoders read them untransformed, the standard's + // default transforms them. Only an explicit /ColorTransform 0 settles it. + ("components R, G, B, no /ColorTransform", None, true, Parms::None, Untouched), + ("components R, G, B, /ColorTransform 0", None, true, Parms::Dict(0), Reencoded), + ] { + let mut jpeg = plain.clone(); + if rgb_ids { + jpeg = with_rgb_ids(&jpeg); + } + if let Some(t) = adobe { + jpeg = with_adobe(&jpeg, t); + } + let (mut doc, img) = showing(w, h, 72, "DeviceRGB", jpeg, parms); + let original = stream(&doc, img); + let before = render(&doc, 1.0); + // Reduce File Size: an image at 72 ppi isn't resampled, only recompressed as JPEG 60. + let report = optimize(&mut doc, &Settings::default()).unwrap(); + let after = render(&doc, 1.0); + let new = stream(&doc, img); + let outcome = if new == original { + Untouched + } else { + assert_eq!(new.dict.name(b"Filter"), Some(&b"DCTDecode"[..]), "{what}"); + assert!(!new.dict.contains(b"DecodeParms"), "{what}: a plain JPEG has no colour transform to state"); + assert_eq!(report.images_recompressed, 1, "{what}"); + Reencoded + }; + let difference = mean_difference(&before, &after); + if outcome != expect || difference > TOLERANCE { + failures.push(format!("{what}: {outcome:?} (expected {expect:?}), colours moved by {difference:.1}")); + } + } + assert!(failures.is_empty(), "\n{}", failures.join("\n")); +} + +#[test] +fn optimize_leaves_cmyk_jpegs_as_they_are_with_or_without_an_adobe_marker() { + // 256 px drawn at 288 ppi: Reduce File Size would resample a colour space it handles. + let (w, h) = (256u16, 256u16); + let block = |bx: u16, by: u16| [(bx * 8) as u8, (by * 8) as u8, (255 - (bx + by) * 4) as u8, ((bx ^ by) * 8) as u8]; + for (adobe, parms) in [ + (None, Parms::None), + (None, Parms::Dict(0)), + (None, Parms::Dict(1)), + (Some(0), Parms::None), + (Some(0), Parms::Dict(1)), + (Some(2), Parms::None), + (Some(2), Parms::Dict(0)), + ] { + let what = format!("Adobe marker {adobe:?}, {parms:?}"); + let jpeg = four_component_jpeg(w, h, adobe, block); + let (mut doc, img) = showing(u32::from(w), u32::from(h), 288, "DeviceCMYK", jpeg, parms); + let original = doc.get(img); + let before = render(&doc, 4.0); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(doc.get(img), original, "{what}: left as it is"); + assert!(before == render(&doc, 4.0), "{what}: the page looks the same"); + } +} + +#[test] +fn merging_jpeg_strips_keeps_their_colour_transform() { + // A page picture printed as 16 JPEG strips stored untransformed (/ColorTransform 0, no + // Adobe marker): the merged image must show the strips' colours, not their samples converted + // from YCbCr. + let (w, h, rows) = (128u32, 128u32, 8u32); + let px = photo(w, h); + let mut doc = Document::new_empty(); + let mut images = Vec::new(); + let mut content = String::new(); + for k in 0..h / rows { + let band = &px[(k * rows * w * 3) as usize..((k + 1) * rows * w * 3) as usize]; + let img = jpeg_image(&mut doc, w, rows, "DeviceRGB", rgb_jpeg(band, w, rows), Parms::Dict(0)); + images.push((format!("S{k}"), img)); + content += &format!("q {w} 0 0 {rows} 0 {} cm /S{k} Do Q\n", h - (k + 1) * rows); + } + one_page(&mut doc, w, h, &images, &content); + let before = render(&doc, 1.0); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.merged_images, 1, "{report:?}"); + let difference = mean_difference(&before, &render(&doc, 1.0)); + assert!(difference <= TOLERANCE, "colours moved by {difference:.1}"); +} + +/// `jpeg` with `bytes` right after its SOI marker. +fn after_soi(jpeg: &[u8], bytes: &[u8]) -> Vec { + let mut v = jpeg[..2].to_vec(); + v.extend_from_slice(bytes); + v.extend_from_slice(&jpeg[2..]); + v +} + +#[test] +fn a_jpeg_header_that_takes_more_than_a_few_steps_to_read_is_left_as_it_is() { + // Decoders skip stray bytes between segments and any number of fill bytes before a marker, + // so a stream can make a header scan walk megabytes. Optimize reads the header again for + // every group of fragments it tries: it stops at the first stray byte, or after a few + // thousand steps, and leaves such an image as it is. + let (w, h) = (128, 128); + let plain = rgb_jpeg(&photo(w, h), w, h); + for (what, jpeg, expect) in [ + ("three fill bytes before the first segment", after_soi(&plain, &[0xFF; 3]), Outcome::Reencoded), + ("one stray byte before the first segment", after_soi(&plain, &[0]), Outcome::Untouched), + ("8 MB of stray bytes before the first segment", after_soi(&plain, &vec![0; 8_000_000]), Outcome::Untouched), + ("10,000 fill bytes before the first segment", after_soi(&plain, &[0xFF; 10_000]), Outcome::Untouched), + ] { + let (mut doc, img) = showing(w, h, 72, "DeviceRGB", jpeg, Parms::None); + let original = stream(&doc, img); + optimize(&mut doc, &Settings::default()).unwrap(); + let outcome = if stream(&doc, img) == original { Outcome::Untouched } else { Outcome::Reencoded }; + assert_eq!(outcome, expect, "{what}"); + } +} + +#[test] +fn cancelling_works_from_the_first_call_with_a_jpeg_drawn_many_times() { + // One JPEG with 8 MB of stray bytes after SOI, drawn 64 times at the same place (the case + // reported on #3012). Fragment merging reads its header for each group it tries, before the + // first progress call. The header scan stops at the first stray byte, so that call is the + // first image, and cancelling there stops Optimize. + let (w, h) = (128, 8); + let jpeg = after_soi(&rgb_jpeg(&photo(w, h), w, h), &vec![0; 8_000_000]); + let mut doc = Document::new_empty(); + let img = jpeg_image(&mut doc, w, h, "DeviceRGB", jpeg, Parms::None); + let content = format!( + "q {w} 0 0 {h} 0 0 cm /Im Do Q +" + ) + .repeat(64); + one_page(&mut doc, w, h, &[("Im".to_string(), img)], &content); + let mut stages = Vec::new(); + let result = optimize_with_progress(&mut doc, &Settings::default(), &mut |stage| { + stages.push(stage); + false + }); + assert!(matches!(result, Err(OptimizeError::Cancelled)), "{result:?}"); + assert_eq!(stages, [Stage::Images { done: 0, total: 1 }]); +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2a83e8d6af..2e3ea2d42f 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3,7 +3,11 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; -use pdfcraft_optimize::{Compression, ImageSettings, Settings, SpaceCategory, audit_space, effective_resolutions, optimize}; +use pdfcraft_filters::{Filter, Params}; +use pdfcraft_optimize::{ + Compatibility, Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Settings, SpaceCategory, audit_space, + effective_resolutions, optimize, +}; fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option) -> ObjRef { // A smooth gradient with a little texture (photo-like). @@ -74,6 +78,57 @@ fn stream(doc: &Document, r: ObjRef) -> Stream { } } +#[test] +fn image_resolution_uses_the_pages_physical_unit() { + for (unit, scale) in [ + (None, 1.0), + (Some(Object::Int(10)), 10.0), + (Some(Object::Real(2.5)), 2.5), + (Some(Object::Int(0)), 1.0), + (Some(Object::Int(-10)), 1.0), + (Some(Object::Real(0.5)), 1.0), + (Some(Object::name("Ten")), 1.0), + (Some(Object::Int(100_000)), pdfcraft_model::MAX_USER_UNIT), + ] { + for indirect in [false, true] { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 1, false, None); + let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q"); + let parent = doc.get(p).as_dict().unwrap().reference(b"Parent").unwrap(); + // UserUnit is page-local, unlike Resources and MediaBox. + doc.update_dict(parent, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap(); + if let Some(unit) = unit.clone() { + let unit = if indirect { Object::Ref(doc.add(unit)) } else { unit }; + doc.update_dict(p, |d| d.set(b"UserUnit".to_vec(), unit)).unwrap(); + } + let ppi = effective_resolutions(&doc, &[p])[&img]; + assert!((ppi - 720.0 / scale).abs() < 1e-9, "unit={unit:?}, indirect={indirect}: {ppi}"); + } + } +} + +#[test] +fn optimization_retains_an_image_below_threshold_on_a_scaled_page() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 3, false, None); + let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q"); + doc.update_dict(p, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap(); + // 720 pixels across ten inches is 72 ppi, already below the 225 ppi threshold. + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + let image = stream(&doc, img); + assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(720), Some(720))); + let reopened = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let dimensions: Vec<_> = reopened + .scan_objects() + .filter_map(|(_, object)| match &*object { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))), + _ => None, + }) + .collect(); + assert_eq!(dimensions, [(Some(720), Some(720))]); +} + #[test] fn images_are_measured_where_drawn_and_downsampled() { let mut doc = Document::new_empty(); @@ -164,6 +219,1861 @@ fn an_identity_decode_array_does_not_stop_reduce() { assert_eq!(stream(&doc, odd).dict.int(b"Width"), Some(900)); } +/// Rows of `w` 1-bit samples (1 white, 0 black), each starting on a byte: lines of text-like +/// glyphs; or, with `noise`, random bits. +fn bilevel(w: u32, h: u32, noise: bool) -> Vec { + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut black = |x: usize, y: usize| { + if x < w as usize && y < h as usize { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + }; + if noise { + for y in 0..h as usize { + for x in 0..w as usize { + if next() & 1 == 0 { + black(x, y); + } + } + } + return data; + } + // Lines 24 rows apart, of glyphs drawn from a few shapes (a stem, a bowl, an arch, a dotted + // stem) at any column, 1–3 columns apart with a wider gap between words: text as a scanner + // sees it. Clean, identical rows would favour Flate; a scan's ragged edges favour Group 4. + for line in (4..h as usize).step_by(24) { + let mut x = 8; + while x + 16 < w as usize { + let wide = 5 + (next() % 4) as usize; + let shape = next() % 4; + // Now and then a row of a stroke is a column wider or narrower at an edge, as a scan's + // edges are. + let mut ragged = || match next() % 8 { + 0 => 0, + 1 => 2, + _ => 1, + }; + let mut fill = |x0: usize, x1: usize, y0: usize, y1: usize| { + for y in line + y0..line + y1 { + let (left, right) = ((x + x0 + 1).saturating_sub(ragged()), x + x1 + ragged()); + for xx in left..right.saturating_sub(1) { + black(xx, y); + } + } + }; + match shape { + 0 => fill(0, 2, 0, 14), + 1 => { + fill(0, wide, 4, 6); + fill(0, wide, 12, 14); + fill(0, 2, 4, 14); + fill(wide - 2, wide, 4, 14); + } + 2 => { + fill(0, 2, 4, 14); + fill(wide - 2, wide, 6, 14); + fill(0, wide - 1, 4, 6); + } + _ => { + fill(0, 2, 7, 14); + fill(0, 2, 2, 4); + } + } + x += wide + 2 + (next() % 2) as usize + if next() % 6 == 0 { 7 } else { 0 }; + } + } + data +} + +/// A 1-bit image of `data`, Flate-compressed (with `predictor`, a PNG predictor, if any), with +/// `entries` added to its dictionary. +fn mono_image(doc: &mut Document, w: u32, h: u32, data: &[u8], predictor: Option, entries: &[(&str, Object)]) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + let s = match predictor { + None => Stream::flate(d, data), + Some(predictor) => { + let params = pdfcraft_filters::Params { predictor, colors: 1, bits_per_component: 1, columns: w as i64, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(predictor)); + parms.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(d, pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, data).unwrap()) + } + }; + doc.add(Object::Stream(s)) +} + +fn render(doc: &Document) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba.to_vec() +} + +/// Black-and-white images become CCITT Group 4, losslessly: the page looks the same to the pixel. +/// Grey, an image mask (with a decode array that inverts it), an indexed image of two colours and +/// one stored with a PNG predictor qualify; noise (bigger as G4), a 1-bit RGB image, one whose +/// colour space is a resource name, one wider than Group 4 encodes and a tiny one (bigger as +/// stored) are left alone, as is everything with `mono: Retain`. +#[test] +fn black_and_white_images_become_ccitt_group_4() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let indexed_cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])), + ]); + let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]); + let predicted = mono_image(&mut doc, w, h, &text, Some(15), &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let noise = + mono_image(&mut doc, 400, 200, &bilevel(400, 200, true), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let rgb = mono_image( + &mut doc, + 400, + 160, + &bilevel(1200, 160, false), + None, + &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceRGB"))], + ); + let wide = mono_image( + &mut doc, + 40_000, + 2, + &bilevel(40_000, 2, false), + None, + &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))], + ); + // A bare /Indexed is no colour space on its own: it names the page's resource, here RGB, so + // these samples are three to a pixel. + let named = + mono_image(&mut doc, 400, 160, &bilevel(1200, 160, false), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("Indexed"))]); + // 64 × 8 white: its Group 4 data is smaller than its Flate data, but not by the longer + // dictionary Group 4 needs. + let tiny = mono_image(&mut doc, 64, 8, &[0xFF; 64], None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let tiny_g4 = pdfcraft_filters::encode_g4(&[0xFF; 64], 64, 8).unwrap(); + assert!(tiny_g4.len() < stream(&doc, tiny).raw.len(), "{} against {}", tiny_g4.len(), stream(&doc, tiny).raw.len()); + let p = page( + &mut doc, + &[("G", gray), ("M", mask), ("I", indexed), ("P", predicted), ("N", noise), ("R", rgb), ("W", wide), ("T", tiny), ("X", named)], + "q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q \ + q 300 0 0 120 320 520 cm /P Do Q q 200 0 0 100 20 400 cm /N Do Q q 200 0 0 80 240 400 cm /R Do Q q 560 0 0 4 20 380 cm /W Do Q q 64 0 0 8 460 400 cm /T Do Q q 200 0 0 80 240 300 cm /X Do Q", + ); + doc.update_dict(p, |d| { + if let Some(Object::Dict(res)) = d.get_mut(b"Resources") { + let mut cs = Dict::new(); + cs.set(b"Indexed".to_vec(), Object::name("DeviceRGB")); + res.set(b"ColorSpace".to_vec(), Object::Dict(cs)); + } + }) + .unwrap(); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let untouched = [noise, rgb, wide, tiny, named].map(|r| raw(&doc, r)); + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is"); + assert!([gray, mask, indexed, predicted].iter().all(|i| raw(&kept, *i) == raw(&doc, *i))); + + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_recompressed, 4); + assert!(r.image_bytes_after < r.image_bytes_before, "{} → {}", r.image_bytes_before, r.image_bytes_after); + for i in [gray, mask, indexed, predicted] { + let s = stream(&doc, i); + assert_eq!(s.dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + let parms = s.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"K"), parms.int(b"Columns"), parms.int(b"Rows")), (Some(-1), Some(w as i64), Some(h as i64))); + } + assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + assert_eq!(stream(&doc, indexed).dict.get(b"ColorSpace"), Some(&indexed_cs), "the colour space stays"); + assert_eq!([noise, rgb, wide, tiny, named].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// Which 1-bit images qualify for Group 4: those of one colour component, an ICC profile of one +/// component and a DeviceN of one colourant among them, but not one of three components or two +/// colourants (more than a bit a pixel); image masks of one bit, as `/BitsPerComponent 1` says or +/// by default, but not one that says 8; and images of up to 80 million pixels, not one row more. +/// The byte totals count exactly the images stored again. +#[test] +fn group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let icc = |doc: &mut Document, n: i64| { + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Int(n)); + Object::Array(vec![Object::name("ICCBased"), Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new()))))]) + }; + let device_n = |colourants: &[&str]| { + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()])); + tint.set(b"N".to_vec(), Object::Int(1)); + Object::Array(vec![ + Object::name("DeviceN"), + Object::Array(colourants.iter().map(|c| Object::name(c)).collect()), + Object::name("DeviceGray"), + Object::Dict(tint), + ]) + }; + let one_bit = |cs: Object| [("BitsPerComponent", 1.into()), ("ColorSpace", cs)]; + let icc_1 = icc(&mut doc, 1); + let icc_1 = mono_image(&mut doc, w, h, &text, None, &one_bit(icc_1)); + let icc_3 = icc(&mut doc, 3); + let icc_3 = mono_image(&mut doc, w, h, &bilevel(3 * w, h, false), None, &one_bit(icc_3)); + let spot = mono_image(&mut doc, w, h, &text, None, &one_bit(device_n(&["Spot"]))); + let two_spots = mono_image(&mut doc, w, h, &bilevel(2 * w, h, false), None, &one_bit(device_n(&["Spot", "Other"]))); + let mask = |bits: i64| [("ImageMask", Object::Bool(true)), ("BitsPerComponent", bits.into())]; + let mask_1 = mono_image(&mut doc, w, h, &text, None, &mask(1)); + let mask_8 = mono_image(&mut doc, w, h, &bilevel(8 * w, h, false), None, &mask(8)); + // 10,000 × 8,000 is the limit itself. + let grey = one_bit(Object::name("DeviceGray")); + let at_limit = mono_image(&mut doc, 10_000, 8_000, &bilevel(10_000, 8_000, false), None, &grey); + let past_limit = mono_image(&mut doc, 10_000, 8_001, &bilevel(10_000, 8_001, false), None, &grey); + let all = [icc_1, icc_3, spot, two_spots, mask_1, mask_8, at_limit, past_limit]; + let names: Vec = (0..all.len()).map(|i| format!("Im{i}")).collect(); + // 200 × 80 pt is 432 ppi, 1800 × 1440 pt 400 ppi: below where monochrome downsampling starts. + let content: String = (0..all.len()) + .map(|i| { + if i < 6 { + format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3)) + } else { + format!("q 1800 0 0 1440 0 0 cm /Im{i} Do Q ") + } + }) + .collect(); + page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::>(), &content); + let converted = [icc_1, spot, mask_1, at_limit]; + let left = [icc_3, two_spots, mask_8, past_limit]; + let before = |doc: &Document, refs: &[ObjRef]| refs.iter().map(|r| stream(doc, *r).raw).collect::>(); + let (old, untouched) = (before(&doc, &converted), before(&doc, &left)); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_recompressed, converted.len(), "{r:?}"); + for i in converted { + assert_eq!(stream(&doc, i).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "{i:?}"); + } + assert_eq!(before(&doc, &left), untouched); + let new = before(&doc, &converted); + assert_eq!((r.image_bytes_before, r.image_bytes_after), (old.iter().map(|b| b.len()).sum(), new.iter().map(|b| b.len()).sum())); +} + +/// An image whose /DecodeParms are a reference, or hold numbers given by reference +/// (`/Predictor 9 0 R`, which `Dict::int` doesn't follow), is read through them, as one with them +/// direct: a predictor's tag bytes would otherwise shift every row, and the image would be stored +/// again wrong. Here a 1-bit image with a PNG predictor (stored again as Group 4), an eight-bit +/// grey one with a PNG predictor at 600 ppi (resampled), an eight-bit one of only black and white +/// with PNG predictor 10 (each row's tag a 0, so the shifted rows would still be black and white; +/// packed to one bit), and a two-entry indexed one the same way come out byte for byte as with +/// direct parameters. +#[test] +fn images_whose_decode_parms_are_references_come_out_as_with_them_direct() { + let build = |how: &str| { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let one_bit = mono_image(&mut doc, w, h, &bilevel(w, h, false), Some(15), &grey); + let black_and_white_samples = eight_bit(&bilevel(w, h, false), w, h); + let shades: Vec = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x0F)).collect(); + let params = pdfcraft_filters::Params { predictor: 15, colors: 1, bits_per_component: 8, columns: w as i64, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(15)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &shades).unwrap(); + let eight_bit = doc.add(Object::Stream(Stream::from_raw(d.clone(), coded))); + let params = pdfcraft_filters::Params { predictor: 10, ..params }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(10)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let black_and_white = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &black_and_white_samples).unwrap(); + let packed = doc.add(Object::Stream(Stream::from_raw(d.clone(), black_and_white))); + let palette = vec![0, 0, 255, 255, 255, 255]; + d.set( + b"ColorSpace".to_vec(), + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(palette)), + ]), + ); + let entries = samples_of(&bilevel(w, h, false), w, h, [&[0], &[1]], 8); + let entries = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &entries).unwrap(); + let indexed = doc.add(Object::Stream(Stream::from_raw(d, entries))); + for r in [one_bit, eight_bit, packed, indexed] { + let mut s = stream(&doc, r); + let Some(Object::Dict(mut parms)) = s.dict.get(b"DecodeParms").cloned() else { panic!("{:?}", s.dict) }; + let made = match how { + "whole" => Object::Ref(doc.add(Object::Dict(parms))), + "numbers" => { + for k in [&b"Predictor"[..], b"Columns"] { + let v = parms.get(k).cloned().unwrap(); + parms.set(k.to_vec(), Object::Ref(doc.add(v))); + } + Object::Dict(parms) + } + _ => continue, + }; + s.dict.set(b"DecodeParms".to_vec(), made); + doc.set(r, Object::Stream(s)); + } + // 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi. + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed), ("D", indexed)], + "q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q \ + q 280 0 0 112 325 200 cm /D Do Q", + ); + let before = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r).raw); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (1, 3), "{r:?}"); + let after = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r)); + assert!(before.iter().zip(&after).all(|(b, a)| *b != a.raw), "all stored again"); + assert_eq!(after[0].dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + for s in &after[2..] { + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + } + after.map(|s| s.raw) + }; + let direct = build("direct"); + assert_eq!(build("whole"), direct, "the dictionary by reference"); + assert_eq!(build("numbers"), direct, "the numbers inside by reference"); +} + +/// An image whose data decodes far past what its samples can need (a decompression bomb) is left +/// alone rather than decoded up to the 1 GiB limit: a 1-bit one that would become Group 4, an +/// eight-bit one that would be resampled, and an eight-bit one of only black and white that would +/// be packed to one bit, each with four megabytes of zeros after its samples. Stored as ASCIIHex +/// text under Flate, broken into lines of 64 digits, their samples aren't a bomb. +#[test] +fn images_whose_data_decodes_far_past_their_samples_are_left_alone() { + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let shades: Vec = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x0F)).collect(); + for padding in [0, 4 << 20] { + let mut doc = Document::new_empty(); + let padded = |data: &[u8]| data.iter().copied().chain(std::iter::repeat_n(0, padding)).collect::>(); + let grey = |bits: i64| [("BitsPerComponent", bits.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let one_bit = mono_image(&mut doc, w, h, &padded(&text), None, &grey(1)); + let eight_bit = mono_image(&mut doc, w, h, &padded(&shades), None, &grey(8)); + let packed = mono_image(&mut doc, w, h, &padded(&self::eight_bit(&text, w, h)), None, &grey(8)); + // 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi. + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed)], + "q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q", + ); + let before = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + let after = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw); + if padding == 0 { + assert_eq!((r.images_resampled, r.images_recompressed), (1, 2), "{r:?}"); + } else { + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(after, before); + } + } + let hex_lines = |data: &[u8]| { + let digits: Vec = data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut text: Vec = digits.chunks(64).flat_map(|line| line.iter().copied().chain(*b"\n")).collect(); + text.push(b'>'); + pdfcraft_filters::encode_flate(&text) + }; + let mut doc = Document::new_empty(); + let as_hex = |doc: &mut Document, data: &[u8], bits: i64, (w, h): (u32, u32)| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bits)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + doc.add(Object::Stream(Stream::from_raw(d, hex_lines(data)))) + }; + // Large enough that its hex text in lines is more than twice its samples and a byte a row + // plus 4 KiB: 2560 × 1200, drawn at 400 ppi. + let page_scan = bilevel(2560, 1200, false); + let one_bit = as_hex(&mut doc, &page_scan, 1, (2560, 1200)); + let eight_bit = as_hex(&mut doc, &shades, 8, (w, h)); + let packed = as_hex(&mut doc, &self::eight_bit(&text, w, h), 8, (w, h)); + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed)], + "q 460.8 0 0 216 20 500 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q", + ); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, one_bit).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "1-bit hex text in lines: Group 4"); + assert!(stream(&doc, eight_bit).dict.int(b"Width").is_some_and(|nw| nw < i64::from(w)), "8-bit hex text in lines: resampled"); + assert_eq!(stream(&doc, packed).dict.int(b"BitsPerComponent"), Some(1), "8-bit black-and-white hex text in lines: packed"); +} + +/// Monochrome Images keeping each image's compression (and resampling as by default). +const RETAIN: MonoSettings = MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE }; + +/// Eight-bit samples (0 or 255) from rows of one-bit ones. +fn eight_bit(bits: &[u8], w: u32, h: u32) -> Vec { + let stride = (w as usize).div_ceil(8); + (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 })).collect() +} + +/// Eight-bit samples of only 0 and 255, dithered (Floyd–Steinberg) from a smooth picture as a +/// scanner's halftone mode gives them: no shade but black and white, in short runs. +fn dithered(w: u32, h: u32) -> Vec { + let (w, h) = (w as usize, h as usize); + let mut level: Vec = (0..w * h).map(|i| 127.5 + 127.5 * ((i % w) as f32 / 37.0).sin() * ((i / w) as f32 / 53.0).cos()).collect(); + let mut out = vec![0u8; w * h]; + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let v = if level[i] < 128.0 { 0.0 } else { 255.0 }; + out[i] = v as u8; + let e = level[i] - v; + if x + 1 < w { + level[i + 1] += e * 7.0 / 16.0; + } + if y + 1 < h { + if x > 0 { + level[i + w - 1] += e * 3.0 / 16.0; + } + level[i + w] += e * 5.0 / 16.0; + if x + 1 < w { + level[i + w + 1] += e / 16.0; + } + } + } + } + out +} + +/// A dithered scan stored as eight-bit grey of only black and white is smaller downsampled and +/// JPEG-compressed by the grey settings than packed to one bit, which can't shrink its dots: the +/// smaller wins, so Reduce File Size makes it no larger than with Monochrome Images on Retain. +/// Clean text is still packed to one bit at the same resolution. +#[test] +fn eight_bit_black_and_white_takes_the_smaller_of_one_bit_and_the_grey_settings() { + let (w, h) = (1200u32, 1500u32); + for (what, samples) in [("dither", dithered(w, h)), ("text", eight_bit(&bilevel(w, h, false), w, h))] { + let build = || { + let mut doc = Document::new_empty(); + let img = mono_image(&mut doc, w, h, &samples, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]); + // 288 × 360 pt is 300 ppi, above where the grey settings downsample. + page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q"); + (doc, img) + }; + let (mut doc, img) = build(); + optimize(&mut doc, &Settings::default()).unwrap(); + let (mut retained, _) = build(); + optimize(&mut retained, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + let (s, kept) = (stream(&doc, img), stream(&retained, img)); + let bits = if what == "dither" { 8 } else { 1 }; + assert_eq!(s.dict.int(b"BitsPerComponent"), Some(bits), "{what}: {} bytes, {} with Retain", s.raw.len(), kept.raw.len()); + if what == "dither" { + assert!(s.raw.len() <= kept.raw.len(), "{what}: {} bytes, {} with Retain", s.raw.len(), kept.raw.len()); + } + } +} + +/// The smaller of one bit and the grey settings counts the soft mask each leaves: packed, the +/// image keeps its mask as it is; resampled by the grey settings, the mask is resampled with it. +/// Clean text, 1200 × 1500 at 300 ppi, under a noisy mask. A mask as large: the grey settings +/// shrink it by far more than packing saves on the image, and win. A mask of 640 × 800 (a soft +/// mask may be another size than its image): resampled to the image's new size it shrinks only a +/// little, by less than the grey settings' JPEG adds over the packed image, and packing wins. +#[test] +fn eight_bit_black_and_white_with_a_soft_mask_counts_the_mask() { + let (w, h) = (1200u32, 1500u32); + for ((mw, mh), grey_wins) in [((w, h), true), ((640, 800), false)] { + let mut doc = Document::new_empty(); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let noise: Vec = (0..mw * mh) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 24) as u8 + }) + .collect(); + let mask = mono_image(&mut doc, mw, mh, &noise, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let grey = [("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray")), ("SMask", Object::Ref(mask))]; + let img = mono_image(&mut doc, w, h, &eight_bit(&bilevel(w, h, false), w, h), None, &grey); + page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, img); + let expected = if grey_wins { (Some(8), Some(&b"DCTDecode"[..])) } else { (Some(1), Some(&b"CCITTFaxDecode"[..])) }; + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), expected, "mask {mw} × {mh}: {:?}", s.dict); + } +} + +/// Eight-bit grey images of only black and white (as some scanners and converters write scans) +/// become one-bit Group 4, losslessly: the page looks the same, an inverting decode array +/// included. Not touched by this: a grey image with any other shade, an indexed image (its samples +/// are palette indices), one masked by colour key, and everything with `mono: Retain`. +#[test] +fn eight_bit_black_and_white_images_become_one_bit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let grey = eight_bit(&bilevel(w, h, false), w, h); + let image = |doc: &mut Document, data: &[u8], entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let gray_cs = ("ColorSpace", Object::name("DeviceGray")); + let plain = image(&mut doc, &grey, std::slice::from_ref(&gray_cs)); + let decode = Object::Array(vec![1.into(), 0.into()]); + let inverted = image(&mut doc, &grey, &[gray_cs.clone(), ("Decode", decode.clone())]); + let mut shaded = grey.clone(); + shaded[1000] = 128; + let shade = image(&mut doc, &shaded, std::slice::from_ref(&gray_cs)); + let palette = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceGray"), + 255.into(), + Object::String(pdfcraft_cos::PdfString::literal((0..=255u8).rev().collect::>())), + ]); + let indexed = image(&mut doc, &grey, &[("ColorSpace", palette)]); + let keyed = image(&mut doc, &grey, &[gray_cs, ("Mask", Object::Array(vec![0.into(), 0.into()]))]); + page( + &mut doc, + &[("A", plain), ("B", inverted), ("C", shade), ("D", indexed), ("E", keyed)], + "q 300 0 0 120 20 660 cm /A Do Q q 300 0 0 120 320 660 cm /B Do Q q 300 0 0 120 20 520 cm /C Do Q \ + q 300 0 0 120 320 520 cm /D Do Q q 300 0 0 120 20 380 cm /E Do Q", + ); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + // Grey settings that leave grey images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { gray: keep, ..Settings::default() }; + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap(); + assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is"); + + let untouched = [shade, indexed, keyed].map(|r| raw(&doc, r)); + let old: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum(); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 2); + let new: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum(); + assert_eq!((r.image_bytes_before, r.image_bytes_after), (old, new), "the byte totals count both"); + for i in [plain, inverted] { + let s = stream(&doc, i); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + } + assert_eq!(stream(&doc, inverted).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + assert_eq!([shade, indexed, keyed].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// `bits` (1-bit rows) with each 0 as `values[0]` and each 1 as `values[1]`, at `bpc` bits a sample +/// (each row starting on a new byte). +fn samples_of(bits: &[u8], w: u32, h: u32, values: [&[u8]; 2], bpc: usize) -> Vec { + let (w, h) = (w as usize, h as usize); + let stride = w.div_ceil(8); + let mut out = Vec::new(); + for y in 0..h { + let row: Vec = (0..w).flat_map(|x| values[usize::from(bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0)].iter().copied()).collect(); + let mut packed = vec![0u8; (row.len() * bpc).div_ceil(8)]; + for (i, s) in row.iter().enumerate() { + packed[i * bpc / 8] |= if bpc == 8 { *s } else { s << (8 - bpc - i * bpc % 8) }; + } + out.extend(packed); + } + out +} + +/// Eight-bit RGB of only black and white becomes one-bit grey, and an indexed image that uses only +/// palette entries 0 and 1 becomes one bit with its palette (here blue and white), at 8 bits or 4, +/// both as Group 4 and losslessly: the page looks the same to the pixel. An RGB decode array the +/// same for each component becomes grey's, and an indexed image's default one goes. Not touched: +/// RGB with any other colour, a decode array that differs between components, a colour-key mask or +/// calibrated RGB; an indexed image that uses a third entry or has another decode array; and +/// everything with `mono: Retain`. +#[test] +fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], bpc: i64, cs: Object, entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bpc)); + d.set(b"ColorSpace".to_vec(), cs); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let rgb_cs = Object::name("DeviceRGB"); + let rgb_data = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let rgb = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[]); + let pairs = |p: [i64; 6]| Object::Array(p.into_iter().map(Object::Int).collect()); + let rgb_inverted = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 1, 0, 1, 0]))]); + let rgb_mixed = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 0, 1, 0, 1]))]); + let mut red = rgb_data.clone(); + red[3000..3003].copy_from_slice(&[255, 0, 0]); + let rgb_red = image(&mut doc, &red, 8, rgb_cs.clone(), &[]); + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let cal_rgb = image(&mut doc, &rgb_data, 8, Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]), &[]); + let rgb_keyed = image(&mut doc, &rgb_data, 8, rgb_cs, &[("Mask", pairs([0, 0, 0, 0, 0, 0]))]); + let palette = |entries: &[u8]| { + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + (entries.len() as i64 / 3 - 1).into(), + Object::String(pdfcraft_cos::PdfString::literal(entries.to_vec())), + ]) + }; + let blue_white = palette(&[0, 0, 255, 255, 255, 255]); + let indices = samples_of(&text, w, h, [&[0], &[1]], 8); + let blue = image(&mut doc, &indices, 8, blue_white.clone(), &[]); + let default_decode = Object::Array(vec![0.into(), 255.into()]); + let blue_decoded = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", default_decode)]); + let blue_4 = image(&mut doc, &samples_of(&text, w, h, [&[0], &[1]], 4), 4, blue_white.clone(), &[]); + let mut third = indices.clone(); + third[3000] = 2; + let three = image(&mut doc, &third, 8, palette(&[0, 0, 255, 255, 255, 255, 255, 0, 0]), &[]); + let other_decode = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", Object::Array(vec![0.into(), 1.into()]))]); + let all = [rgb, rgb_inverted, rgb_mixed, rgb_red, cal_rgb, rgb_keyed, blue, blue_decoded, blue_4, three, other_decode]; + // 200 × 80 pt is 432 ppi: below where monochrome downsampling starts. + let names: Vec = (0..all.len()).map(|i| format!("Im{i}")).collect(); + let content: String = + (0..all.len()).map(|i| format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3) as i64)).collect(); + page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::>(), &content); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + // Colour and grey settings that leave images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { color: keep, gray: keep, ..Settings::default() }; + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "Retain keeps every image as it is"); + + let untouched = [rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 5), "{r:?}"); + for (i, cs) in [ + (rgb, Object::name("DeviceGray")), + (rgb_inverted, Object::name("DeviceGray")), + (blue, blue_white.clone()), + (blue_decoded, blue_white.clone()), + (blue_4, blue_white), + ] { + let s = stream(&doc, i); + assert_eq!( + (s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter"), s.dict.get(b"ColorSpace")), + (Some(1), Some(&b"CCITTFaxDecode"[..]), Some(&cs)), + "{i:?}" + ); + assert_eq!(doc.decoded_image(&s).unwrap(), text, "{i:?}"); + let decode = if i == rgb_inverted { Some(Object::Array(vec![1.into(), 0.into()])) } else { None }; + assert_eq!(s.dict.get(b"Decode").cloned(), decode, "{i:?}"); + } + assert_eq!([rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// Eight-bit black-and-white images in other one-component spaces are packed too: calibrated +/// grey, an ICC profile of one component, a separation, each keeping its colour space. Calibrated +/// RGB whose samples are only 0 and 1 is no indexed image, and stays as it is. +#[test] +fn eight_bit_black_and_white_in_other_one_component_spaces_is_packed() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], cs: Object| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), cs); + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let mut profile = Dict::new(); + profile.set(b"N".to_vec(), Object::Int(1)); + let profile = doc.add(Object::Stream(Stream::from_raw(profile, Vec::new()))); + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()])); + tint.set(b"N".to_vec(), Object::Int(1)); + let spaces = [ + Object::Array(vec![Object::name("CalGray"), Object::Dict(white_point.clone())]), + Object::Array(vec![Object::name("ICCBased"), Object::Ref(profile)]), + Object::Array(vec![Object::name("Separation"), Object::name("Spot"), Object::name("DeviceGray"), Object::Dict(tint)]), + ]; + let grey = samples_of(&text, w, h, [&[0], &[255]], 8); + let packed = spaces.clone().map(|cs| image(&mut doc, &grey, cs)); + let cal_rgb = Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]); + let cal_rgb = image(&mut doc, &samples_of(&text, w, h, [&[0, 0, 0], &[1, 1, 1]], 8), cal_rgb); + let names = ["A", "B", "C", "D"]; + // 200 × 80 pt is 432 ppi: below where monochrome downsampling starts. + let content: String = (0..4).map(|i| format!("q 200 0 0 80 6 {} cm /{} Do Q ", 690 - 100 * i, names[i])).collect(); + page(&mut doc, &names.into_iter().zip([packed[0], packed[1], packed[2], cal_rgb]).collect::>(), &content); + let untouched = stream(&doc, cal_rgb).raw; + // Colour and grey settings that leave images alone, so only the packing acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + optimize(&mut doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + for (r, cs) in packed.iter().zip(&spaces) { + let s = stream(&doc, *r); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"ColorSpace")), (Some(1), Some(cs)), "{cs:?}"); + } + assert_eq!(stream(&doc, cal_rgb).raw, untouched, "calibrated RGB"); +} + +/// A resampled Group 4 image is stored as 1-bit Flate when that is smaller, as any resampled image +/// may be: here vertical stripes, whose identical rows Flate takes for almost nothing. +#[test] +fn a_resampled_group_4_image_is_stored_as_flate_when_smaller() { + let (w, h) = (1200u32, 480u32); + let stripes = vec![0x0Fu8; (w as usize).div_ceil(8) * h as usize]; + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&stripes, w, h).unwrap()))); + // 144 × 57.6 pt is 600 ppi. + page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q"); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, 1, "{r:?}"); + let s = stream(&doc, img); + assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(600), Some(&b"FlateDecode"[..]))); +} + +/// A decode array is read as numbers, whichever way they are written: an indexed image's default +/// as `[0 255.0]` or with an entry by reference, and RGB's inverting pairs with a real among them, +/// pack as when written as integers. +#[test] +fn decode_arrays_read_as_numbers_however_written() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], cs: Object, decode: Object| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), cs); + d.set(b"Decode".to_vec(), decode); + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let palette = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 255, 255])), + ]); + let indices = samples_of(&text, w, h, [&[0], &[1]], 8); + let max = doc.add(Object::Int(255)); + let real = image(&mut doc, &indices, palette.clone(), Object::Array(vec![0.into(), Object::Real(255.0)])); + let referred = image(&mut doc, &indices, palette, Object::Array(vec![0.into(), Object::Ref(max)])); + let rgb = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let pairs = Object::Array(vec![1.into(), 0.into(), 1.into(), 0.into(), Object::Real(1.0), 0.into()]); + let inverted = image(&mut doc, &rgb, Object::name("DeviceRGB"), pairs); + page( + &mut doc, + &[("A", real), ("B", referred), ("C", inverted)], + "q 200 0 0 80 6 690 cm /A Do Q q 200 0 0 80 6 590 cm /B Do Q q 200 0 0 80 6 490 cm /C Do Q", + ); + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + optimize(&mut doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + for r in [real, referred] { + let s = stream(&doc, r); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"Decode")), (Some(1), None), "{r:?}"); + } + let s = stream(&doc, inverted); + let decode = Object::Array(vec![1.into(), 0.into()]); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"Decode")), (Some(1), Some(&decode))); +} + +/// With Retain, a Group 3 halftone above 450 ppi, which isn't resampled, stays Group 3 byte for +/// byte: Retain stores nothing again that it doesn't resample. +#[test] +fn retain_keeps_a_group_3_halftone_as_it_is() { + let (w, h) = (64u32, 64u32); + let a: Vec = vec![1; 64]; + let b: Vec = std::iter::once(0).chain(std::iter::repeat_n(1, 64)).collect(); + let runs: Vec<&[u32]> = (0..h).map(|y| if y % 2 == 0 { &a[..] } else { &b[..] }).collect(); + let g3 = group_3(&runs, false, None); + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(0)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone()))); + // 7.68 pt for 64 samples is 600 ppi. + page(&mut doc, &[("A", fax)], "q 7.68 0 0 7.68 100 600 cm /A Do Q"); + assert_eq!(doc.decoded_image(&stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes"); + let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(stream(&doc, fax).raw[..], g3[..]); +} + +/// Black-and-white RGB stays RGB where grey would be drawn otherwise: drawn, on any of its uses, +/// where `/DefaultGray` or `/DefaultRGB` is in force (ISO 32000-2 §8.6.5.6), from a page's +/// resources or a form's own, as grey would then go through DefaultGray, or no longer through +/// DefaultRGB; or with a soft mask whose `/Matte` has three components, one for each of RGB's. A +/// form drawn where they are in force counts too, though it has resources of its own. The same +/// image drawn plainly, or with a soft mask and no `/Matte`, becomes grey. +#[test] +fn black_and_white_rgb_stays_rgb_where_grey_would_be_drawn_otherwise() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let rgb_data = samples_of(&bilevel(w, h, false), w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let image = |doc: &mut Document, cs: &str, data: &[u8], entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name(cs)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let opaque = vec![255u8; (w * h) as usize]; + let plain_mask = image(&mut doc, "DeviceGray", &opaque, &[]); + let matte_mask = image(&mut doc, "DeviceGray", &opaque, &[("Matte", Object::Array(vec![0.into(); 3]))]); + let plain = image(&mut doc, "DeviceRGB", &rgb_data, &[]); + let masked = image(&mut doc, "DeviceRGB", &rgb_data, &[("SMask", Object::Ref(plain_mask))]); + let matted = image(&mut doc, "DeviceRGB", &rgb_data, &[("SMask", Object::Ref(matte_mask))]); + let [under_gray, under_rgb, shared, in_form, nested] = [(); 5].map(|_| image(&mut doc, "DeviceRGB", &rgb_data, &[])); + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let spaces = |key: &str| { + let family = if key == "DefaultGray" { "CalGray" } else { "CalRGB" }; + let mut c = Dict::new(); + c.set(key.as_bytes().to_vec(), Object::Array(vec![Object::name(family), Object::Dict(white_point.clone())])); + Object::Dict(c) + }; + let draw = |names: &[&str]| names.iter().enumerate().map(|(i, n)| format!("q 200 0 0 80 6 {} cm /{n} Do Q ", 690 - 100 * i)).collect::(); + let form = |doc: &mut Document, im: ObjRef, spaces: Option| { + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(im)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + if let Some(spaces) = spaces { + resources.set(b"ColorSpace".to_vec(), spaces); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("XObject")); + f.set(b"Subtype".to_vec(), Object::name("Form")); + f.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + f.set(b"Resources".to_vec(), Object::Dict(resources)); + doc.add(Object::Stream(Stream::from_raw(f, draw(&["Im"]).into_bytes()))) + }; + // Drawn plainly first, then where DefaultGray is in force: the second walk must happen. + let plain_form = form(&mut doc, nested, None); + page(&mut doc, &[("A", plain), ("B", masked), ("C", matted), ("D", shared), ("E", plain_form)], &draw(&["A", "B", "C", "D", "E"])); + for (key, xobjects) in [("DefaultGray", vec![("A", under_gray), ("B", shared), ("C", plain_form)]), ("DefaultRGB", vec![("A", under_rgb)])] { + let names: Vec<&str> = xobjects.iter().map(|(n, _)| *n).collect(); + let p = page(&mut doc, &xobjects, &draw(&names)); + doc.update_dict(p, |d| { + let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); + res.set(b"ColorSpace".to_vec(), spaces(key)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + } + // A form with its own resources, DefaultGray among them, on a page without any. + let gray_form = form(&mut doc, in_form, Some(spaces("DefaultGray"))); + page(&mut doc, &[("F", gray_form)], "/F Do"); + let kept = [under_gray, under_rgb, shared, in_form, nested, matted]; + let before = kept.map(|r| stream(&doc, r)); + // Colour and grey settings that leave images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { color: keep, gray: keep, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 2), "{r:?}"); + for i in [plain, masked] { + let s = stream(&doc, i); + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1)), "{i:?}"); + } + for (i, b) in kept.iter().zip(&before) { + let s = stream(&doc, *i); + assert_eq!((&s.dict, &s.raw), (&b.dict, &b.raw), "{i:?}"); + } + + // A use that couldn't be followed (forms nested too deep) may be where DefaultGray is in force. + let mut doc = Document::new_empty(); + let img = image(&mut doc, "DeviceRGB", &rgb_data, &[]); + let deep = nested_forms(&mut doc, img, 13); + page(&mut doc, &[("Im", img), ("F", deep)], &draw(&["Im", "F"])); + let before = stream(&doc, img); + optimize(&mut doc, &settings).unwrap(); + assert_eq!(stream(&doc, img).raw, before.raw, "a use not followed"); +} + +/// An empty document, and a maker of images of only black and white as eight-bit `DeviceRGB`. +fn rgb_page_fixture() -> (Document, impl Fn(&mut Document) -> ObjRef) { + let (w, h) = (1200u32, 480u32); + let rgb_data = samples_of(&bilevel(w, h, false), w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let image = move |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + doc.add(Object::Stream(Stream::flate(d, &rgb_data))) + }; + (Document::new_empty(), image) +} + +/// `q … cm /N Do Q` for each name, one under the other. +fn draw_each(names: &[&str]) -> String { + names.iter().enumerate().map(|(i, n)| format!("q 200 0 0 80 6 {} cm /{n} Do Q ", 690 - 100 * i)).collect() +} + +/// An `/XObject` dictionary of `names`. +fn xobject_dict(names: &[(&str, ObjRef)]) -> Dict { + let mut x = Dict::new(); + for (n, r) in names { + x.set(n.as_bytes().to_vec(), Object::Ref(*r)); + } + x +} + +/// A stream drawing `/Im` (or nothing) whose `/Resources` is `resources`, with `extra` in its +/// dictionary: a form, an appearance or a tiling pattern. +fn drawing(doc: &mut Document, resources: Option, extra: &[(&str, Object)]) -> ObjRef { + let mut f = Dict::new(); + f.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + if let Some(resources) = resources { + f.set(b"Resources".to_vec(), resources); + } + for (k, v) in extra { + f.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::from_raw(f, draw_each(&["Im"]).into_bytes()))) +} + +fn form_entries() -> [(&'static str, Object); 2] { + [("Type", Object::name("XObject")), ("Subtype", Object::name("Form"))] +} + +/// Resources of `entries`. +fn resources_of(entries: Vec<(&str, Object)>) -> Dict { + let mut res = Dict::new(); + for (k, v) in entries { + res.set(k.as_bytes().to_vec(), v); + } + res +} + +/// Give page `p` an annotation whose normal appearance is `appearance`. +fn annotate(doc: &mut Document, p: ObjRef, appearance: ObjRef) { + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(appearance)); + let mut annotation = Dict::new(); + annotation.set(b"Subtype".to_vec(), Object::name("Square")); + annotation.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 100.into(), 100.into()])); + annotation.set(b"AP".to_vec(), Object::Dict(ap)); + let annotation = doc.add(Object::Dict(annotation)); + let mut annots = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.push(Object::Ref(annotation)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); +} + +/// `/DefaultGray` as a calibrated grey, in a `/ColorSpace` resource dictionary. +fn default_gray() -> Object { + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let mut c = Dict::new(); + c.set(b"DefaultGray".to_vec(), Object::Array(vec![Object::name("CalGray"), Object::Dict(white_point)])); + Object::Dict(c) +} + +/// Optimize with colour and grey settings that leave images alone, so only packing acts; the +/// report's images resampled and stored again. +fn pack_only(doc: &mut Document) -> (usize, usize) { + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let r = optimize(doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + (r.images_resampled, r.images_recompressed) +} + +/// Black-and-white RGB becomes grey for every use at once, so it stays RGB, byte for byte, when +/// any use may be beyond the page content and forms the optimizer reads, where other default +/// colour spaces may be in force: drawn by an annotation's appearance whose resources make +/// `/DefaultGray` the grey it would be drawn in; by a tiling pattern; through an `/XObject` +/// dictionary, or a form, that a page shares with such an appearance; +/// referred to with another generation by the page than by such an appearance; drawn plainly on +/// one page and, named with another generation, on a page whose resources set `/DefaultGray`; +/// drawn plainly and only listed (not drawn) where `/DefaultGray` is in force, under the same +/// generation or another, as a Type3 glyph without resources could draw it there; or held in an +/// array elsewhere (another image's `/Alternates`). +#[test] +fn black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb() { + let (mut doc, image) = rgb_page_fixture(); + let [in_annotation, in_pattern, shared_xobjects, shared_form, other_generation, generation_under_defaults, listed, listed_as_1, in_array] = + [(); 9].map(|_| image(&mut doc)); + let gray_resources = |im: Object| Some(Object::Dict(resources_of(vec![("XObject", im), ("ColorSpace", default_gray())]))); + let direct = |im: ObjRef| Object::Dict(xobject_dict(&[("Im", im)])); + + let appearance = drawing(&mut doc, gray_resources(direct(in_annotation)), &form_entries()); + let pattern_entries = + [("PatternType", 1.into()), ("PaintType", 1.into()), ("TilingType", 1.into()), ("XStep", 612.into()), ("YStep", 792.into())]; + let pattern = drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", direct(in_pattern))]))), &pattern_entries); + let p = page(&mut doc, &[("A", in_annotation), ("B", in_pattern)], &(draw_each(&["A", "B"]) + "/Pattern cs /P scn 0 0 10 10 re f")); + annotate(&mut doc, p, appearance); + doc.update_dict(p, |d| { + let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); + let mut patterns = Dict::new(); + patterns.set(b"P".to_vec(), Object::Ref(pattern)); + res.set(b"Pattern".to_vec(), Object::Dict(patterns)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + + // An /XObject dictionary of its own, read for a page, and a form drawn by a page, each also + // used by an appearance under DefaultGray. + let x = doc.add(Object::Dict(xobject_dict(&[("Im", shared_xobjects)]))); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(resources_of(vec![("XObject", Object::Ref(x))])))).unwrap(); + let appearance = drawing(&mut doc, gray_resources(Object::Ref(x)), &form_entries()); + annotate(&mut doc, q, appearance); + let form = drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", direct(shared_form))]))), &form_entries()); + let q = page(&mut doc, &[("F", form)], &draw_each(&["F"])); + let appearance = drawing(&mut doc, gray_resources(Object::Dict(xobject_dict(&[("Im", form)]))), &form_entries()); + annotate(&mut doc, q, appearance); + + // The page names it with generation 1 (it is 0); the appearance with 0. + let q = page(&mut doc, &[("Im", ObjRef { generation: 1, ..other_generation })], &draw_each(&["Im"])); + let appearance = drawing(&mut doc, gray_resources(direct(other_generation)), &form_entries()); + annotate(&mut doc, q, appearance); + + // Drawn plainly as 0, and as 1 where DefaultGray is in force. + page(&mut doc, &[("Im", generation_under_defaults)], &draw_each(&["Im"])); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + let gray = resources_of(vec![ + ("XObject", Object::Dict(xobject_dict(&[("Im", ObjRef { generation: 1, ..generation_under_defaults })]))), + ("ColorSpace", default_gray()), + ]); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(gray))).unwrap(); + + // Drawn plainly, and only listed where DefaultGray is in force, as 0 and as 1. + page(&mut doc, &[("A", listed), ("B", listed_as_1)], &draw_each(&["A", "B"])); + let q = page(&mut doc, &[], ""); + let gray = resources_of(vec![ + ("XObject", Object::Dict(xobject_dict(&[("A", listed), ("B", ObjRef { generation: 1, ..listed_as_1 })]))), + ("ColorSpace", default_gray()), + ]); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(gray))).unwrap(); + + // Another image drawn on the page lists it as an alternate. + let mut alternate = Dict::new(); + alternate.set(b"Image".to_vec(), Object::Ref(in_array)); + let holder = image(&mut doc); + doc.update_dict(holder, |d| { + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Alternates".to_vec(), Object::Array(vec![Object::Dict(alternate)])); + }) + .unwrap(); + page(&mut doc, &[("Im", in_array), ("Alt", holder)], &draw_each(&["Im"])); + + let kept = [in_annotation, in_pattern, shared_xobjects, shared_form, other_generation, generation_under_defaults, listed, listed_as_1, in_array]; + let before = kept.map(|r| stream(&doc, r)); + pack_only(&mut doc); + for (i, b) in kept.iter().zip(&before) { + let s = stream(&doc, *i); + assert_eq!((&s.dict, &s.raw), (&b.dict, &b.raw), "{i:?}"); + } +} + +/// Black-and-white RGB that the page content and forms the optimizer reads are the only uses of +/// becomes grey, however its resources are written: drawn on two pages, by a form, under two +/// names on one page; through resources that are an object of their own, shared by two pages, +/// whose `/XObject` dictionary is one too; through resources an object of their own with a direct +/// `/XObject` dictionary; through resources a page inherits from the page tree; by a form whose +/// `/Resources` is a reference; by a form without resources, through its page's; listed under a +/// second name nothing draws; through resources an object of their own that a page without +/// content lists too; and with an object nothing in the document reaches referring to it as well. +#[test] +fn black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey() { + let (mut doc, image) = rgb_page_fixture(); + let [everywhere, shared_resources, resources_object, inherited, form_resources_object, no_resources, spare_name, no_contents, unreachable] = + [(); 9].map(|_| image(&mut doc)); + let set_resources = |doc: &mut Document, q: ObjRef, res: Object| doc.update_dict(q, |d| d.set(b"Resources".to_vec(), res)).unwrap(); + + let form = + drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", everywhere)])))]))), &form_entries()); + page(&mut doc, &[("E", everywhere), ("F", form)], &draw_each(&["E", "F"])); + page(&mut doc, &[("E", everywhere), ("G", everywhere)], &draw_each(&["E", "G"])); + + let x = doc.add(Object::Dict(xobject_dict(&[("Im", shared_resources)]))); + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Ref(x))]))); + for _ in 0..2 { + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + } + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", resources_object)])))]))); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + + let q = page(&mut doc, &[], &draw_each(&["I"])); + doc.update_dict(q, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + set_resources(&mut doc, pages, Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("I", inherited)])))]))); + + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", form_resources_object)])))]))); + let form = drawing(&mut doc, Some(Object::Ref(res)), &form_entries()); + page(&mut doc, &[("F", form)], &draw_each(&["F"])); + let form = drawing(&mut doc, None, &form_entries()); + page(&mut doc, &[("F", form), ("Im", no_resources)], &draw_each(&["F"])); + + page(&mut doc, &[("C", spare_name), ("D", spare_name)], &draw_each(&["C"])); + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", no_contents)])))]))); + for drawn in [true, false] { + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + if !drawn { + doc.update_dict(q, |d| { + d.remove(b"Contents"); + }) + .unwrap(); + } + } + + page(&mut doc, &[("Im", unreachable)], &draw_each(&["Im"])); + // A form nothing refers to, drawing it under DefaultGray. + let orphan = resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", unreachable)]))), ("ColorSpace", default_gray())]); + drawing(&mut doc, Some(Object::Dict(orphan)), &form_entries()); + + let grey = [everywhere, shared_resources, resources_object, inherited, form_resources_object, no_resources, spare_name, no_contents, unreachable]; + assert_eq!(pack_only(&mut doc), (0, grey.len())); + for i in grey { + let s = stream(&doc, i); + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1)), "{i:?}"); + } +} + +/// LibreOffice writes one resources object for a page and its `/Pages` node, and a transparency +/// group's form whose `/Resources` is that same object, which the object lists in turn. Black- +/// and-white RGB in it becomes grey: every reference is one the page walk reads, the cycle +/// included. Drawn from outside as well, by an appearance under `/DefaultGray` that draws the +/// form, it stays RGB. +#[test] +fn black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey() { + for outside in [false, true] { + let (mut doc, image) = rgb_page_fixture(); + let img = image(&mut doc); + let res = doc.add(Object::Dict(Dict::new())); + let form = drawing(&mut doc, Some(Object::Ref(res)), &form_entries()); + doc.update_dict(res, |d| d.set(b"XObject".to_vec(), Object::Dict(xobject_dict(&[("Im", img), ("Tr", form)])))).unwrap(); + let p = page(&mut doc, &[], &draw_each(&["Im", "Tr"])); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + for holder in [p, pages] { + doc.update_dict(holder, |d| d.set(b"Resources".to_vec(), Object::Ref(res))).unwrap(); + } + if outside { + let gray = resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", form)]))), ("ColorSpace", default_gray())]); + let appearance = drawing(&mut doc, Some(Object::Dict(gray)), &form_entries()); + annotate(&mut doc, p, appearance); + } + let before = stream(&doc, img); + pack_only(&mut doc); + let s = stream(&doc, img); + if outside { + assert_eq!((&s.dict, &s.raw), (&before.dict, &before.raw), "drawn from outside too"); + } else { + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1))); + } + } +} + +/// Dense local mixed-sample patches are retained conservatively by the monochrome settings. +/// One-bit Flate, CCITT and masks preserve samples; eight-bit bilevel inputs may be packed +/// losslessly. Disable the independent gray/color reduction for the exact-render comparison: +/// those settings can otherwise choose smaller grayscale/JPEG output for eight-bit sources. +/// The Retain case separately checks that gray/color reduction remains available. +#[test] +fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let g4 = |doc: &mut Document, entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode")])); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Dict(parms)])); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&text, w, h).unwrap()))) + }; + // Group 4 behind ASCIIHexDecode, its parameters second in the array. + let behind_hex = |doc: &mut Document| { + let r = g4(doc, &grey); + let s = stream(doc, r); + let mut d = s.dict.clone(); + let parms = d.get(b"DecodeParms").and_then(Object::as_array).and_then(|a| a.first()).cloned().unwrap(); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("CCITTFaxDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, parms])); + let hex = s.raw.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + doc.set(r, Object::Stream(Stream::from_raw(d, hex))); + r + }; + let flate = mono_image(&mut doc, w, h, &text, None, &grey); + let fax = g4(&mut doc, &grey); + let hex_fax = behind_hex(&mut doc); + let hex_at_400 = behind_hex(&mut doc); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let eight = doc.add(Object::Stream(Stream::flate(d.clone(), &eight_bit(&text, w, h)))); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + let rgb = doc.add(Object::Stream(Stream::flate(d.clone(), &samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8)))); + d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0].map(Object::Int).to_vec())); + let rgb_inverted = doc.add(Object::Stream(Stream::flate(d, &samples_of(&text, w, h, [&[255, 255, 255], &[0, 0, 0]], 8)))); + let at_400 = mono_image(&mut doc, w, h, &text, None, &grey); + let fax_at_400 = g4(&mut doc, &grey); + let mut sm = Dict::new(); + sm.set(b"Subtype".to_vec(), Object::name("Image")); + sm.set(b"Width".to_vec(), Object::Int(2)); + sm.set(b"Height".to_vec(), Object::Int(1)); + sm.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + sm.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let smask = doc.add(Object::Stream(Stream::flate(sm, &[255, 200]))); + let soft = mono_image(&mut doc, w, h, &text, None, &[grey[0].clone(), grey[1].clone(), ("SMask", Object::Ref(smask))]); + // 144 × 57.6 pt is 2 × 0.8 in: 600 ppi. 216 × 86.4 pt is 400 ppi. + page( + &mut doc, + &[ + ("A", flate), + ("B", fax), + ("C", mask), + ("D", eight), + ("E", at_400), + ("F", fax_at_400), + ("G", soft), + ("H", hex_fax), + ("I", hex_at_400), + ("J", rgb), + ("K", rgb_inverted), + ], + "q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 0 0 1 rg 144 0 0 57.6 380 700 cm /C Do Q \ + q 144 0 0 57.6 20 600 cm /D Do Q q 216 0 0 86.4 200 580 cm /E Do Q q 216 0 0 86.4 20 480 cm /F Do Q \ + q 144 0 0 57.6 260 480 cm /G Do Q q 144 0 0 57.6 430 600 cm /H Do Q q 216 0 0 86.4 20 360 cm /I Do Q q 144 0 0 57.6 260 380 cm /J Do Q \ + q 144 0 0 57.6 430 480 cm /K Do Q", + ); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let size = |doc: &Document, r: ObjRef| { + let d = stream(doc, r).dict; + (d.int(b"Width").unwrap(), d.int(b"Height").unwrap()) + }; + // This ragged text fixture has dense local mixed-sample patches. The conservative + // tone guard retains it too: a false positive costs size, not document content. + + // The grey and colour settings left alone too: an image of more bits would otherwise take + // them when smaller. + let mut off = doc.clone(); + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let mono = MonoSettings { downsample: false, ..MonoSettings::REDUCE }; + let r = optimize(&mut off, &Settings { mono, gray: keep, color: keep, ..Settings::default() }).unwrap(); + assert_eq!(r.images_resampled, 0, "{r:?}"); + assert!([flate, fax, mask, eight, hex_fax, rgb, rgb_inverted].iter().all(|i| size(&off, *i) == (1200, 480))); + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + // Not the inverted RGB: the colour settings leave an image with a decode array alone. + assert_eq!(r.images_resampled, 2, "{r:?}"); + assert_eq!(stream(&kept, flate).dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "Retain: Flate stays Flate"); + for i in [fax, hex_fax] { + assert_eq!(stream(&kept, i).dict.get(b"Filter"), stream(&doc, i).dict.get(b"Filter"), "Retain keeps the exact filter chain"); + } + assert_eq!(raw(&kept, hex_at_400), raw(&doc, hex_at_400), "and nothing else changes"); + for i in [flate, fax, hex_fax] { + assert_eq!((size(&kept, i), kept.decoded_image(&stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}"); + } + let bits = [eight, rgb, rgb_inverted].map(|i| stream(&kept, i).dict.int(b"BitsPerComponent")); + assert_eq!(bits, [Some(8); 3], "Retain leaves eight bits to the grey and colour settings"); + + let untouched = [fax_at_400].map(|r| raw(&doc, r)); + let hex_len = raw(&doc, hex_at_400).len(); + let r = optimize(&mut doc, &Settings { gray: keep, color: keep, ..Settings::default() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 9), "{r:?}"); + assert_eq!(stream(&doc, rgb).dict.get(b"ColorSpace"), Some(&Object::name("DeviceGray"))); + for i in [flate, fax, mask, eight, hex_fax, rgb] { + let s = stream(&doc, i); + assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((1200, 480), if i == mask { None } else { Some(1) })); + assert!(matches!(s.filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "{i:?}"); + assert_eq!(doc.decoded_image(&s).unwrap(), text.clone(), "{i:?}"); + } + assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + // Grey now, with grey's decode pair, which makes 1 black: its ties are black too. + let s = stream(&doc, rgb_inverted); + let inverting = Object::Array(vec![1.into(), 0.into()]); + assert_eq!( + (size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")), + ((1200, 480), Some(&Object::name("DeviceGray")), Some(&inverting)) + ); + assert_eq!(doc.decoded_image(&s).unwrap(), text.iter().map(|b| !b).collect::>(), "the same picture, ties black"); + for i in [at_400, soft, hex_at_400] { + assert_eq!((size(&doc, i), stream(&doc, i).dict.name(b"Filter")), ((1200, 480), Some(&b"CCITTFaxDecode"[..])), "{i:?}"); + } + assert!(raw(&doc, hex_at_400).len() < hex_len, "the ASCIIHexDecode is dropped"); + assert_eq!(doc.decoded_image(&stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept"); + assert_eq!([fax_at_400].map(|r| raw(&doc, r)), untouched); + let after = render(&doc); + assert_eq!(after, before, "conservative retention preserves the rendered page exactly"); +} + +/// Monochrome downsampling leaves a halftone or a dither at its resolution, as most new pixels +/// would cover both black and white and taking the sample covering more would turn its tones +/// solid: a 50% checkerboard, a 25% ordered dither and an error-diffused one, each at 600 ppi. +/// Dense ragged text is retained too. Rows wider than a fax row (`MAX_CCITT_COLUMNS`) +/// are never averaged: the averaging keeps a counter for each column. +#[test] +fn monochrome_downsampling_leaves_halftones_and_overwide_rows_at_their_resolution() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let pattern = |white: &dyn Fn(usize, usize) -> bool| { + let mut data = vec![0u8; stride * h as usize]; + for y in 0..h as usize { + for x in 0..w as usize { + if white(x, y) { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + } + } + data + }; + let diffused = dithered(w, h); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let mut doc = Document::new_empty(); + let text = mono_image(&mut doc, w, h, &bilevel(w, h, false), None, &grey); + let halftones = + [pattern(&|x, y| (x + y) % 2 == 0), pattern(&|x, y| x % 2 == 1 || y % 2 == 1), pattern(&|x, y| diffused[y * w as usize + x] == 255)] + .map(|data| mono_image(&mut doc, w, h, &data, None, &grey)); + // 144 × 57.6 pt is 600 ppi. + let names = ["T", "A", "B", "C"]; + let content: String = (0..4).map(|i| format!("q 144 0 0 57.6 {} 600 cm /{} Do Q ", 6 + 150 * i, names[i])).collect(); + page(&mut doc, &names.into_iter().zip([text, halftones[0], halftones[1], halftones[2]]).collect::>(), &content); + optimize(&mut doc, &Settings::default()).unwrap(); + let size = |r: ObjRef| { + let d = stream(&doc, r).dict; + (d.int(b"Width"), d.int(b"Height")) + }; + assert_eq!(size(text), (Some(1200), Some(480)), "ambiguous dense text is retained conservatively"); + for (i, r) in halftones.iter().enumerate() { + assert_eq!(size(*r), (Some(1200), Some(480)), "halftone {i}"); + } + + for (columns, resampled) in [(pdfcraft_filters::MAX_CCITT_COLUMNS, 1), (pdfcraft_filters::MAX_CCITT_COLUMNS + 1, 0)] { + let mut doc = Document::new_empty(); + let white = vec![0xFF; (columns as usize).div_ceil(8) * 2]; + let img = mono_image(&mut doc, columns, 2, &white, None, &grey); + // Far above 450 ppi both ways. + page(&mut doc, &[("A", img)], "q 612 0 0 0.1 0 400 cm /A Do Q"); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, resampled, "{columns} columns: {r:?}"); + } +} + +/// A fax image behind a decompression bomb is left alone: the filters before its decoder may +/// produce no more than a fax code of its size can take (about a byte a pixel), not the 1 GiB +/// limit. Here a Group 4 page at 600 ppi behind Flate, with four megabytes of zeros after its +/// code; without them it is averaged down. +#[test] +fn a_fax_image_behind_a_decompression_bomb_is_left_alone() { + let (w, h) = (1200u32, 480u32); + let g4 = pdfcraft_filters::encode_g4(&bilevel(w, h, false), w, h).unwrap(); + for padding in [0, 4 << 20] { + let mut doc = Document::new_empty(); + let mut code = g4.clone(); + code.resize(g4.len() + padding, 0); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&code)))); + // 144 × 57.6 pt is 600 ppi. + page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q"); + let before = stream(&doc, img).raw; + let r = optimize(&mut doc, &Settings::default()).unwrap(); + if padding == 0 { + assert_eq!(r.images_resampled, 0, "ambiguous local tones are retained: {r:?}"); + assert_eq!(doc.decoded_image(&stream(&doc, img)).unwrap(), bilevel(w, h, false)); + } else { + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(stream(&doc, img).raw, before); + } + } +} + +/// A CCITT image `w` × `h` coded as Group 4 from `bits`, behind ASCIIHexDecode when `hex`. +fn g4_image(doc: &mut Document, bits: &[u8], (w, h): (u32, u32), hex: bool) -> ObjRef { + let g4 = pdfcraft_filters::encode_g4(bits, w, h).unwrap(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + let data = if hex { + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("CCITTFaxDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + g4.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect() + } else { + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + g4 + }; + doc.add(Object::Stream(Stream::from_raw(d, data))) +} + +/// What Monochrome Images leaves alone and what it may still do. A Group 4 halftone above 450 ppi +/// isn't resampled, so it stays byte for byte: alone, by default; behind ASCIIHexDecode, with +/// Retain (by default it is stored again as Group 4 alone). Under Retain an ambiguous Flate scan +/// keeps its bytes and resolution though Group 4 would be smaller. A busy Group 4 image behind ASCIIHexDecode, whose +/// code is more than three times its raster, decodes within a fax code's bound and is stored +/// again as Group 4 alone. Sixteen-bit grey of only 0x0000 and 0xFFFF isn't eight-bit black and +/// white, and keeps its bytes. +#[test] +fn monochrome_images_keep_what_they_should() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let checker: Vec = (0..h as usize).flat_map(|y| vec![if y % 2 == 0 { 0xAA } else { 0x55 }; stride]).collect(); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + // Twelve dark bands a row, their edges ragged by up to five samples, as a scan's are. + let mut ragged = vec![0xFFu8; stride * h as usize]; + for y in 0..h as usize { + for band in 0..12 { + let (x0, x1) = (band * 100 + (next() % 5) as usize, band * 100 + 50 + (next() % 5) as usize); + for x in x0..x1.min(w as usize) { + ragged[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let busy: Vec = (0..256).flat_map(|y| [if y % 2 == 0 { 0xAA } else { 0xFF }; 32]).collect(); + let build = || { + let mut doc = Document::new_empty(); + let alone = g4_image(&mut doc, &checker, (w, h), false); + let wrapped = g4_image(&mut doc, &checker, (w, h), true); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let scan = mono_image(&mut doc, w, h, &ragged, None, &grey); + let fax = g4_image(&mut doc, &busy, (256, 256), true); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(16)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let samples: Vec = eight_bit(&bilevel(w, h, false), w, h).iter().flat_map(|v| [*v, *v]).collect(); + let deep = doc.add(Object::Stream(Stream::flate(d, &samples))); + // 144 × 57.6 pt is 600 ppi; 46.08 pt for 256 samples is 400 ppi. + page( + &mut doc, + &[("A", alone), ("B", wrapped), ("C", scan), ("D", fax), ("E", deep)], + "q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 144 0 0 57.6 20 600 cm /C Do Q \ + q 46.08 0 0 46.08 200 600 cm /D Do Q q 144 0 0 57.6 20 500 cm /E Do Q", + ); + (doc, [alone, wrapped, scan, fax, deep]) + }; + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let filters = |doc: &Document, r: ObjRef| stream(doc, r).filters().len(); + + let (mut doc, [alone, wrapped, scan, fax, deep]) = build(); + let before = [alone, deep].map(|r| raw(&doc, r)); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey"); + assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default"); + assert_eq!(stream(&doc, scan).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "the scan: Group 4 is smaller"); + assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image"); + + let (mut doc, [_, wrapped, scan, _, _]) = build(); + let before = raw(&doc, wrapped); + let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(raw(&doc, wrapped), before, "Retain: a wrapped Group 4 halftone stays as it is"); + let s = stream(&doc, scan); + assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(1200), Some(&b"FlateDecode"[..])), "Retain: {r:?}"); + assert_eq!(doc.decoded_image(&s).unwrap(), ragged, "Retain preserves every sample"); +} + +/// `rows` coded as CCITT Group 3, one-dimensional (`/K 0`): each row its runs in turn, white first, +/// with ITU-T T.4's terminating codes for the few lengths used here. With `on_bytes`, each row +/// starts on a byte: `Some(false)`, with no end-of-line codes (TIFF's Modified Huffman); `Some(true)`, +/// after an end-of-line code that the fill ends on a byte (T.4's fill, as TIFF writes it). +fn group_3(rows: &[&[u32]], mixed: bool, on_bytes: Option) -> Vec { + let code = |white: bool, run: u32| match (white, run) { + (true, 0) => "00110101", + (true, 1) => "000111", + (false, 1) => "010", + (true, 8) => "10011", + (true, 16) => "101010", + (true, 32) => "00011011", + (false, 8) => "000101", + (false, 16) => "0000010111", + (false, 32) => "000001101010", + _ => panic!("no code here for a run of {run}"), + }; + let mut bits = String::new(); + for row in rows { + let codes = mixed || on_bytes == Some(true); + if on_bytes.is_some() { + // The end-of-line code is 12 bits. + while !(bits.len() + if codes { 12 } else { 0 }).is_multiple_of(8) { + bits.push('0'); + } + } + if codes { + bits.push_str("000000000001"); + } + // Mixed coding (/K above 0): a 1 bit after the code, the row one-dimensional. + if mixed { + bits.push('1'); + } + row.iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() +} + +/// A Group 3 fax image is stored again as Group 4 when that is smaller, the same picture (to the +/// pixel on the page, against the picture in Flate); with Retain it stays Group 3. One-dimensional (/K 0) and mixed (/K 1, with +/// end-of-line codes) alike, and rows on bytes, with no codes and with codes the fill ends on a byte +/// (without `/EndOfLine`, which reading by the flag misread). +#[test] +fn group_3_fax_images_become_group_4() { + let (w, h) = (64u32, 64u32); + let runs: Vec<&[u32]> = [&[32, 32][..], &[16; 4], &[8; 8], &[0, 16, 16, 32]].into_iter().flat_map(|r| std::iter::repeat_n(r, 16)).collect(); + // White is 1, as CCITTFaxDecode gives it without /BlackIs1. + let mut bits = Vec::new(); + for row in &runs { + let mut packed = [0u8; 8]; + let mut x = 0; + for (i, run) in row.iter().enumerate() { + for p in x..x + *run as usize { + if i % 2 == 0 { + packed[p / 8] |= 0x80 >> (p % 8); + } + } + x += *run as usize; + } + bits.extend(packed); + } + for (k, on_bytes) in [(0, None), (1, None), (0, Some(false)), (0, Some(true)), (1, Some(true))] { + let g3 = group_3(&runs, k > 0, on_bytes); + let (end_of_line, encoded_byte_align) = (k > 0 && on_bytes.is_none(), on_bytes.is_some()); + let p = pdfcraft_filters::CcittParams { k, columns: w.into(), end_of_line, encoded_byte_align, ..Default::default() }; + assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture"); + + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + // The page with the picture in Flate, as it should look. + let mut control = Document::new_empty(); + let flate = control.add(Object::Stream(Stream::flate(d.clone(), &bits))); + page(&mut control, &[("A", flate)], "q 64 0 0 64 100 600 cm /A Do Q"); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(k)); + parms.set(b"EndOfLine".to_vec(), Object::Bool(end_of_line)); + parms.set(b"EncodedByteAlign".to_vec(), Object::Bool(encoded_byte_align)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + parms.set(b"Rows".to_vec(), Object::Int(h.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone()))); + page(&mut doc, &[("A", fax)], "q 64 0 0 64 100 600 cm /A Do Q"); + + let mut kept = doc.clone(); + optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(stream(&kept, fax).raw[..], g3[..], "/K {k}, on bytes {on_bytes:?}: Retain keeps Group 3"); + + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 1), "/K {k}, on bytes {on_bytes:?}: {r:?}"); + let s = stream(&doc, fax); + assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "/K {k}, on bytes {on_bytes:?}: Group 4"); + assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len()); + assert_eq!(doc.decoded_image(&s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture"); + assert!(render(&doc) == render(&control), "/K {k}, on bytes {on_bytes:?}: the page looks as the picture does"); + } +} + +/// The images of a document that are images, by object number. +fn image_refs(doc: &Document) -> Vec { + doc.object_numbers() + .into_iter() + .map(|num| ObjRef { num, generation: doc.generation(num) }) + .filter(|r| matches!(&*doc.get(*r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"))) + .collect() +} + +/// Black-and-white images as lossless JBIG2: the page looks the same to the pixel (so +/// JBIG2Decode gives black as 0, as the encoder assumes), and scan-like text comes out smaller +/// than as Group 4. JBIG2Decode is PDF 1.4, so a PDF 1.3 document gets `/Version /1.4` in its +/// catalog. +#[test] +fn black_and_white_images_become_jbig2() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let indexed_cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])), + ]); + let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]); + page( + &mut doc, + &[("G", gray), ("M", mask), ("I", indexed)], + "q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q", + ); + // The same document, as PDF 1.3. + let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + bytes[..8].copy_from_slice(b"%PDF-1.3"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.version(), "1.3"); + let before = render(&doc); + + let mut g4 = doc.clone(); + let as_g4 = optimize(&mut g4, &Settings::default()).unwrap(); + let mut jbig2 = doc.clone(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut jbig2, &settings).unwrap(); + assert_eq!(r.images_recompressed, 3); + assert!(r.image_bytes_after < as_g4.image_bytes_after, "JBIG2 {} bytes, Group 4 {}", r.image_bytes_after, as_g4.image_bytes_after); + let images: Vec = image_refs(&jbig2).into_iter().map(|i| stream(&jbig2, i)).collect(); + assert_eq!(images.len(), 3); + for s in &images { + assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false)); + } + assert!(images.iter().any(|s| s.dict.get(b"Decode") == Some(&decode)), "the decode array stays"); + let indexed_kept = + |s: &Stream| s.dict.get(b"ColorSpace").and_then(Object::as_array).and_then(|a| a.first()).and_then(Object::as_name) == Some(b"Indexed"); + assert!(images.iter().any(indexed_kept), "the colour space stays"); + let catalog = jbig2.root().and_then(|r| jbig2.get(r).as_dict().cloned()).unwrap(); + assert_eq!(catalog.name(b"Version"), Some(&b"1.4"[..])); + assert!(g4.root().and_then(|r| g4.get(r).as_dict().cloned()).is_some_and(|c| !c.contains(b"Version")), "Group 4 is PDF 1.0"); + assert!(render(&jbig2) == before, "the page looks the same"); + // The integration also supports changing an already-CCITT image to lossless JBIG2. + let mut from_g4 = g4.clone(); + let converted = optimize(&mut from_g4, &settings).unwrap(); + assert_eq!(converted.images_recompressed, 3); + assert!(render(&from_g4) == before, "CCITT decoded and re-encoded as JBIG2 keeps the picture"); +} + +/// A catalog `/Version` given by reference is read through it: a PDF 1.3 file whose catalog says +/// 1.7 by reference keeps 1.7 when its images become JBIG2, rather than being lowered to 1.4; one +/// whose catalog says 1.3 by reference gets 1.4. +#[test] +fn a_catalog_version_given_by_reference_is_read() { + for (catalog, after) in [("1.7", "1.7"), ("1.3", "1.4")] { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 300 0 0 120 20 660 cm /Im Do Q"); + let version = doc.add(Object::name(catalog)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::Ref(version))).unwrap(); + let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + bytes[..8].copy_from_slice(b"%PDF-1.3"); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 1); + let root = doc.root().unwrap(); + let v = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()).unwrap(); + assert_eq!(doc.resolve(&v).as_name(), Some(after.as_bytes()), "catalog {catalog} by reference"); + } +} + +/// Text as a renderer draws it at 300 dpi: lines 48 rows apart of a few letter shapes, each +/// `#` a 3 × 3 block, three columns apart with a wider gap between words, every copy of a letter +/// the same to the pixel. +fn rendered_text(w: u32, h: u32) -> Vec { + const LETTERS: [&[&str]; 5] = [ + &["##", "##", "##", "##", "##", "##", "##", "##", "##", "##"], + &["......", ".####.", "##..##", "##..##", "##..##", "##..##", ".####."], + &["##....", "##....", "##....", "#####.", "##..##", "##..##", "##..##", "#####."], + &["##..##", "##..##", "##..##", "##..##", ".#####"], + &["##", "##", "..", "##", "##", "##", "##", "##"], + ]; + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for baseline in (32..h as usize).step_by(48) { + let mut x = 8; + while x + 20 < w as usize { + let letter = LETTERS[(next() % 5) as usize]; + for (dy, row) in letter.iter().enumerate() { + for (dx, c) in row.bytes().enumerate() { + for (py, px) in (0..9).map(|k| (baseline + 1 + 3 * dy + k / 3 - 3 * letter.len(), x + 3 * dx + k % 3)) { + if c == b'#' { + data[py * stride + px / 8] &= !(0x80 >> (px % 8)); + } + } + } + } + x += 3 * letter[0].len() + 3 + if next() % 5 == 0 { 12 } else { 0 }; + } + } + data +} + +/// Text whose letters repeat exactly, as rendered text does, becomes JBIG2 symbols: each letter +/// stored once and its copies placed, several times smaller than JBIG2's generic coding of the +/// same image, and the page looks the same. +#[test] +fn rendered_text_becomes_jbig2_symbols() { + let mut doc = Document::new_empty(); + let (w, h) = (600, 480); + let text = rendered_text(w, h); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + // One image pixel a device pixel at scale 1, so a wrong pixel shows. + page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q"); + let before = render(&doc); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 1); + let s = stream(&doc, im); + assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + let generic = pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap(); + assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, generic {}", s.raw.len(), generic.len()); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// A whole 300 dpi letter page of rendered text, more marks than one JBIG2 text region of the +/// decoder PdfCraft renders with takes, still looks the same as JBIG2 symbols. +#[test] +fn a_dense_page_of_rendered_text_looks_the_same_as_jbig2_symbols() { + let mut doc = Document::new_empty(); + let (w, h) = (2550, 3300); + let text = rendered_text(w, h); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 612 0 0 792 0 0 cm /Im Do Q"); + let before = render(&doc); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 1); + let s = stream(&doc, im); + assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + assert!(s.raw.len() * 2 < pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap().len(), "symbol coded"); + // The copies take several text regions (segment type 7, ISO/IEC 14492 7.3), read from the + // segment headers (7.2): number, flags, referred-to count and numbers, page, data length. + let mut regions = 0; + let mut at = 0; + while let Some(header) = s.raw.get(at..at + 6) { + let refers = usize::from(header[5] >> 5); + let length = u32::from_be_bytes(s.raw[at + 7 + refers..at + 11 + refers].try_into().unwrap()) as usize; + regions += usize::from(header[4] & 0x3F == 7); + at += 11 + refers + length; + } + assert!(regions > 1, "{regions} text regions"); + assert!(render(&doc) == before, "the page looks the same"); +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); @@ -925,6 +2835,23 @@ fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef { inner } +#[test] +fn shared_forms_are_measured_in_each_pages_physical_units() { + for reverse in [false, true] { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 3, false, None); + let outer = nested_forms(&mut doc, img, 3); + let normal = page(&mut doc, &[("F", outer)], "q 72 0 0 72 0 0 cm /F Do Q"); + let scaled = page(&mut doc, &[("F", outer)], "q 72 0 0 72 0 0 cm /F Do Q"); + doc.update_dict(scaled, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap(); + let pages = if reverse { [scaled, normal] } else { [normal, scaled] }; + assert_eq!(effective_resolutions(&doc, &pages)[&img], 72.0, "reverse={reverse}"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(720)); + } +} + #[test] fn a_deeply_nested_form_is_measured_at_its_largest_use() { // 1200 px drawn 1 in wide (1200 ppi) and then 8 in wide (150 ppi): 150 ppi, so it isn't resampled. @@ -1022,3 +2949,531 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { let refs = pdfcraft_annot::page_refs(&doc).unwrap(); assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } + +/// Each form entry counts against the page's form allowance, even for a form that decodes to +/// nothing and so costs no decoded bytes. +#[test] +fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 1200, 1200, 3, false, None); + let empty = form(&mut doc, None, ""); + let draws = "/E Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1); + page(&mut doc, &[("E", empty), ("Im", img)], &format!("q 72 0 0 72 36 36 cm /Im Do Q {draws}")); + let refs = pdfcraft_annot::page_refs(&doc).unwrap(); + assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); +} + +#[test] +fn compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption() { + let mut base = Document::new_empty(); + page(&mut base, &[], "BT /F1 12 Tf 72 720 Td (Compatibility test) Tj ET"); + let root = base.root().unwrap(); + base.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.6"))).unwrap(); + + for (compat, expected) in [ + (Compatibility::Pdf1_4, "1.4"), + (Compatibility::Pdf1_5, "1.5"), + (Compatibility::Pdf1_6, "1.6"), + (Compatibility::Pdf1_7, "1.7"), + (Compatibility::Pdf2_0, "2.0"), + ] { + let mut doc = base.clone(); + let settings = Settings { compatibility: compat, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + assert_eq!(doc.version(), expected); + assert!(!doc.get(root).as_dict().unwrap().contains(b"Version"), "overriding catalog /Version is cleared"); + let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap(); + assert!(bytes.starts_with(format!("%PDF-{expected}\n").as_bytes()), "{expected}"); + let has_objstm = bytes.windows(7).any(|w| w == b"/ObjStm"); + assert_eq!(has_objstm, compat != Compatibility::Pdf1_4, "{compat:?}"); + let info = pdfcraft_render::inspect(Arc::new(bytes), None).unwrap(); + assert_eq!(info.pdf_version, expected); + } + + // Disabling object_streams on PDF 1.7 writes a classic cross-reference table. + let mut doc = base.clone(); + let settings = Settings { compatibility: Compatibility::Pdf1_7, object_streams: false, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.7\n") && !bytes.windows(7).any(|w| w == b"/ObjStm")); + + // Encryption that requires a newer PDF version than the chosen compatibility is refused. + let mut aes256 = base.clone(); + aes256 + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "u", + owner_password: "o", + permissions: -1, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + assert!(matches!( + optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }), + Err(OptimizeError::IncompatibleEncryption { required: "1.7", chosen: "1.6" }) + )); + optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf2_0, ..Settings::default() }).unwrap(); + assert_eq!(aes256.version(), "2.0"); + + let mut aes128 = base; + aes128 + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "u", + owner_password: "o", + permissions: -1, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + assert!(matches!( + optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_5, ..Settings::default() }), + Err(OptimizeError::IncompatibleEncryption { required: "1.6", chosen: "1.5" }) + )); + optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }).unwrap(); +} + +#[test] +fn image_resolution_keeps_comments_across_content_pieces() { + for ending in ["", "\n", "\r", "\r\n"] { + let mut doc = Document::new_empty(); + let visible = image(&mut doc, 20, 20, 3, false, None); + let hidden = image(&mut doc, 20, 20, 3, false, None); + let page = page(&mut doc, &[("Visible", visible), ("Hidden", hidden)], ""); + let first = doc + .add(Object::Stream(Stream::from_raw(Dict::new(), format!("q 72 0 0 72 0 0 cm /Visible Do Q\n% trailing comment{ending}").into_bytes()))); + let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 144 0 0 144 0 0 cm /Hidden Do Q\n".to_vec()))); + doc.update_dict(page, |dict| { + dict.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Int(17), Object::Ref(second)])); + }) + .unwrap(); + let measured = effective_resolutions(&doc, &[page]); + assert_eq!(measured.get(&visible), Some(&20.0)); + if ending.is_empty() { + assert_eq!(measured.len(), 1); + assert_eq!(measured.get(&hidden), None, "a comment must hide the next piece's first line"); + } else { + assert_eq!(measured.len(), 2); + assert_eq!(measured.get(&hidden), Some(&10.0), "a real line ending exposes the image"); + } + } +} + +#[test] +fn a_hostile_image_size_is_left_alone_not_multiplied_into_overflow() { + // /Width and /Height are file-controlled: 2^32 + 100 wraps to 100 when read as u32, while the + // real values overflow `w * h * n`. The image must be skipped, not sized by a wrapped product. + let mut doc = Document::new_empty(); + let side = i64::from(u32::MAX) + 101; + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(side)); + d.set(b"Height".to_vec(), Object::Int(side)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let hostile = doc.add(Object::Stream(Stream::flate(d, &[7u8; 256]))); + page(&mut doc, &[("Im", hostile)], "q 1 0 0 1 10 10 cm /Im Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, hostile).dict.int(b"Width"), Some(side), "the image is left as it was"); +} + +#[test] +fn a_hostile_soft_mask_leaves_its_image_unchanged() { + let mut doc = Document::new_empty(); + let mask = image(&mut doc, 400, 400, 1, false, None); + let mut hostile = stream(&doc, mask); + hostile.dict.set(b"Width".to_vec(), Object::Int(i64::from(u32::MAX) + 401)); + doc.set(mask, Object::Stream(hostile.clone())); + let photo = image(&mut doc, 400, 400, 3, false, Some(mask)); + let before = stream(&doc, photo); + page(&mut doc, &[("Photo", photo)], "q 72 0 0 72 10 10 cm /Photo Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, photo), before); + assert_eq!(stream(&doc, mask), hostile); +} + +#[test] +fn indirectly_referenced_destination_is_kept() { + // A link's /Dest can be an indirect reference to the name string, not the string itself. Reduce + // File Size read the name only from a direct string, so it deleted a destination the link still + // used, silently breaking the link. + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + // A link whose /Dest is `ref -> "target"`, and an unused destination for contrast. + let name_ref = Object::Ref(doc.add(s("target"))); + let mut link = Dict::new(); + link.set(b"Type".to_vec(), Object::name("Annot")); + link.set(b"Subtype".to_vec(), Object::name("Link")); + link.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + link.set(b"Dest".to_vec(), name_ref); + let link = Object::Ref(doc.add(Object::Dict(link))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![link]))).unwrap(); + let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![s("target"), fit.clone(), s("unused"), fit])); + let tree = doc.add(Object::Dict(tree)); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + // Only "unused" is removed; the indirectly-referenced "target" is kept. + assert_eq!(report.unreferenced_dests, 1); + let left = named_dest_keys(&doc); + assert!(left.contains(&b"target".to_vec()), "indirectly referenced destination was kept: {left:?}"); + assert!(!left.contains(&b"unused".to_vec())); +} + +#[test] +fn a_destination_named_only_by_a_remote_or_embedded_go_to_is_removed() { + // The /D of a GoToR or GoToE action names a destination in another document. A local + // destination of the same name that nothing else uses is unreferenced. + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + let link = |doc: &mut Document, action: Dict| { + let mut link = Dict::new(); + link.set(b"Type".to_vec(), Object::name("Annot")); + link.set(b"Subtype".to_vec(), Object::name("Link")); + link.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + link.set(b"A".to_vec(), Object::Dict(action)); + Object::Ref(doc.add(Object::Dict(link))) + }; + let mut go_to = Dict::new(); + go_to.set(b"S".to_vec(), Object::name("GoTo")); + go_to.set(b"D".to_vec(), s("local")); + let mut remote = Dict::new(); + remote.set(b"S".to_vec(), Object::name("GoToR")); + remote.set(b"F".to_vec(), s("other.pdf")); + remote.set(b"D".to_vec(), s("remote")); + let mut target = Dict::new(); + target.set(b"R".to_vec(), Object::name("C")); + target.set(b"N".to_vec(), s("attached.pdf")); + let mut embedded = Dict::new(); + embedded.set(b"S".to_vec(), Object::name("GoToE")); + embedded.set(b"T".to_vec(), Object::Dict(target)); + embedded.set(b"D".to_vec(), s("embedded")); + let links = vec![link(&mut doc, go_to), link(&mut doc, remote), link(&mut doc, embedded)]; + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(links))).unwrap(); + let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![s("embedded"), fit.clone(), s("local"), fit.clone(), s("remote"), fit])); + let tree = doc.add(Object::Dict(tree)); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.unreferenced_dests, 2); + assert_eq!(named_dest_keys(&doc), vec![b"local".to_vec()]); +} + +/// The keys in the catalog's /Names /Dests name tree (one level, as the optimizer writes it). +fn named_dest_keys(doc: &Document) -> Vec> { + let cat = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let names = doc.resolve(cat.get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = doc.resolve(names.get(b"Dests").unwrap()).as_dict().cloned().unwrap(); + let arr = doc.resolve(tree.get(b"Names").unwrap()).as_array().cloned().unwrap(); + arr.chunks(2).filter_map(|c| doc.resolve(&c[0]).as_string().map(|s| s.bytes.clone())).collect() +} + +/// Page content long enough for LZW codes to grow past 9 bits, where `EarlyChange` matters. +fn lzw_ops() -> Vec { + (0..300).flat_map(|i| format!("{} {} m {} {} l S\n", i % 97, i * 7 % 389, i * 13 % 612, i * 31 % 792).into_bytes()).collect() +} + +fn lzw(data: &[u8], params: &Params) -> Vec { + pdfcraft_filters::encode(&Filter::Lzw, params, data).unwrap() +} + +/// `params` as a `/DecodeParms` dictionary, only the entries that differ from the defaults. +fn decode_parms(p: &Params) -> Dict { + let mut d = Dict::new(); + for (k, v, default) in [ + (&b"Predictor"[..], p.predictor, 1), + (b"Colors", p.colors, 1), + (b"BitsPerComponent", p.bits_per_component, 8), + (b"Columns", p.columns, 1), + (b"EarlyChange", p.early_change, 1), + ] { + if v != default { + d.set(k.to_vec(), Object::Int(v)); + } + } + d +} + +/// A 64 × 48 RGB image, LZW-encoded with `params`. +fn lzw_image(doc: &mut Document, params: &Params) -> (ObjRef, Vec) { + let (w, h) = (64u32, 48u32); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 4) as u8, (i / w * 5) as u8, ((i % w + i / w) * 2) as u8]).collect(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(params))); + (doc.add(Object::Stream(Stream::from_raw(d, lzw(&px, params)))), px) +} + +/// Settings that leave images to the clean-up: no resampling, no recompression. +fn clean_up_only() -> Settings { + let keep = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE }; + Settings { color: keep, gray: keep, ..Settings::default() } +} + +#[test] +fn lzw_streams_become_flate_and_decode_to_the_same_bytes() { + let ops = lzw_ops(); + let early = Params::default(); + let late = Params { early_change: 0, ..Params::default() }; + let png = Params { predictor: 15, colors: 3, columns: 64, ..Params::default() }; + let png_late = Params { early_change: 0, ..png.clone() }; + let mut doc = Document::new_empty(); + let content = |doc: &mut Document, p: &Params| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + if *p != Params::default() { + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(p))); + } + doc.add(Object::Stream(Stream::from_raw(d, lzw(&ops, p)))) + }; + let c_early = content(&mut doc, &early); + let c_late = content(&mut doc, &late); + let (img, px) = lzw_image(&mut doc, &png); + let (img_late, _) = lzw_image(&mut doc, &png_late); + page(&mut doc, &[("Im", img), ("Im2", img_late)], "q 64 0 0 48 72 600 cm /Im Do Q q 64 0 0 48 172 600 cm /Im2 Do Q"); + let pg = pdfcraft_annot::page_refs(&doc).unwrap()[0]; + doc.update_dict(pg, |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(c_early), Object::Ref(c_late), d.get(b"Contents").unwrap().clone()])) + }) + .unwrap(); + // Each LZW stream reads as its own data before, so a decoder ignoring EarlyChange fails here. + assert_eq!((stream(&doc, c_early).decoded_strict().unwrap(), stream(&doc, c_late).decoded_strict().unwrap()), (ops.clone(), ops.clone())); + assert!(lzw(&ops, &early) != lzw(&ops, &late), "the two code widths differ on this data"); + + let r = optimize(&mut doc, &clean_up_only()).unwrap(); + assert_eq!(r.lzw_streams, 4, "{r:?}"); + for (label, at, data) in + [("content", c_early, &ops), ("content, EarlyChange 0", c_late, &ops), ("image", img, &px), ("image, EarlyChange 0", img_late, &px)] + { + let after = stream(&doc, at); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode")), "{label}"); + assert_eq!(&after.decoded_strict().unwrap(), data, "{label}"); + let parms = after.dict.get(b"DecodeParms").and_then(Object::as_dict); + assert!(parms.is_none_or(|p| !p.contains(b"EarlyChange")), "{label}: EarlyChange is LZW's alone"); + } + // The image keeps its PNG predictor, now applied under Flate. + let parms = stream(&doc, img).dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"Predictor"), parms.int(b"Colors"), parms.int(b"Columns")), (Some(15), Some(3), Some(64))); + assert!(!stream(&doc, c_late).dict.contains(b"DecodeParms"), "nothing left to say for the content stream"); +} + +#[test] +fn a_page_drawn_from_lzw_streams_renders_the_same_after_conversion() { + let png = Params { predictor: 12, colors: 3, columns: 64, early_change: 0, ..Params::default() }; + let mut doc = Document::new_empty(); + let (img, _) = lzw_image(&mut doc, &png); + let pg = page(&mut doc, &[("Im", img)], ""); + let ops = b"0.2 0.5 0.8 rg 36 36 300 200 re f q 256 0 0 192 72 400 cm /Im Do Q\n".repeat(4); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(&Params { early_change: 0, ..Params::default() }))); + let c = doc.add(Object::Stream(Stream::from_raw(d, lzw(&ops, &Params { early_change: 0, ..Params::default() })))); + doc.update_dict(pg, |p| p.set(b"Contents".to_vec(), Object::Ref(c))).unwrap(); + let render = |doc: &Document| { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba + }; + let before = render(&doc); + // The picture and the rectangle are drawn: the page is not blank. + assert!(before.chunks(4).any(|p| p[..3] != [255, 255, 255])); + let r = optimize(&mut doc, &clean_up_only()).unwrap(); + assert_eq!(r.lzw_streams, 2, "{r:?}"); + assert_eq!(stream(&doc, c).dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert!(render(&doc) == before, "the page renders pixel for pixel as before"); +} + +#[test] +fn lzw_inside_a_filter_chain_is_replaced_and_the_other_stages_kept() { + let ops = lzw_ops(); + let inner = lzw(&ops, &Params::default()); + // An ASCII85 layer outside the LZW goes with it; a JPEG inside it stays encoded as it was. + let (mut doc, c) = with_content(filtered(&["ASCII85Decode", "LZWDecode"], ascii85(&inner))); + let photo = image(&mut doc, 40, 30, 3, true, None); + let jpeg = stream(&doc, photo).raw.to_vec(); + let mut picture = filtered(&["LZWDecode", "DCTDecode"], lzw(&jpeg, &Params::default())); + picture.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Null])); + let pic = doc.add(Object::Stream(picture)); + // An ASCII85 layer over LZW with a PNG predictor: the predictor's parameters move to the one + // Flate filter left, without EarlyChange. + let rows: Vec = (0..40 * 30).map(|i| (i % 40 * 3 + i / 40) as u8).collect(); + let png = Params { predictor: 12, columns: 40, early_change: 0, ..Params::default() }; + let mut predicted = filtered(&["ASCII85Decode", "LZWDecode"], ascii85(&lzw(&rows, &png))); + predicted.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(decode_parms(&png))])); + let pred = doc.add(Object::Stream(predicted)); + let r = optimize(&mut doc, &clean_up_only()).unwrap(); + assert_eq!(r.lzw_streams, 3, "{r:?}"); + let after = stream(&doc, pred); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode"))); + let parms = after.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"Predictor"), parms.int(b"Columns"), parms.contains(b"EarlyChange")), (Some(12), Some(40), false)); + assert_eq!(after.decoded_strict().unwrap(), rows); + let after = stream(&doc, c); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode"))); + assert_eq!(after.decoded_strict().unwrap(), ops); + let after = stream(&doc, pic); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::Array(vec![Object::name("FlateDecode"), Object::name("DCTDecode")]))); + assert!(!after.dict.contains(b"DecodeParms"), "all null: dropped"); + let inside = pdfcraft_filters::decode(&[(Filter::Flate, Params::default())], &after.raw, 1 << 20).unwrap(); + assert_eq!(inside, jpeg, "the JPEG bytes are kept exactly"); +} + +#[test] +fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { + let ops = lzw_ops(); + let good = lzw(&ops, &Params::default()); + // 9-bit codes 511 after a literal: past the table, corrupt. + let corrupt = vec![0x80, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + assert!(filtered(&["LZWDecode"], corrupt.clone()).decoded_strict().is_err()); + let mut bad_predictor = filtered(&["LZWDecode"], good.clone()); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(7)); + bad_predictor.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let mut indirect_parms = filtered(&["LZWDecode"], good.clone()); + indirect_parms.dict.set(b"DecodeParms".to_vec(), Object::Ref(ObjRef { num: 999, generation: 0 })); + // Columns the reader can't take as an integer: read as the default 1 here, so the data is + // predicted with one column and decodes, but a viewer resolving the value would use another. + let one_column = Params { predictor: 12, ..Params::default() }; + let odd_columns = |columns: Object| { + let mut s = filtered(&["LZWDecode"], lzw(&ops, &one_column)); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), columns); + s.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + s + }; + assert_eq!(odd_columns(Object::Real(12.5)).decoded_strict().unwrap(), ops, "read with one column"); + let mut objstm = filtered(&["LZWDecode"], good.clone()); + objstm.dict.set(b"Type".to_vec(), Object::name("ObjStm")); + let mut external = filtered(&["LZWDecode"], good.clone()); + external.dict.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"data.bin".to_vec()))); + let cases = [ + ("corrupt LZW", filtered(&["LZWDecode"], corrupt), Settings::default()), + ("corrupt under a PNG predictor", bad_predictor, Settings::default()), + ("indirect DecodeParms", indirect_parms, Settings::default()), + ("external file", external, Settings::default()), + ("object stream, rewritten on save", objstm, Settings::default()), + ("Columns by reference", odd_columns(Object::Ref(ObjRef { num: 9, generation: 0 })), Settings::default()), + ("Columns not an integer", odd_columns(Object::Real(12.5)), Settings::default()), + ("crypt filter", filtered(&["Crypt", "LZWDecode"], good.clone()), Settings::default()), + ("option off", filtered(&["LZWDecode"], good.clone()), Settings { lzw_to_flate: false, ..Settings::default() }), + ]; + for (label, s, settings) in cases { + let (mut doc, c) = with_content(s.clone()); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.lzw_streams, 0, "{label}"); + assert_eq!(stream(&doc, c), s, "{label}"); + } + // The same data, readable and asked for, is converted: the cases above fail for their reason. + let (mut doc, c) = with_content(filtered(&["LZWDecode"], good)); + assert_eq!(optimize(&mut doc, &Settings::default()).unwrap().lzw_streams, 1); + assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops); +} + +/// Unknown palette polarity is never guessed. Both palette orders and Decode directions +/// retain the original thin rule and exact samples through a full save and render. +#[test] +fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut gray_data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + gray_data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let mut gray = Document::new_empty(); + let gray_image = mono_image(&mut gray, w, h, &gray_data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut gray, &[("Im", gray_image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + let gray_render = render(&gray); + optimize(&mut gray, &Settings::default()).unwrap(); + let gray_stream = stream(&gray, gray_image); + let gray_samples = gray.decoded_image(&gray_stream).unwrap(); + assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240))); + assert!(gray_samples.iter().any(|b| *b != 0xFF), "the DeviceGray thin rule survives"); + for white_first in [false, true] { + for inverted in [false, true] { + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + if white_first != inverted { + data.iter_mut().for_each(|b| *b = !*b); + } + let palette = if white_first { vec![255, 0] } else { vec![0, 255] }; + let cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceGray"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(palette)), + ]); + let mut entries = vec![("BitsPerComponent", 1.into()), ("ColorSpace", cs)]; + if inverted { + entries.push(("Decode", Object::Array(vec![1.into(), 0.into()]))); + } + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &entries); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + let before = render(&doc); + assert_eq!(before, gray_render, "the palette image is visually identical to DeviceGray"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, image); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into()))); + assert_eq!(doc.decoded_image(&s).unwrap(), data); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(render(&saved), before, "palette white-first={white_first}, inverted={inverted}"); + } + } +} + +/// White margins must not hide a small screened photograph from the tone guard. +#[test] +fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + for side in [8usize, 40, 300] { + let mut data = vec![0xFF; stride * h as usize]; + for y in 40..40 + side { + for x in 100..100 + side { + if (x + y) % 2 == 0 { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + // A separate one-pixel text/rule stroke makes this a mixed-content scan. + for y in 380..420 { + data[y * stride + 801 / 8] &= !(0x80 >> (801 % 8)); + } + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + let before = render(&doc); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, image); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into()))); + assert_eq!(doc.decoded_image(&s).unwrap(), data, "screened samples survive, not a solid patch"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(render(&saved), before, "local halftone {side}x{side}"); + } +} diff --git a/crates/oracle/README.md b/crates/oracle/README.md index 04eea193aa..01a53360d8 100644 --- a/crates/oracle/README.md +++ b/crates/oracle/README.md @@ -15,7 +15,7 @@ Every test that wanted an oracle used to probe for it by hand and decide on its it was missing. The common choice — print a line and return — reports as a **pass**, so a suite with no oracles installed looked exactly like one in which every oracle agreed. -That was not hypothetical. No CI runner installed `qpdf`, so `cos/tests/encryption.rs::qpdf_agrees` +That was not hypothetical. No CI runner installed `qpdf`, so `cos/tests/it/encryption.rs::qpdf_agrees` had never executed on a runner, while `README.md` said saved output was "independently checked with qpdf". For R2–R4 that test is the *only* check of owner-password authentication (Algorithm 7) that does not go through our own code. @@ -50,10 +50,10 @@ Each returns `Result`. `Oracle::command()` hands back a fresh `C install the tools, so a skip notice there becomes a red build. ``` -$ cargo test -p pdfcraft-cos --test encryption qpdf_agrees +$ cargo test -p pdfcraft-cos --test it encryption::qpdf_agrees test result: ok. 1 passed # qpdf absent: skipped, with a notice on stderr -$ PDFCRAFT_ORACLES_REQUIRED=1 cargo test -p pdfcraft-cos --test encryption qpdf_agrees +$ PDFCRAFT_ORACLES_REQUIRED=1 cargo test -p pdfcraft-cos --test it encryption::qpdf_agrees oracle `qpdf` is required but is not on PATH (program not found): PDFCRAFT_ORACLES_REQUIRED is set, so this is a failure rather than a skip (apt install qpdf / brew install qpdf) test result: FAILED. 1 failed @@ -90,7 +90,7 @@ appearance we do draw. **Poppler is not a usable oracle for JBIG2.** The 96 `bitmap-*.pdf` corpus fixtures encode one picture through every corner of the format, so a correct decoder renders them all identically. We render 1 distinct image across all 96; Poppler renders 8, drawing a blank page for seven of them. -`crates/render/tests/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it. +`crates/render/tests/it/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it. ## Comparing text diff --git a/crates/oracle/src/lib.rs b/crates/oracle/src/lib.rs index e9e7ae372e..16c7192b17 100644 --- a/crates/oracle/src/lib.rs +++ b/crates/oracle/src/lib.rs @@ -23,6 +23,8 @@ //! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency //! only, never compiled into the product. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + pub mod pixels; use std::fmt; diff --git a/crates/oracle/src/pixels.rs b/crates/oracle/src/pixels.rs index 2b11719819..4ff0ebaf13 100644 --- a/crates/oracle/src/pixels.rs +++ b/crates/oracle/src/pixels.rs @@ -201,6 +201,10 @@ pub fn decode_pam_gray(bytes: &[u8]) -> Option<(usize, usize, Vec)> { } fn find(haystack: &[u8], needle: &[u8]) -> Option { + // `windows(0)` panics. + if needle.is_empty() { + return None; + } haystack.windows(needle.len()).position(|w| w == needle) } @@ -404,6 +408,14 @@ mod tests { assert_eq!((w, h, px.len()), (4, 2, 8)); } + /// An empty needle is not found, rather than panicking in `windows(0)`. + #[test] + fn find_with_an_empty_needle_is_none() { + assert_eq!(find(b"abc", b""), None); + assert_eq!(find(b"", b""), None); + assert_eq!(find(b"abcabc", b"ca"), Some(2)); + } + /// Malformed or hostile headers return `None` rather than panicking or inventing a size. #[test] fn hostile_pgm_headers_are_rejected() { diff --git a/crates/organize/src/attachments.rs b/crates/organize/src/attachments.rs new file mode 100644 index 0000000000..b0bf222991 --- /dev/null +++ b/crates/organize/src/attachments.rs @@ -0,0 +1,166 @@ +//! Document-level file attachments (Acrobat: Edit ▸ Attach file; the Attachments panel): the +//! `/Names /EmbeddedFiles` name tree of file specifications, each with its embedded stream. +//! +//! A FileAttachment annotation (a paper clip on a page) is a comment and lives in the annot +//! crate; this is the document level only. + +use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; + +use crate::OrganizeError; +use crate::import::{name_tree_entries, set_attachments}; + +/// The keys of the document-level attachments, in name-tree order. +pub fn attachment_keys(doc: &Document) -> Vec { + entries(doc).into_iter().map(|(k, _)| PdfString::literal(k).to_text()).collect() +} + +/// Attach `bytes` to the document under `name` (a file name; any folder part is dropped). The +/// name tree's key is the name, made unique with " (2)", " (3)"… when it is already taken, and +/// is returned so the caller can find the attachment again. `date` is a PDF date string for the +/// embedded file's `/ModDate`; `mime` its `/Subtype` when known. +pub fn add_attachment( + doc: &mut Document, + name: &str, + bytes: &[u8], + description: Option<&str>, + mime: Option<&str>, + date: Option<&str>, +) -> Result { + let name = file_name(name); + if name.is_empty() { + return Err(OrganizeError::Invalid("the attachment needs a file name".into())); + } + let mut all = entries(doc); + let taken: Vec = all.iter().map(|(k, _)| PdfString::literal(k.clone()).to_text()).collect(); + let mut key = name.clone(); + let mut n = 2; + while taken.contains(&key) { + key = format!("{name} ({n})"); + n += 1; + } + + let mut params = Dict::new(); + params.set(b"Size".to_vec(), Object::Int(bytes.len() as i64)); + if let Some(d) = date { + params.set(b"ModDate".to_vec(), Object::String(PdfString::literal(d.as_bytes().to_vec()))); + } + let mut ef = Dict::new(); + ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); + if let Some(m) = mime.map(str::trim).filter(|m| !m.is_empty()) { + ef.set(b"Subtype".to_vec(), Object::Name(m.as_bytes().to_vec())); + } + ef.set(b"Params".to_vec(), Object::Dict(params)); + let stream = doc.add(Object::Stream(Stream::flate(ef, bytes))); + + let mut spec = Dict::new(); + spec.set(b"Type".to_vec(), Object::name("Filespec")); + spec.set(b"F".to_vec(), Object::String(PdfString::text(&key))); + spec.set(b"UF".to_vec(), Object::String(PdfString::text(&key))); + if let Some(d) = description.map(str::trim).filter(|d| !d.is_empty()) { + spec.set(b"Desc".to_vec(), Object::String(PdfString::text(d))); + } + let mut files = Dict::new(); + files.set(b"F".to_vec(), Object::Ref(stream)); + files.set(b"UF".to_vec(), Object::Ref(stream)); + spec.set(b"EF".to_vec(), Object::Dict(files)); + let spec = doc.add(spec); + + all.push((PdfString::text(&key).bytes, Object::Ref(spec))); + set_attachments(doc, all)?; + Ok(key) +} + +/// Remove the document-level attachment whose name-tree key is `key`. The embedded stream is +/// left for the writer's garbage collection, as with every other removal. +pub fn delete_attachment(doc: &mut Document, key: &str) -> Result<(), OrganizeError> { + let mut all = entries(doc); + let before = all.len(); + all.retain(|(k, _)| PdfString::literal(k.clone()).to_text() != key); + if all.len() == before { + return Err(OrganizeError::Invalid(format!("there is no attachment named {key}"))); + } + if all.is_empty() { clear_embedded_files(doc) } else { set_attachments(doc, all) } +} + +/// The `/EmbeddedFiles` entries (key, file specification), in tree order. +fn entries(doc: &Document) -> Vec<(Vec, Object)> { + let Some(tree) = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Names").map(|n| doc.resolve(n))) + .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) + else { + return Vec::new(); + }; + let mut out = Vec::new(); + name_tree_entries(doc, &tree, 0, &mut std::collections::HashSet::new(), &mut out); + out +} + +/// Drop `/EmbeddedFiles` from the catalog's `/Names` (and `/Names` itself when nothing is left). +fn clear_embedded_files(doc: &mut Document) -> Result<(), OrganizeError> { + let root = doc.root().ok_or(OrganizeError::NoPageTree)?; + let names = doc.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()); + match names { + Some(Object::Ref(r)) => { + let mut empty = false; + doc.update_dict(r, |n| { + n.remove(b"EmbeddedFiles"); + empty = n.is_empty(); + })?; + if empty { + doc.update_dict(root, |c| { + c.remove(b"Names"); + })?; + } + } + Some(Object::Dict(mut n)) => { + n.remove(b"EmbeddedFiles"); + doc.update_dict(root, |c| { + if n.is_empty() { + c.remove(b"Names"); + } else { + c.set(b"Names".to_vec(), Object::Dict(n)); + } + })?; + } + _ => {} + } + Ok(()) +} + +/// The last path component of `name`, trimmed: a dragged-in path or a folder part never becomes +/// the attachment's name. +fn file_name(name: &str) -> String { + name.rsplit(['/', '\\']).next().unwrap_or(name).trim().to_string() +} + +/// A media type from the file's extension, for the embedded file's `/Subtype`; `None` when it +/// is not one of the common kinds (the attachment is still fine without it). +pub fn mime_for_name(name: &str) -> Option<&'static str> { + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?; + Some(match ext.as_str() { + "pdf" => "application/pdf", + "txt" | "log" | "md" => "text/plain", + "csv" => "text/csv", + "htm" | "html" => "text/html", + "xml" => "text/xml", + "json" => "application/json", + "zip" => "application/zip", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "tif" | "tiff" => "image/tiff", + "svg" => "image/svg+xml", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "rtf" => "application/rtf", + "eml" => "message/rfc822", + "msg" => "application/vnd.ms-outlook", + _ => return None, + }) +} diff --git a/crates/organize/src/comments.rs b/crates/organize/src/comments.rs new file mode 100644 index 0000000000..dd5d63ebc4 --- /dev/null +++ b/crates/organize/src/comments.rs @@ -0,0 +1,657 @@ +//! Import comments from another PDF (Acrobat: the Comments list's Import Data File, with a PDF +//! chosen): each comment is copied onto the page with the same number, at the same place, with +//! its appearance, pop-up and replies. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::import::{Copier, register_layers}; +use crate::{OrganizeError, walk}; + +/// The annotation subtypes copied as comments: the markup annotations of ISO 32000-2 Table 171, +/// except redaction marks (`Redact`) and `Projection`. +const COMMENTS: [&[u8]; 16] = [ + b"Text", + b"FreeText", + b"Line", + b"Square", + b"Circle", + b"Polygon", + b"PolyLine", + b"Highlight", + b"Underline", + b"Squiggly", + b"StrikeOut", + b"Stamp", + b"Caret", + b"Ink", + b"FileAttachment", + b"Sound", +]; + +/// The Locked annotation flag. +const LOCKED: i64 = 128; + +/// One comment of the source: its entry in the page's `/Annots` and its dictionary. +struct Incoming { + entry: Object, + dict: Dict, +} + +/// Copy the comments of `src` onto the pages of `dst`: the comments of page 1 onto page 1, those of +/// page 2 onto page 2, and so on, each at the position it has in `src`. Comments on pages past the +/// last page of `dst` are left out, and so is a reply whose comment is left out. Each copy keeps its +/// appearance, its pop-up, its replies and its layer. Links, form fields and redaction marks are +/// not copied. +/// +/// A comment named (`/NM`) like one already on its page replaces that one, which keeps its object +/// so replies to it stay attached. A locked comment, or an annotation of that name that is no +/// comment (a link, a redaction mark, a form widget), is not replaced: the copy is added beside it +/// without the name. Returns the number of comments copied. +pub fn import_comments(dst: &mut Document, src: &Document) -> Result { + let from = walk(src)?; + let to = walk(dst)?; + let pages: Vec<(ObjRef, ObjRef)> = from.iter().zip(&to).map(|((s, _), (d, _))| (*s, *d)).collect(); + let mut incoming: Vec> = pages.iter().map(|(s, _)| page_comments(src, *s)).collect(); + let kept = threads_kept(&incoming); + for list in &mut incoming { + list.retain(|c| c.dict.get(b"IRT").is_none_or(|irt| irt.as_ref().is_some_and(|p| kept.contains(&p)))); + } + let mut copier = Copier::new(src, HashMap::new(), pages.iter().copied().collect()); + // Every comment's object is chosen before any is copied, so a comment that another one reaches + // through `/IRT` is copied into the object chosen for it by name. + let mut lists: Vec> = Vec::new(); + let mut planned: Vec<(usize, ObjRef, Dict)> = Vec::new(); + for (i, ((_, page), list)) in pages.iter().zip(incoming).enumerate() { + if list.is_empty() { + lists.push(None); + continue; + } + let mut annots = PageAnnots::read(dst, *page); + for Incoming { entry, dict } in list { + // The same comment listed twice on a page is copied once. + if entry.as_ref().is_some_and(|r| copier.map.contains_key(&r)) { + continue; + } + let (target, dict) = reserve(dst, src, &mut annots, dict); + if let Some(r) = entry.as_ref() { + copier.map.insert(r, target); + } + annots.push(target); + planned.push((i, target, dict)); + } + lists.push(Some(annots)); + } + let mut annot_pages = HashMap::new(); + for (i, target, dict) in &planned { + let copy = copier.copy_value(dst, &Object::Dict(dict.clone()), Some(*target))?; + dst.set(*target, copy); + annot_pages.insert(*target, pages[*i].1); + } + for (i, target, _) in &planned { + if let Some(popup) = dst.get(*target).as_dict().and_then(|d| d.reference(b"Popup")) + && let Some(annots) = lists[*i].as_mut() + { + annots.push(popup); + annot_pages.insert(popup, pages[*i].1); + } + } + // A pop-up that only its `/Parent` ties to a copied comment comes along too. + for (i, (from, to)) in pages.iter().enumerate() { + let Some(annots) = lists[i].as_mut() else { continue }; + for (popup, parent) in page_popups(src, *from) { + if copier.map.contains_key(&popup) || !copier.map.contains_key(&parent) { + continue; + } + let r = dst.add(Object::Null); + copier.map.insert(popup, r); + let copy = copier.copy_value(dst, &src.get(popup), Some(r))?; + dst.set(r, copy); + annots.push(r); + annot_pages.insert(r, *to); + } + } + for ((_, page), annots) in pages.iter().zip(lists) { + if let Some(annots) = annots { + dst.update_dict(*page, |p| p.set(b"Annots".to_vec(), Object::Array(annots.into_list())))?; + } + } + copier.fix_annotations(dst, &annot_pages)?; + register_layers(dst, src, &copier.ocgs)?; + Ok(planned.len()) +} + +/// Choose the object a comment is copied into: the comment of `annots` with its name, or a new +/// object. Returns it with the dictionary to copy. +fn reserve(dst: &mut Document, src: &Document, annots: &mut PageAnnots, mut dict: Dict) -> (ObjRef, Dict) { + let nm = dict.get(b"NM").and_then(|n| src.resolve(n).as_string().map(|s| s.to_text())); + let same = nm.as_deref().and_then(|nm| annots.named(dst, nm)); + // A second incoming comment with the name of one added earlier is added without the name. + let taken = nm.is_some_and(|nm| !annots.added.insert(nm)); + let target = match same { + None if taken => { + dict.remove(b"NM"); + dst.add(Object::Null) + } + Some((r, false)) => { + annots.claimed.insert(r); + // The replaced comment's pop-up goes with it: the one it names, and any whose `/Parent` + // is the comment. + if let Some(p) = dst.get(r).as_dict().and_then(|d| d.reference(b"Popup")) { + annots.remove(p); + } + for p in annots.popups.get(&r).cloned().unwrap_or_default() { + annots.remove(p); + } + r + } + Some((_, true)) => { + dict.remove(b"NM"); + dst.add(Object::Null) + } + None => dst.add(Object::Null), + }; + (target, dict) +} + +/// The comments of `incoming` that go: a reply goes only with the comment it answers, and that one +/// only with the comment it answers, up to the first comment of the thread. A thread whose replies +/// answer each other in a loop goes, as all of its comments are there. Each comment is visited once. +fn threads_kept(incoming: &[Vec]) -> HashSet { + // For each comment: no parent, a parent comment, or a parent that is no comment of the source. + let parents: HashMap>> = + incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, c.dict.get(b"IRT").map(Object::as_ref)))).collect(); + let mut known: HashMap = HashMap::new(); + for &start in parents.keys() { + let (mut path, mut on_path) = (Vec::new(), HashSet::new()); + let mut at = start; + let kept = loop { + if let Some(&k) = known.get(&at) { + break k; + } + if !on_path.insert(at) { + break true; + } + match parents.get(&at) { + None => break false, + Some(None) => { + path.push(at); + break true; + } + Some(Some(None)) => { + path.push(at); + break false; + } + Some(Some(Some(parent))) => { + path.push(at); + at = *parent; + } + } + }; + for r in path { + known.insert(r, kept); + } + } + known.into_iter().filter_map(|(r, k)| k.then_some(r)).collect() +} + +/// The comments in `page`'s `/Annots`, in order. +fn page_comments(src: &Document, page: ObjRef) -> Vec { + let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned()); + list.unwrap_or_default() + .into_iter() + .filter_map(|entry| { + let dict = src.resolve(&entry).as_dict().cloned()?; + let subtype = dict.get(b"Subtype").map(|s| src.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))?; + COMMENTS.contains(&subtype.as_slice()).then_some(Incoming { entry, dict }) + }) + .collect() +} + +/// The pop-ups in `page`'s `/Annots` with the comment each belongs to: (pop-up, its `/Parent`). +fn page_popups(src: &Document, page: ObjRef) -> Vec<(ObjRef, ObjRef)> { + let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned()); + list.unwrap_or_default() + .iter() + .filter_map(|entry| { + let r = entry.as_ref()?; + let d = src.get(r).as_dict().cloned()?; + (d.name(b"Subtype") == Some(b"Popup".as_slice())).then(|| d.reference(b"Parent").map(|p| (r, p)))? + }) + .collect() +} + +/// The annotation in `annots` named `nm` (compared as text, whatever the encoding), and whether it +/// must not be replaced: a locked comment, or an annotation that is no comment, such as a link, a +/// redaction mark or a form widget. +/// A page's `/Annots` while comments are added to it, indexed so that placing each comment does not +/// scan the whole list. +struct PageAnnots { + list: Vec, + members: HashSet, + /// The annotations on the page before the import, by `/NM`, in list order. + names: HashMap>, + /// The pop-ups on the page before the import, by `/Parent`. + popups: HashMap>, + removed: HashSet, + /// The annotations that an incoming comment has already replaced. + claimed: HashSet, + /// The names of the incoming comments placed so far. + added: HashSet, +} + +impl PageAnnots { + /// The `/Annots` of `page`, with inline annotation dictionaries made indirect objects. + fn read(dst: &mut Document, page: ObjRef) -> Self { + let list: Vec = dst + .get(page) + .as_dict() + .and_then(|p| p.get(b"Annots").cloned()) + .map(|a| dst.resolve(&a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default(); + let list: Vec = list + .into_iter() + .map(|a| match a { + Object::Dict(_) => Object::Ref(dst.add(a)), + a => a, + }) + .collect(); + let (mut members, mut names, mut popups) = (HashSet::new(), HashMap::>::new(), HashMap::>::new()); + for r in list.iter().filter_map(Object::as_ref) { + members.insert(r); + let Some(d) = dst.get(r).as_dict().cloned() else { continue }; + if let Some(name) = d.get(b"NM").and_then(|n| dst.resolve(n).as_string().map(|s| s.to_text())) { + names.entry(name).or_default().push(r); + } + if d.name(b"Subtype") == Some(b"Popup".as_slice()) + && let Some(parent) = d.reference(b"Parent") + { + popups.entry(parent).or_default().push(r); + } + } + PageAnnots { list, members, names, popups, removed: HashSet::new(), claimed: HashSet::new(), added: HashSet::new() } + } + + /// Add `r` at the end, unless the page already lists it. + fn push(&mut self, r: ObjRef) { + if self.removed.remove(&r) { + self.list.retain(|a| a.as_ref() != Some(r)); + } + if self.members.insert(r) { + self.list.push(Object::Ref(r)); + } + } + + /// Take every entry for `r` off the page. + fn remove(&mut self, r: ObjRef) { + if self.members.remove(&r) { + self.removed.insert(r); + } + } + + /// The first annotation on the page named `nm`, and whether it must be kept: it is locked, or + /// it is no comment. + fn named(&self, dst: &Document, nm: &str) -> Option<(ObjRef, bool)> { + let mut listed = self.names.get(nm)?.iter().filter(|r| self.members.contains(r)); + let first = *listed.clone().next()?; + // A second incoming comment with the name of one already replaced is added beside it. + let Some(&r) = listed.find(|r| !self.claimed.contains(r)) else { return Some((first, true)) }; + let d = dst.get(r).as_dict().cloned()?; + let flags = d.get(b"F").and_then(|f| dst.resolve(f).as_int()).unwrap_or(0); + let subtype = d.get(b"Subtype").map(|s| dst.resolve(s)); + let comment = subtype.as_deref().and_then(Object::as_name).is_some_and(|s| COMMENTS.contains(&s)); + Some((r, flags & LOCKED != 0 || !comment || d.contains(b"FT"))) + } + + /// The new `/Annots`. + fn into_list(self) -> Vec { + let removed = self.removed; + self.list.into_iter().filter(|a| a.as_ref().is_none_or(|r| !removed.contains(&r))).collect() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::{SaveOptions, write_full}; + + use super::*; + + fn build(bodies: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") + } + + /// The reviewer's copy: three pages of comments, a link and a pop-up among them. + fn reviewed() -> Document { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 13 0 R 7 0 R 8 0 R 9 0 R 15 0 R 16 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [10 0 R 11 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [12 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (from the reviewer) /Rect [10 10 30 30] /Popup 13 0 R /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Stamp /Name /Approved /Rect [50 50 150 100] /AP << /N 14 0 R >> /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 12 0 R /Contents (about page 3) /Rect [10 40 30 60] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 4 0 R >>", + "<< /Type /Annot /Subtype /Highlight /Rect [0 0 50 20] /QuadPoints [0 20 50 20 0 0 50 0] /Contents (marked) /P 4 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 6 0 R /P 3 0 R >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 13 >>\nstream\n0 0 100 50 re\nendstream", + "<< /Type /Annot /Subtype /Text /NM (locked) /Contents (the reviewer's) /Rect [80 10 100 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Redact /Rect [0 200 20 220] /F 2 /P 3 0 R >>", + ]) + } + + /// The document receiving the comments: two pages, three comments of its own on page 1. + fn original() -> Document { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (older text) /Rect [10 10 30 30] /Popup 8 0 R /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (locked) /F 128 /Contents (mine) /Rect [80 10 100 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Square /NM (keep) /Rect [100 100 200 200] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 3 0 R >>", + ]) + } + + fn page_annots(doc: &Document, page: usize) -> Vec<(ObjRef, Dict)> { + let (p, _) = walk(doc).unwrap()[page].clone(); + let list = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| doc.resolve(&a).as_array().cloned().unwrap()).unwrap_or_default(); + list.iter().map(|a| (a.as_ref().unwrap(), doc.resolve(a).as_dict().cloned().unwrap())).collect() + } + + fn text(d: &Dict, key: &[u8]) -> Option { + d.get(key).and_then(|v| v.as_string()).map(|s| s.to_text()) + } + + fn by_contents<'a>(list: &'a [(ObjRef, Dict)], contents: &str) -> &'a (ObjRef, Dict) { + list.iter().find(|(_, d)| text(d, b"Contents").as_deref() == Some(contents)).unwrap_or_else(|| panic!("no comment {contents:?}")) + } + + #[test] + fn comments_of_another_pdf_land_on_the_pages_with_the_same_numbers() { + let mut doc = original(); + let src = reviewed(); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 5, "four comments on page 1 and the highlight on page 2"); + let pages: Vec = walk(&doc).unwrap().into_iter().map(|(p, _)| p).collect(); + let one = page_annots(&doc, 0); + for (_, d) in &one { + assert_eq!(d.reference(b"P"), Some(pages[0]), "each annotation names the page it is on"); + } + + // The note named like one already there replaces it in place, with its new pop-up. + let (note, d) = by_contents(&one, "from the reviewer"); + assert_eq!(*note, ObjRef::new(5, 0), "the replaced comment keeps its object"); + let popup = d.reference(b"Popup").unwrap(); + assert_ne!(popup, ObjRef::new(8, 0)); + assert_eq!(doc.get(popup).as_dict().unwrap().reference(b"Parent"), Some(*note)); + let popups: Vec = one.iter().filter(|(_, d)| d.name(b"Subtype") == Some(b"Popup".as_slice())).map(|(r, _)| *r).collect(); + assert_eq!(popups, [popup], "the replaced note's pop-up goes, the copy's is listed once"); + assert_eq!(by_contents(&one, "a reply").1.reference(b"IRT"), Some(*note), "the reply answers the note"); + + // The locked comment of that name stays; the copy is added beside it, unnamed. + assert_eq!(text(&by_contents(&one, "mine").1, b"NM").as_deref(), Some("locked")); + assert_eq!(text(&by_contents(&one, "the reviewer's").1, b"NM"), None); + assert!(one.iter().any(|(_, d)| text(d, b"NM").as_deref() == Some("keep")), "the document's own comments stay"); + + // The stamp keeps its appearance; the redaction mark is not copied. + let (_, stamp) = one.iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Stamp".as_slice())).unwrap(); + let ap = doc.resolve(stamp.get(b"AP").unwrap()).as_dict().cloned().unwrap(); + assert!(matches!(&*doc.resolve(ap.get(b"N").unwrap()), Object::Stream(_)), "the appearance stream is copied"); + assert!(one.iter().all(|(_, d)| d.name(b"Subtype") != Some(b"Redact".as_slice()))); + + // Page 3 is past the document's last page: its square and the reply to it are left out. + assert!(one.iter().all(|(_, d)| text(d, b"Contents").as_deref() != Some("about page 3"))); + assert_eq!(one.len(), 7, "three of the document's own, three new copies and the note's new pop-up"); + + // Page 2 takes the highlight, not the link. + let two = page_annots(&doc, 1); + assert_eq!(two.len(), 1); + assert_eq!(two[0].1.name(b"Subtype"), Some(b"Highlight".as_slice())); + assert_eq!(two[0].1.reference(b"P"), Some(pages[1])); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!((page_annots(&saved, 0).len(), page_annots(&saved, 1).len()), (7, 1), "a saved copy reads back the same"); + } + + #[test] + fn a_reply_on_an_earlier_page_reaches_its_comment_by_name() { + // Page 1 holds a reply to a note on page 2, named like the document's own note there. + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-2) /Contents (from the reviewer) /Rect [10 10 30 30] /P 4 0 R >>", + // A pop-up that only its /Parent ties to the note. + "<< /Type /Annot /Subtype /Popup /Rect [50 50 210 130] /Open true /Parent 6 0 R /P 4 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", + // The same name in UTF-16BE, and a pop-up the note does not name. + "<< /Type /Annot /Subtype /Text /NM /Contents (older text) /Rect [10 10 30 30] /P 4 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 4 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let two = page_annots(&doc, 1); + assert_eq!(two.len(), 2, "the note replaces the document's own, whose pop-up goes, and its own pop-up comes"); + assert_eq!((two[0].0, text(&two[0].1, b"Contents").as_deref()), (ObjRef::new(5, 0), Some("from the reviewer"))); + assert_ne!(two[1].0, ObjRef::new(6, 0)); + assert_eq!(two[1].1.reference(b"Parent"), Some(ObjRef::new(5, 0))); + assert_eq!(two[1].1.get(b"Open"), Some(&Object::Bool(true))); + assert_eq!(two[1].1.reference(b"P"), Some(walk(&doc).unwrap()[1].0)); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 1); + assert_eq!(one[0].1.reference(b"IRT"), Some(ObjRef::new(5, 0)), "the reply answers the replaced note"); + } + + #[test] + fn a_comment_keeps_its_layer_and_the_layer_its_visibility() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>", + "<< /Type /OCG /Name (Review) >>", + ]); + let mut doc = original(); + import_comments(&mut doc, &src).unwrap(); + let (_, square) = + page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap(); + let layer = square.reference(b"OC").unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(props.get(b"OCGs"), Some(&Object::Array(vec![Object::Ref(layer)]))); + let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden"); + } + + #[test] + fn a_layer_hidden_by_the_base_state_stays_hidden() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /BaseState /OFF >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< >>", + "<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>", + "<< /Type /OCG /Name (Review) >>", + ]); + let mut doc = original(); + import_comments(&mut doc, &src).unwrap(); + let (_, square) = + page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap(); + let layer = square.reference(b"OC").unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap(); + let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden"); + assert_eq!(config.get(b"ON"), Some(&Object::Array(vec![]))); + } + + #[test] + fn a_long_thread_goes_with_its_first_comment_and_a_loop_of_replies_stays() { + // Page 1: replies 6, 7, … each answering the next, the last answering the square on page 3, + // which the document has no page for. Then two replies that answer each other. + let n = 20_000; + let first = 6; + let mut bodies: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into()]; + let list: String = (first..first + n + 2).map(|o| format!("{o} 0 R ")).collect(); + bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{list}] >>")); + bodies.push("<< /Type /Page /Parent 2 0 R >>".into()); + let square = first + n + 2; + bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{square} 0 R] >>")); + for o in first..first + n { + let parent = if o + 1 == first + n { square } else { o + 1 }; + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {parent} 0 R /Rect [0 0 10 10] /P 3 0 R >>")); + } + let (a, b) = (first + n, first + n + 1); + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {b} 0 R /Contents (one) /Rect [0 0 10 10] /P 3 0 R >>")); + bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {a} 0 R /Contents (two) /Rect [0 0 10 10] /P 3 0 R >>")); + bodies.push("<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>".into()); + let src = build(&bodies.iter().map(String::as_str).collect::>()); + let mut doc = original(); + let started = std::time::Instant::now(); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2, "only the two replies that answer each other"); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); + let one = page_annots(&doc, 0); + let (one_ref, one_dict) = by_contents(&one, "one"); + let (two_ref, two_dict) = by_contents(&one, "two"); + assert_eq!((one_dict.reference(b"IRT"), two_dict.reference(b"IRT")), (Some(*two_ref), Some(*one_ref))); + } + + #[test] + fn only_a_comment_is_replaced_by_name() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (link-1) /Contents (from the reviewer) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Square /NM (movie-1) /Contents (a square) /Rect [40 40 60 60] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (note-3) /Contents (newer text) /Rect [70 70 90 90] /P 3 0 R >>", + ]); + // A link and a screen annotation named like two of the comments, and a comment whose + // subtype is an indirect object. + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Link /NM (link-1) /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>", + "<< /Type /Annot /Subtype /Screen /NM (movie-1) /Rect [100 100 200 200] /P 3 0 R >>", + "<< /Type /Annot /Subtype 7 0 R /NM (note-3) /Contents (older text) /Rect [70 70 90 90] /P 3 0 R >>", + "/Text", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 3); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 5, "the link and the screen annotation stay, two comments are added and one replaced"); + let (note, d) = by_contents(&one, "newer text"); + assert_eq!((*note, text(d, b"NM").as_deref()), (ObjRef::new(6, 0), Some("note-3")), "the comment is replaced in place"); + assert_eq!((one[0].0, one[0].1.name(b"Subtype")), (ObjRef::new(4, 0), Some(b"Link".as_slice()))); + assert_eq!(text(&one[0].1, b"NM").as_deref(), Some("link-1")); + assert_eq!((one[1].0, one[1].1.name(b"Subtype")), (ObjRef::new(5, 0), Some(b"Screen".as_slice()))); + assert_eq!(text(&one[1].1, b"NM").as_deref(), Some("movie-1")); + assert_eq!(text(&by_contents(&one, "from the reviewer").1, b"NM"), None, "the copy is added without the name"); + assert_eq!(text(&by_contents(&one, "a square").1, b"NM"), None); + } + + #[test] + fn two_comments_with_one_name_replace_one_comment_and_add_the_other() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (older text) /Rect [10 10 30 30] /P 3 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 2, "one comment replaced and one added"); + let (first, d) = by_contents(&one, "first"); + assert_eq!((*first, text(d, b"NM").as_deref()), (ObjRef::new(4, 0), Some("twin"))); + assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name"); + } + + #[test] + fn two_new_comments_with_one_name_keep_it_once() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 2); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 2); + assert_eq!(text(&by_contents(&one, "first").1, b"NM").as_deref(), Some("twin")); + assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name"); + } + + #[test] + fn an_inline_comment_is_replaced_by_name() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (inline) /Contents (newer text) /Rect [10 10 30 30] /P 3 0 R >>", + ]); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Text /NM (inline) /Contents (older text) /Rect [10 10 30 30] >>] >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 1); + let one = page_annots(&doc, 0); + assert_eq!(one.len(), 1, "the inline comment is replaced, not kept beside the copy"); + assert_eq!(text(&by_contents(&one, "newer text").1, b"NM").as_deref(), Some("inline")); + } + + #[test] + fn a_pdf_without_comments_copies_none() { + let mut doc = original(); + let before = page_annots(&doc, 0).len(); + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>", + ]); + assert_eq!(import_comments(&mut doc, &src).unwrap(), 0); + assert_eq!(page_annots(&doc, 0).len(), before); + } +} diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 588539866a..9f86d6455a 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -17,195 +17,27 @@ //! - layers (optional content groups) used by the pages are registered in the destination's //! `/OCProperties`, keeping their default on/off state. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::{INHERITABLE, OrganizeError, pages_root, rebuild, walk}; -/// Keys that must not be followed when copying: they point back up or across the source -/// document, and following them would drag in unrelated objects. -const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"]; -const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; +mod carry; +mod combine; +mod copy; +mod fields; -/// How deep a copy follows references and nested arrays and dictionaries, counted together. -/// Real documents stay well under this (a page's resources, fonts and descriptors, nested -/// form XObjects and field trees are a few levels each); a chain of thousands of indirect -/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). -const MAX_COPY_DEPTH: usize = 256; - -struct Copier<'a> { - src: &'a Document, - /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. - depth: usize, - map: HashMap, - /// Source page → destination page, for rewriting `/P` and destinations. - pages: HashMap, - /// Destination objects that are annotation dictionaries (fixed up after copying). - annots: Vec, - /// Top-level form fields reached through widgets. - fields: Vec, - /// Optional content groups copied: (source, destination). - ocgs: Vec<(ObjRef, ObjRef)>, -} - -impl<'a> Copier<'a> { - fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { - Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } - } - - /// Copy an indirect object (once), returning its destination reference. - fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> Result { - if let Some(d) = self.map.get(&r) { - return Ok(*d); - } - // Reserve the number first so cycles (field ↔ widget) terminate. - let new = dst.add(Object::Null); - self.map.insert(r, new); - let obj = self.src.get(r); - if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) { - self.ocgs.push((r, new)); - } - let copied = self.copy_value(dst, &obj, Some(new))?; - dst.set(new, copied); - Ok(new) - } - - /// One level deeper into the object graph, or the refusal when there are too many. - fn descend(&mut self) -> Result<(), OrganizeError> { - if self.depth >= MAX_COPY_DEPTH { - return Err(OrganizeError::Invalid(format!("the pages refer to objects nested more than {MAX_COPY_DEPTH} levels deep"))); - } - self.depth += 1; - Ok(()) - } - - fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { - if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { - return Ok(o.clone()); - } - self.descend()?; - let copied = match o { - Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), - Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::, _>>().map(Object::Array), - Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict), - Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| { - let mut s = s.clone(); - s.dict = dict; - Object::Stream(s) - }), - other => Ok(other.clone()), - }; - self.depth = self.depth.saturating_sub(1); - copied - } - - fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { - let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); - let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); - let mut out = Dict::new(); - for (k, v) in d.iter() { - if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) { - continue; - } - // Destinations are rewritten afterwards (they may target pages copied later). - if is_annot && (k.as_slice() == b"Dest" || k.as_slice() == b"A") { - out.set(k.clone(), v.clone()); - continue; - } - out.set(k.clone(), self.copy_value(dst, v, None)?); - } - if let Some(this) = this { - if is_annot { - self.annots.push(this); - } - // A field (or widget) without a parent is a top-level field. - if is_field && !d.contains(b"Parent") { - self.fields.push(this); - } - } - Ok(out) - } - - /// Map a source destination (explicit array or reference to one) to the destination - /// document; `None` if it targets a page that was not copied. - fn map_dest(&self, dest: &Object) -> Option { - self.map_dest_depth(dest, 0) - } - - fn map_dest_depth(&self, dest: &Object, depth: u8) -> Option { - if depth > 4 { - return None; - } - match dest { - Object::Name(n) => self.map_dest_depth(&named_dest(self.src, n)?, depth + 1), - Object::String(s) => self.map_dest_depth(&named_dest(self.src, &s.bytes)?, depth + 1), - Object::Ref(_) => self.map_dest_depth(&self.src.resolve(dest), depth + 1), - Object::Dict(d) => self.map_dest_depth(d.get(b"D")?, depth + 1), - Object::Array(a) => { - let page = a.first()?.as_ref()?; - let new_page = *self.pages.get(&page)?; - let mut a = a.clone(); - a[0] = Object::Ref(new_page); - Some(Object::Array(a)) - } - _ => None, - } - } - - /// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations. - fn fix_annotations(&self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { - for &a in &self.annots { - let src_dict = dst.get(a).as_dict().cloned(); - let Some(d) = src_dict else { continue }; - let mut d = d; - if let Some(p) = annot_pages.get(&a) { - d.set(b"P".to_vec(), Object::Ref(*p)); - } - if let Some(dest) = d.get(b"Dest").cloned() { - let resolved = self.src.resolve(&dest); - match self.map_dest(&resolved) { - Some(nd) => d.set(b"Dest".to_vec(), nd), - None => { - d.remove(b"Dest"); - } - } - } - if let Some(action) = d.get(b"A").cloned() { - let action = self.src.resolve(&action); - if let Some(ad) = action.as_dict() { - if ad.name(b"S") == Some(b"GoTo") { - let target = ad.get(b"D").map(|x| self.src.resolve(x)); - match target.and_then(|t| self.map_dest(&t)) { - Some(nd) => { - let mut na = Dict::new(); - na.set(b"S".to_vec(), Object::name("GoTo")); - na.set(b"D".to_vec(), nd); - d.set(b"A".to_vec(), Object::Dict(na)); - } - None => { - d.remove(b"A"); - } - } - } else { - // Other actions (URI, Launch, JavaScript…) carry no page references. - let mut c = Copier::new(self.src, self.map.clone(), HashMap::new()); - let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None)?; - d.set(b"A".to_vec(), copied); - } - } - } - dst.set(a, Object::Dict(d)); - } - Ok(()) - } -} - -/// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so -/// an object the copied pages already brought along (an ICC profile) is not stored twice. -pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Result { - Copier::new(src, map, HashMap::new()).copy_value(dst, o, None) -} +pub(crate) use carry::set_attachments; +use carry::{Mark, source_outline, write_outline_level}; +pub use combine::{ + CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with, + extract_pages, split, split_ranges, +}; +use copy::SKIP_ON_PAGE; +pub(crate) use copy::{Copier, copy_object}; +use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field}; +pub use fields::{combine_field_conflicts, field_name_conflicts}; /// How colliding fully-qualified field names are resolved when pages are imported. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -240,23 +72,31 @@ pub fn import_pages_with( at: usize, conflicts: FieldConflicts, ) -> Result { - let first_new = dst.object_numbers().last().map_or(1, |n| n + 1); + let before: HashSet = dst.object_numbers().into_iter().collect(); let imported = match import_pages_mapped(dst, src, src_pages, at, conflicts) { Ok(imported) => imported, Err(e) => { // A refusal while copying (a source too deep to follow) has only added objects: // take them back, so the destination is as it was. - for n in dst.object_numbers().into_iter().filter(|n| *n >= first_new) { - dst.free(ObjRef::new(n, dst.generation(n))); + for r in added_objects(dst, &before) { + dst.free(r); } return Err(e); } }; - let created: Vec = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect(); + let created = added_objects(dst, &before); crate::dedupe::dedupe_resources(dst, &created, true); Ok(ImportOutcome { pages: imported.pages, renamed_fields: imported.renamed_fields }) } +/// The objects in `dst` that are not among `before`, the numbers it held before an import: the +/// ones the import added. They are not always numbered above the others: when no number is left +/// above the highest (the document holds object 4294967295, or its `/Size` is exhausted), +/// [`Document::add`] fills free numbers below it. +fn added_objects(dst: &Document, before: &HashSet) -> Vec { + dst.object_numbers().into_iter().filter(|n| !before.contains(n)).map(|n| ObjRef::new(n, dst.generation(n))).collect() +} + /// Insert Pages from File: [`import_pages_with`], and the inserted file's bookmarks come along /// under one new bookmark titled `title` (the file's name) at its first inserted page, as Combine /// Files does for each file. That bookmark goes among the top-level bookmarks where the pages went: @@ -270,9 +110,9 @@ pub fn insert_file_pages( at: usize, conflicts: FieldConflicts, ) -> Result { - let first_new = dst.object_numbers().last().map_or(1, |n| n + 1); + let before: HashSet = dst.object_numbers().into_iter().collect(); let imported = import_pages_mapped(dst, src, src_pages, at, conflicts)?; - let created: Vec = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect(); + let created = added_objects(dst, &before); crate::dedupe::dedupe_resources(dst, &created, true); let outline = source_outline(src, imported.page_map); if let Some(first) = imported.pages.first() @@ -393,27 +233,33 @@ fn import_pages_mapped( let r = dst.add(Object::Null); let c = copier.copy_value(dst, &resolved, Some(r))?; dst.set(r, c); - // `/Kids` may be an array object of its own: add to it there, so the - // field keeps its other widgets. - match dst.get(copied).as_dict().and_then(|f| f.get(b"Kids").cloned()) { - Some(Object::Ref(list)) => { - let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); - kids.push(Object::Ref(r)); - dst.set(list, Object::Array(kids)); - } - _ => dst.update_dict(copied, |f| { - let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); - kids.push(Object::Ref(r)); - f.set(b"Kids".to_vec(), Object::Array(kids)); - })?, - } + add_kid(dst, copied, r)?; annot_pages.insert(r, new); out.push(Object::Ref(r)); continue; } - // A widget that is its own field (no parent to hang a second widget on) stays - // the one object, shown on both pages, so the field stays one. + // A widget that is its own field (no parent to hang a second widget on): on + // its first copy it is split into a parent field and a widget child (#1810); + // every later copy of the page gets one more widget child of that field, so + // the pages no longer share one widget whose `/P` names only the last page. let own_field = resolved.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent")); + if own_field + && let Some(first) = a.as_ref().and_then(|r| copier.map.get(&r).copied()) + && let Some(parent) = dst.get(first).as_dict().and_then(|d| d.reference(b"Parent")) + { + let r = dst.add(Object::Null); + let c = copier.copy_value(dst, &resolved, Some(r))?; + let Object::Dict(d) = c else { continue }; + let (_, mut d) = field_and_widget(dst, &d); + d.set(b"Parent".to_vec(), Object::Ref(parent)); + // The new widget is not a field root of its own. + copier.fields.retain(|f| *f != r); + dst.set(r, Object::Dict(d)); + add_kid(dst, parent, r)?; + annot_pages.insert(r, new); + out.push(Object::Ref(r)); + continue; + } let annots = if own_field { &mut copier } else { own.as_mut().unwrap_or(&mut copier) }; // Direct (inline) annotation dictionaries become indirect objects, so every // copied annotation can be fixed up the same way. @@ -427,6 +273,9 @@ fn import_pages_mapped( } _ => continue, }; + if own_field && copier.fields.contains(&r) { + split_own_field(dst, &mut copier.fields, r); + } annot_pages.insert(r, new); out.push(Object::Ref(r)); } @@ -533,13 +382,16 @@ fn named_dest(src: &Document, name: &[u8]) -> Option { } let names = src.resolve(catalog.get(b"Names")?); let tree = names.as_dict()?.get(b"Dests")?.clone(); - name_tree_lookup(src, &tree, name, 0) + name_tree_lookup(src, &tree, name, 0, &mut HashSet::new()) } -fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Option { - if depth > 32 { +/// Name trees are read once per node: a node listed twice (or a cycle) is skipped. +fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8, seen: &mut HashSet) -> Option { + if depth > 32 || node.as_ref().is_some_and(|r| !seen.insert(r)) { return None; } + #[cfg(test)] + crate::work(1); let node = src.resolve(node); let d = node.as_dict()?; if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) { @@ -552,14 +404,16 @@ fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Opt } } let kids = d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - kids.iter().find_map(|k| name_tree_lookup(src, k, key, depth + 1)) + kids.iter().find_map(|k| name_tree_lookup(src, k, key, depth + 1, seen)) } -/// All (key, value) pairs of a name tree, in order. -fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec, Object)>) { - if depth > 32 { +/// All (key, value) pairs of a name tree, in order, each node read once. +pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen: &mut HashSet, out: &mut Vec<(Vec, Object)>) { + if depth > 32 || node.as_ref().is_some_and(|r| !seen.insert(r)) { return; } + #[cfg(test)] + crate::work(1); let node = src.resolve(node); let Some(d) = node.as_dict() else { return }; if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) { @@ -572,24 +426,28 @@ fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Ve } } for k in d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default() { - name_tree_entries(src, &k, depth + 1, out); + name_tree_entries(src, &k, depth + 1, seen, out); } } /// Register copied optional content groups in the destination's `/OCProperties`, keeping each /// layer's default visibility from the source. -fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> { +pub(crate) fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> { if ocgs.is_empty() { return Ok(()); } - let src_off: Vec = src + let src_config: Dict = src .root() .and_then(|r| src.get(r).as_dict().cloned()) .and_then(|c| c.get(b"OCProperties").map(|o| src.resolve(o))) .and_then(|o| o.as_dict().and_then(|d| d.get(b"D")).map(|d| src.resolve(d))) - .and_then(|d| d.as_dict().and_then(|d| d.get(b"OFF")).map(|o| src.resolve(o))) - .and_then(|o| o.as_array().cloned()) + .and_then(|d| d.as_dict().cloned()) .unwrap_or_default(); + let src_list = |k: &[u8]| src_config.get(k).map(|v| src.resolve(v)).and_then(|v| v.as_array().cloned()).unwrap_or_default(); + let (src_on, src_off) = (src_list(b"ON"), src_list(b"OFF")); + // With `/BaseState /OFF`, a layer is hidden unless `/ON` lists it (§8.11.4.3, Table 101). + let base_off = src_config.get(b"BaseState").map(|b| src.resolve(b)).is_some_and(|b| b.as_name() == Some(b"OFF".as_slice())); + let hidden = |r: &Object| src_off.contains(r) || (base_off && !src_on.contains(r)); let root = dst.root().ok_or(OrganizeError::NoPageTree)?; let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; let (props_ref, mut props) = match catalog.get(b"OCProperties") { @@ -608,7 +466,7 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)] } all.push(r.clone()); order.push(r.clone()); - if src_off.contains(&Object::Ref(*from)) { off.push(r) } else { on.push(r) } + if hidden(&Object::Ref(*from)) { off.push(r) } else { on.push(r) } } props.set(b"OCGs".to_vec(), Object::Array(all)); config.set(b"ON".to_vec(), Object::Array(on)); @@ -622,710 +480,6 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)] Ok(()) } -/// Rename the top-level fields about to be registered in `dst` whose fully-qualified names -/// (`parent.T.child`) are already taken, so each imported field stays its own field instead of -/// blurring into the destination's field of the same name (fields of one fully-qualified name -/// are one field). A colliding node's own `/T` becomes the first free `Name-1`, `Name-2`… -/// variant, so everything below it keeps its place in the tree. Returns what was renamed -/// (old fully-qualified name → new one), in order. -fn resolve_field_conflicts(dst: &mut Document, fields: &[ObjRef]) -> Result, OrganizeError> { - let mut taken = taken_field_names(dst)?; - let planned = plan_renames(dst, fields, &mut taken); - let mut renamed = Vec::new(); - for (node, new_t, old, new) in planned { - dst.update_dict(node, |d| d.set(b"T".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&new_t))))?; - renamed.push((old, new)); - } - Ok(renamed) -} - -/// What importing `src` into `dst` would rename, computed without changing either document: -/// each fully-qualified name of `src`'s fields that `dst` already uses (or that an earlier -/// field of `src` itself uses) is renamed to the first free variant, exactly as the import -/// would. Unreadable structure previews as nothing: the import itself will say why it failed. -pub fn field_name_conflicts(dst: &Document, src: &Document) -> Vec<(String, String)> { - let Ok(mut taken) = taken_field_names(dst) else { return Vec::new() }; - let Ok(roots) = field_roots(src) else { return Vec::new() }; - plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect() -} - -/// What combining `sources`, in order, would rename in each one: the whole list is simulated -/// with the same first-free-variant rule the combine uses, so a later file's preview sees the -/// earlier files' renames. Unreadable structure previews as nothing (the combine itself will -/// say why it fails); non-colliding names still count as taken for the files after them. -pub fn combine_field_conflicts(sources: &[&Document]) -> Vec> { - let mut taken = std::collections::HashSet::new(); - sources - .iter() - .map(|src| match field_roots(src) { - Ok(roots) => plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect(), - Err(_) => Vec::new(), - }) - .collect() -} - -/// Walk field trees `roots` in `doc` and plan the renames that would keep every name separate -/// from `taken`: each node's fully-qualified name joins `taken` (so a later field of the same -/// import collides with an earlier one), and a node already taken is renamed to the first free -/// variant of its own `/T`. Names are compared and renamed as decoded text, not as stored -/// bytes, so one name written in two encodings (PDFDocEncoding, UTF-16BE) is one name. -/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe. -fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet) -> Vec<(ObjRef, String, String, String)> { - let mut planned = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for &root in roots { - plan_renames_under(doc, root, "", taken, &mut seen, 0, &mut planned); - } - planned -} - -/// One node of [`plan_renames`]. -fn plan_renames_under( - doc: &Document, - node: ObjRef, - prefix: &str, - taken: &mut std::collections::HashSet, - seen: &mut std::collections::HashSet, - depth: u8, - planned: &mut Vec<(ObjRef, String, String, String)>, -) { - if depth > 32 || !seen.insert(node) { - return; - } - let Some(d) = doc.get(node).as_dict().cloned() else { return }; - let mut name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); - if !name.is_empty() { - let fqn = qualified(prefix, &name); - if taken.contains(&fqn) { - let mut n = 1u32; - let mut variant; - while { - variant = format!("{name}-{n}"); - taken.contains(&qualified(prefix, &variant)) - } { - n += 1; - } - let new_fqn = qualified(prefix, &variant); - planned.push((node, variant.clone(), fqn, new_fqn.clone())); - taken.insert(new_fqn); - name = variant; - } else { - taken.insert(fqn); - } - } - let prefix = qualified_optional(prefix, &name); - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for kid in kids.iter().filter_map(|k| k.as_ref()) { - plan_renames_under(doc, kid, &prefix, taken, seen, depth + 1, planned); - } -} - -/// The field names in use in `dst`: every name in its form, and every name hanging only off a -/// page's widgets — a field the form never lists still owns its name. -fn taken_field_names(dst: &Document) -> Result, OrganizeError> { - let mut taken = std::collections::HashSet::new(); - let mut seen = std::collections::HashSet::new(); - for root in field_roots(dst)? { - collect_names(dst, root, "", &mut seen, 0, &mut taken); - } - Ok(taken) -} - -/// A document's field trees, wherever they are rooted: the `/AcroForm /Fields` entries, and -/// the fields behind widgets on pages (climbed to their root, for forms that never registered -/// them). -fn field_roots(doc: &Document) -> Result, OrganizeError> { - let mut roots: Vec = doc - .root() - .and_then(|r| doc.get(r).as_dict().cloned()) - .and_then(|c| c.get(b"AcroForm").map(|f| doc.resolve(f))) - .and_then(|f| f.as_dict().cloned()) - .and_then(|f| f.get(b"Fields").map(|l| doc.resolve(l))) - .and_then(|l| l.as_array().cloned()) - .unwrap_or_default() - .iter() - .filter_map(|f| f.as_ref()) - .collect(); - for (page, _) in walk(doc)? { - let Some(d) = doc.get(page).as_dict().cloned() else { continue }; - let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); - for a in annots.iter() { - let Some(w) = doc.resolve(a).as_dict().cloned() else { continue }; - if w.name(b"Subtype") != Some(b"Widget") { - continue; - } - // Walk up to the field the widget belongs to, however deep it sits; a widget that - // is its own field is its own root. - let Some(mut root) = a.as_ref() else { continue }; - let mut up = 0; - while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) { - if up >= 32 || root == parent { - break; - } - root = parent; - up += 1; - } - roots.push(root); - } - } - Ok(roots) -} - -/// Record the fully-qualified names of one field tree. Cycle- and depth-safe. -fn collect_names( - dst: &Document, - node: ObjRef, - prefix: &str, - seen: &mut std::collections::HashSet, - depth: u8, - out: &mut std::collections::HashSet, -) { - if depth > 32 || !seen.insert(node) { - return; - } - let Some(d) = dst.get(node).as_dict().cloned() else { return }; - let name = d.get(b"T").and_then(|t| dst.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); - let prefix = qualified_optional(prefix, &name); - if !name.is_empty() { - out.insert(prefix.clone()); - } - let kids = d.get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for kid in kids.iter().filter_map(|k| k.as_ref()) { - collect_names(dst, kid, &prefix, seen, depth + 1, out); - } -} - -/// `prefix.name`, or `name` at the top level. -fn qualified(prefix: &str, name: &str) -> String { - if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") } -} - -/// The prefix a node's children hang under: its own name, when it has one. -fn qualified_optional(prefix: &str, name: &str) -> String { - if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) } -} - -/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). -fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { - if fields.is_empty() { - return Ok(()); - } - let root = dst.root().ok_or(OrganizeError::NoPageTree)?; - let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; - let (form_ref, mut form) = match catalog.get(b"AcroForm") { - Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()), - Some(Object::Dict(d)) => (None, d.clone()), - _ => (None, Dict::new()), - }; - let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - for f in fields { - if !list.contains(&Object::Ref(*f)) { - list.push(Object::Ref(*f)); - } - } - form.set(b"Fields".to_vec(), Object::Array(list)); - match form_ref { - Some(r) => dst.set(r, Object::Dict(form)), - None => { - let r = dst.add(form); - dst.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; - } - } - Ok(()) -} - -/// A new document containing copies of `pages` from `src` (Extract Pages / Split). -/// Document information (title, author…) is carried over, and so is the print standard the -/// source declares (PDF/X: its output intents and identification, #263). -pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { - let mut out = Document::new_empty(); - // One source: nothing to deduplicate. Its fields keep their names (a source with two - // same-named field roots stays one field, as it was), so Extract and Split never rename. - let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; - for key in crate::INFO_KEYS { - if let Some(v) = crate::info(src, key) { - crate::set_info(&mut out, key, &v)?; - } - } - // After the title: the XMP packet repeats it. - crate::pdfx::carry(&mut out, Some(src), &crate::pdfx::PrintStandard::of(src), imported.objects)?; - // The pages share their source's resource dictionary, so the part would otherwise carry every - // XObject the source lists — images and all (#204). Keep only what these pages draw. - crate::prune::prune_unused_xobjects(&mut out)?; - Ok(out) -} - -/// How to split a document. -#[derive(Clone, Debug, PartialEq)] -pub enum SplitBy { - /// Every `n` pages. - PageCount(usize), - /// Before each of these 0-based page indices (e.g. `[3, 7]` → 0–2, 3–6, 7–end). - Before(Vec), -} - -/// The page ranges (0-based, end-exclusive) a split produces. -pub fn split_ranges(page_count: usize, by: &SplitBy) -> Vec> { - let mut cuts: Vec = match by { - SplitBy::PageCount(n) => (1..).map(|i| i * (*n).max(1)).take_while(|c| *c < page_count).collect(), - SplitBy::Before(v) => v.iter().copied().filter(|c| *c > 0 && *c < page_count).collect(), - }; - cuts.sort_unstable(); - cuts.dedup(); - let mut ranges = Vec::new(); - let mut start = 0; - for c in cuts.into_iter().chain(std::iter::once(page_count)) { - if c > start { - ranges.push(start..c); - start = c; - } - } - ranges -} - -/// Split a document into several new documents. -pub fn split(src: &Document, by: &SplitBy) -> Result, OrganizeError> { - let n = crate::page_count(src)?; - split_ranges(n, by).into_iter().map(|r| extract_pages(src, &r.collect::>())).collect() -} - -/// What combining produced, beyond the document: the fields renamed to keep colliding names -/// separate (old fully-qualified name → new one), in order across all sources. -pub struct CombineOutcome { - /// The combined document. - pub doc: Document, - /// Every field renamed while combining. - pub renamed_fields: Vec<(String, String)>, -} - -/// Combine whole documents, in order, into a new document. Each source gets a top-level -/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files. -/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level -/// attachments are carried over. When every source declares the same print standard (PDF/X -/// with the same output intent), so does the result. -pub fn combine(sources: &[(&str, &Document)]) -> Result { - combine_with(sources, FieldConflicts::Rename).map(|o| o.doc) -} - -/// [`combine`] reporting what the conflict resolution renamed. -pub fn combine_with(sources: &[(&str, &Document)], conflicts: FieldConflicts) -> Result { - let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect(); - combine_selected_with(&all, conflicts) -} - -/// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order; -/// `None` for all of them). Bookmarks that point at pages left out lose their destination. -pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result { - combine_selected_with(sources, FieldConflicts::Rename).map(|o| o.doc) -} - -/// [`combine_selected`] reporting what the conflict resolution renamed. -pub fn combine_selected_with(sources: &[(&str, &Document, Option<&[usize]>)], conflicts: FieldConflicts) -> Result { - let mut combiner = Combiner::with_field_conflicts(conflicts); - for (title, src, chosen) in sources { - combiner.add(title, src, *chosen)?; - } - combiner.finish_with_renames() -} - -/// Combine Files one source at a time, with the same result as [`combine_selected`]. [`Combiner::add`] -/// copies a source's pages into the result and keeps only what the rest of the combine needs: its -/// bookmarks (read and mapped to the copied pages), its attachments (copied), and its document only -/// when it declares output intents. So each source can be dropped as soon as it is added, and a long -/// list holds one source at a time rather than all of them. -pub struct Combiner { - out: Document, - marks: Vec, - attachments: Vec<(Vec, Object)>, - /// The first source's print standard, and what its pages brought along. - standard: Option, - /// Whether every source so far declares the first source's print standard. - agreed: bool, - /// How colliding field names are resolved as each source is added. - field_conflicts: FieldConflicts, - /// What adding sources renamed (old → new fully-qualified field name), in order. - renamed_fields: Vec<(String, String)>, -} - -/// The first source's print standard (see [`Combiner`]). -struct Standard { - declared: crate::pdfx::PrintStandard, - /// The source objects the copied pages brought along, and their copies. - objects: HashMap, - /// The source itself, kept only for its output intents, which are copied from it. - source: Option, -} - -impl Default for Combiner { - fn default() -> Self { - Self::new() - } -} - -impl Combiner { - /// A combine with no sources yet, renaming colliding field names as sources are added. - pub fn new() -> Self { - Self::with_field_conflicts(FieldConflicts::Rename) - } - - /// [`Combiner::new`] resolving colliding field names as `conflicts` says. - pub fn with_field_conflicts(field_conflicts: FieldConflicts) -> Self { - Self { - out: Document::new_empty(), - marks: Vec::new(), - attachments: Vec::new(), - standard: None, - agreed: true, - field_conflicts, - renamed_fields: Vec::new(), - } - } - - /// What adding sources has renamed so far (old → new fully-qualified field name). - pub fn renamed_fields(&self) -> &[(String, String)] { - &self.renamed_fields - } - - /// Add `src`'s `pages` (0-based, in that order; `None` for all of them) after the pages added so far, - /// under a bookmark titled `title`. `src` need not outlive the call. - pub fn add(&mut self, title: &str, src: &Document, pages: Option<&[usize]>) -> Result<(), OrganizeError> { - let n = crate::page_count(src)?; - let pages: Vec = match pages { - Some(p) => { - if let Some(bad) = p.iter().find(|i| **i >= n) { - return Err(OrganizeError::NoSuchPage(*bad)); - } - p.to_vec() - } - None => (0..n).collect(), - }; - let at = crate::page_count(&self.out)?; - let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?; - self.renamed_fields.extend(imported.renamed_fields); - if let Some(first) = imported.pages.first() { - let outline = source_outline(src, imported.page_map); - self.marks.push(Mark { title: title.to_string(), page: *first, outline }); - } - collect_attachments(&mut self.out, src, &mut self.attachments)?; - let declared = crate::pdfx::PrintStandard::of(src); - match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) { - Some(same) => self.agreed &= same, - None => { - let source = declared.declares_output_intents().then(|| src.clone()); - self.standard = Some(Standard { declared, objects: imported.objects, source }); - } - } - Ok(()) - } - - /// The combined document: the pages as added, the print standard of the first source when every - /// source declares the same one, shared resources stored once, then the bookmarks and attachments. - pub fn finish(self) -> Result { - self.finish_with_renames().map(|o| o.doc) - } - - /// [`Combiner::finish`] reporting what the combine renamed. - pub fn finish_with_renames(self) -> Result { - let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self; - if let Some(first) = standard.filter(|_| agreed) { - crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?; - } - // Sources often share fonts, images and profiles (or are the same file): store them once. - let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); - crate::dedupe::dedupe_resources(&mut out, &all, false); - add_outline(&mut out, &marks)?; - set_attachments(&mut out, attachments)?; - Ok(CombineOutcome { doc: out, renamed_fields }) - } -} - -/// One run of pages for [`combine_grouped`]: the file it comes from (`group`: runs with the same -/// group are one file split around, and give the same document), its title, the document, and -/// its pages (0-based, in that order; `None` for all of them). -pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>); - -/// Combine Files where a file's pages may be split into several runs with other files' pages -/// between them. Each file (group) is copied once, all its pages together, then the pages are put -/// in the runs' order: its links between its own pages, its form fields and its attachments stay -/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its -/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources). -pub fn combine_grouped(runs: &[Run<'_>]) -> Result { - combine_grouped_with(runs, FieldConflicts::Rename).map(|o| o.doc) -} - -/// [`combine_grouped`] reporting what the conflict resolution renamed. -pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Result { - // Each file's pages, in the order its runs list them, and each run's place in that list. - struct File<'a> { - title: &'a str, - src: &'a Document, - pages: Vec, - } - let mut files: Vec<(usize, File<'_>)> = Vec::new(); - let mut slices: Vec<(usize, usize, usize)> = Vec::with_capacity(runs.len()); - for (group, title, src, chosen) in runs { - let at = match files.iter().position(|(g, _)| g == group) { - Some(at) => at, - None => { - files.push((*group, File { title, src, pages: Vec::new() })); - files.len() - 1 - } - }; - let Some((_, file)) = files.get_mut(at) else { continue }; - let n = crate::page_count(file.src)?; - let pages: Vec = match chosen { - Some(p) => { - if let Some(bad) = p.iter().find(|i| **i >= n) { - return Err(OrganizeError::NoSuchPage(*bad)); - } - p.to_vec() - } - None => (0..n).collect(), - }; - slices.push((at, file.pages.len(), pages.len())); - file.pages.extend(pages); - } - let mut out = Document::new_empty(); - let mut marks = Vec::new(); - let mut attachments = Vec::new(); - let mut copies: Vec> = Vec::with_capacity(files.len()); - let mut renamed_fields = Vec::new(); - let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap)> = None; - let mut agreed = true; - for (_, file) in &files { - let at = crate::page_count(&out)?; - let imported = import_pages_mapped(&mut out, file.src, &file.pages, at, conflicts)?; - copies.push(imported.pages.clone()); - renamed_fields.extend(imported.renamed_fields); - if let Some(first) = imported.pages.first() { - marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) }); - } - collect_attachments(&mut out, file.src, &mut attachments)?; - let declared = crate::pdfx::PrintStandard::of(file.src); - match &standard { - None => standard = Some((declared, file.src, imported.objects)), - Some((first, _, _)) => agreed &= first.same_as(&declared), - } - } - // The pages in the runs' order. A file's first run comes before its others, and its pages - // were copied in run order, so its bookmark (its first copied page) is its first page shown. - let mut order: Vec<(ObjRef, Dict)> = Vec::new(); - for (file, start, len) in slices { - let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default(); - order.extend(run.iter().map(|r| (*r, Dict::new()))); - } - rebuild(&mut out, &order)?; - if let Some((declared, src, objects)) = standard.filter(|_| agreed) { - crate::pdfx::carry(&mut out, Some(src), &declared, objects)?; - } - let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); - crate::dedupe::dedupe_resources(&mut out, &all, false); - add_outline(&mut out, &marks)?; - set_attachments(&mut out, attachments)?; - Ok(CombineOutcome { doc: out, renamed_fields }) -} - -/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. -fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { - let Some(tree) = src - .root() - .and_then(|r| src.get(r).as_dict().cloned()) - .and_then(|c| c.get(b"Names").map(|n| src.resolve(n))) - .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) - else { - return Ok(()); - }; - let mut entries = Vec::new(); - name_tree_entries(src, &tree, 0, &mut entries); - let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); - for (k, v) in entries { - let v = copier.copy_value(dst, &v, None)?; - out.push((k, v)); - } - Ok(()) -} - -/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"… -fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { - if entries.is_empty() { - return Ok(()); - } - let mut seen = std::collections::HashSet::new(); - for (k, _) in entries.iter_mut() { - let base = k.clone(); - let mut n = 2; - while !seen.insert(k.clone()) { - *k = [base.as_slice(), format!(" ({n})").as_bytes()].concat(); - n += 1; - } - } - entries.sort_by(|a, b| a.0.cmp(&b.0)); - let mut names = Vec::new(); - for (k, v) in entries { - names.push(Object::String(pdfcraft_cos::PdfString::literal(k))); - names.push(v); - } - let mut tree = Dict::new(); - tree.set(b"Names".to_vec(), Object::Array(names)); - let tree = dst.add(tree); - let root = dst.root().ok_or(OrganizeError::NoPageTree)?; - dst.update_dict(root, |c| { - let mut n = match c.get(b"Names") { - Some(Object::Dict(d)) => d.clone(), - _ => Dict::new(), - }; - n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)); - c.set(b"Names".to_vec(), Object::Dict(n)); - })?; - Ok(()) -} - -/// A source's entry in the combined outline (see [`add_outline`]): its title, its first page in the -/// result, and its own bookmarks, read from the source when it was added. -struct Mark { - title: String, - page: ObjRef, - outline: Vec, -} - -/// One of a source's bookmarks, read and ready to write: its title, colour and flags, its destination -/// in the result (`None` when the page it points at was not copied), whether it is open, and its own -/// bookmarks. -struct OutlineNode { - title: Option, - colour: Option, - flags: Option, - dest: Option, - open: bool, - children: Vec, -} - -/// The bookmarks of `src`, with destinations mapped through `pages` (source page to copied page). They -/// are read while `src` is at hand, so the source itself need not be kept. -fn source_outline(src: &Document, pages: HashMap) -> Vec { - let first = src - .root() - .and_then(|r| src.get(r).as_dict().cloned()) - .and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o))) - .and_then(|o| o.as_dict().and_then(|d| d.reference(b"First"))); - let mapper = Copier::new(src, HashMap::new(), pages); - let mut budget = 10_000usize; - outline_level(&mapper, first, 0, &mut budget) -} - -/// One level of bookmarks from `first` (its siblings), each with its children. Cycle- and size-safe: -/// at most `budget` bookmarks are read in all, and nesting stops at 32 levels. -fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut usize) -> Vec { - if depth > 32 { - return Vec::new(); - } - let mut seen = std::collections::HashSet::new(); - let mut made = Vec::new(); - let mut cur = first; - while let Some(r) = cur { - if !seen.insert(r) || *budget == 0 { - break; - } - *budget -= 1; - let Some(item) = m.src.get(r).as_dict().cloned() else { break }; - cur = item.reference(b"Next"); - let title = item.get(b"Title").map(|t| m.src.resolve(t).as_ref().clone()); - let colour = item.get(b"C").map(|v| m.src.resolve(v).as_ref().clone()); - let flags = item.get(b"F").map(|v| m.src.resolve(v).as_ref().clone()); - let target = item.get(b"Dest").cloned().or_else(|| { - let a = m.src.resolve(item.get(b"A")?); - let a = a.as_dict()?; - (a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten() - }); - let dest = target.and_then(|t| m.map_dest(&t)); - let open = item.int(b"Count").is_some_and(|c| c > 0); - let children = outline_level(m, item.reference(b"First"), depth + 1, budget); - made.push(OutlineNode { title, colour, flags, dest, open, children }); - } - made -} - -/// Write `nodes` as one level under `parent`: each bookmark is created, then its children, depth first -/// and in order, so the object numbers come out as they always did. Returns the level's first and last -/// item and how many items it has (`None` when it is empty). -fn write_outline_level(dst: &mut Document, parent: ObjRef, nodes: &[OutlineNode]) -> Result, OrganizeError> { - let mut made: Vec = Vec::new(); - for node in nodes { - let new = dst.add(Object::Null); - let mut d = Dict::new(); - if let Some(t) = &node.title { - d.set(b"Title".to_vec(), t.clone()); - } - if let Some(c) = &node.colour { - d.set(b"C".to_vec(), c.clone()); - } - if let Some(f) = &node.flags { - d.set(b"F".to_vec(), f.clone()); - } - if let Some(dest) = &node.dest { - d.set(b"Dest".to_vec(), dest.clone()); - } - d.set(b"Parent".to_vec(), Object::Ref(parent)); - if let Some(prev) = made.last() { - d.set(b"Prev".to_vec(), Object::Ref(*prev)); - dst.update_dict(*prev, |p| p.set(b"Next".to_vec(), Object::Ref(new)))?; - } - if let Some((f, l, n)) = write_outline_level(dst, new, &node.children)? { - d.set(b"First".to_vec(), Object::Ref(f)); - d.set(b"Last".to_vec(), Object::Ref(l)); - // Keep the source's open/closed state. - d.set(b"Count".to_vec(), Object::Int(if node.open { n as i64 } else { -(n as i64) })); - } - dst.set(new, Object::Dict(d)); - made.push(new); - } - Ok(match (made.first(), made.last()) { - (Some(first), Some(last)) => Some((*first, *last, made.len())), - _ => None, - }) -} - -/// Replace the outline with one bookmark per source (title → its first page), each holding a collapsed -/// copy of that source's own bookmarks with destinations mapped to the copied pages. -fn add_outline(doc: &mut Document, marks: &[Mark]) -> Result<(), OrganizeError> { - if marks.is_empty() { - return Ok(()); - } - let outlines = doc.add(Object::Null); - let items: Vec = marks.iter().map(|_| doc.add(Object::Null)).collect(); - let (Some(&first_item), Some(&last_item)) = (items.first(), items.last()) else { return Ok(()) }; - for (i, (mark, r)) in marks.iter().zip(&items).enumerate() { - let mut d = Dict::new(); - d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&mark.title))); - d.set(b"Parent".to_vec(), Object::Ref(outlines)); - d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(mark.page), Object::name("Fit")])); - if i > 0 { - d.set(b"Prev".to_vec(), Object::Ref(items[i - 1])); - } - if let Some(next) = items.get(i + 1) { - d.set(b"Next".to_vec(), Object::Ref(*next)); - } - if let Some((f, l, n)) = write_outline_level(doc, *r, &mark.outline)? { - d.set(b"First".to_vec(), Object::Ref(f)); - d.set(b"Last".to_vec(), Object::Ref(l)); - d.set(b"Count".to_vec(), Object::Int(-(n as i64))); // collapsed - } - doc.set(*r, Object::Dict(d)); - } - let mut o = Dict::new(); - o.set(b"Type".to_vec(), Object::name("Outlines")); - o.set(b"First".to_vec(), Object::Ref(first_item)); - o.set(b"Last".to_vec(), Object::Ref(last_item)); - o.set(b"Count".to_vec(), Object::Int(items.len() as i64)); - doc.set(outlines, Object::Dict(o)); - let root = doc.root().ok_or(OrganizeError::NoPageTree)?; - doc.update_dict(root, |c| { - c.set(b"Outlines".to_vec(), Object::Ref(outlines)); - c.set(b"PageMode".to_vec(), Object::name("UseOutlines")); - })?; - Ok(()) -} - /// Page `page` of `src` as a form XObject in `dst` (its content and resources, no annotations), /// for backgrounds and watermarks taken from a PDF. The form's `/BBox` is the page's crop box /// and its `/Matrix` undoes the page rotation, so it draws upright as displayed. Returns the @@ -1346,19 +500,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<( let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]); let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360); // Content: the page's streams, decoded and joined. - let mut content = Vec::new(); - if let Some(c) = own.get(b"Contents") { - let list = match &*src.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }; - for o in list { - if let Object::Stream(s) = &*src.resolve(&o) { - content.extend(s.decoded().map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?); - content.push(b'\n'); - } - } - } + let content = match own.get(b"Contents") { + Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?, + None => Vec::new(), + }; let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); let resources = match d.get(b"Resources") { Some(r) => copier.copy_value(dst, r, None)?, diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs new file mode 100644 index 0000000000..15a412e231 --- /dev/null +++ b/crates/organize/src/import/carry.rs @@ -0,0 +1,233 @@ +use super::copy::Copier; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, name_tree_entries}; + +/// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. +pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { + let Some(tree) = src + .root() + .and_then(|r| src.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Names").map(|n| src.resolve(n))) + .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) + else { + return Ok(()); + }; + let mut entries = Vec::new(); + name_tree_entries(src, &tree, 0, &mut HashSet::new(), &mut entries); + let mut copier = Copier::new(src, HashMap::new(), HashMap::new()); + for (k, v) in entries { + let v = copier.copy_value(dst, &v, None)?; + out.push((k, v)); + } + Ok(()) +} + +/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"… +pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { + if entries.is_empty() { + return Ok(()); + } + // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent + // or Chinese letters) must not get ASCII bytes appended to it. + let mut seen = std::collections::HashSet::new(); + for (k, _) in entries.iter_mut() { + let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); + let mut text = base.clone(); + let mut n = 2; + while !seen.insert(text.clone()) { + text = format!("{base} ({n})"); + n += 1; + } + if text != base { + *k = pdfcraft_cos::PdfString::text(&text).bytes; + } + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut names = Vec::new(); + for (k, v) in entries { + names.push(Object::String(pdfcraft_cos::PdfString::literal(k))); + names.push(v); + } + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(names)); + let tree = dst.add(tree); + let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + // `/Names` may be an indirect dictionary (Acrobat writes it so): update it where it is, so + // its other trees (/Dests, /JavaScript…) stay. + if let Some(Object::Ref(names)) = dst.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()) + && dst.get(names).as_dict().is_some() + { + dst.update_dict(names, |n| n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)))?; + return Ok(()); + } + dst.update_dict(root, |c| { + let mut n = match c.get(b"Names") { + Some(Object::Dict(d)) => d.clone(), + _ => Dict::new(), + }; + n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)); + c.set(b"Names".to_vec(), Object::Dict(n)); + })?; + Ok(()) +} + +/// A source's entry in the combined outline (see [`add_outline`]): its title, its first page in the +/// result, and its own bookmarks, read from the source when it was added. +pub(super) struct Mark { + pub(super) title: String, + pub(super) page: ObjRef, + pub(super) outline: Vec, +} + +/// One of a source's bookmarks, read and ready to write: its title, colour and flags, its destination +/// in the result (`None` when the page it points at was not copied), whether it is open, and its own +/// bookmarks. +pub(super) struct OutlineNode { + title: Option, + colour: Option, + flags: Option, + dest: Option, + open: bool, + children: Vec, +} + +/// The bookmarks of `src`, with destinations mapped through `pages` (source page to copied page). They +/// are read while `src` is at hand, so the source itself need not be kept. +pub(super) fn source_outline(src: &Document, pages: HashMap) -> Vec { + let first = src + .root() + .and_then(|r| src.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o))) + .and_then(|o| o.as_dict().and_then(|d| d.reference(b"First"))); + let mapper = Copier::new(src, HashMap::new(), pages); + let mut budget = 10_000usize; + outline_level(&mapper, first, 0, &mut budget) +} + +/// `nodes` without the bookmarks whose page was not copied (no destination left), unless a bookmark +/// under them stays. [`outline_level`] already bounds the depth. +pub(super) fn kept_bookmarks(nodes: Vec) -> Vec { + nodes + .into_iter() + .filter_map(|mut n| { + n.children = kept_bookmarks(n.children); + (n.dest.is_some() || !n.children.is_empty()).then_some(n) + }) + .collect() +} + +/// One level of bookmarks from `first` (its siblings), each with its children. Cycle- and size-safe: +/// at most `budget` bookmarks are read in all, and nesting stops at 32 levels. +fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut usize) -> Vec { + if depth > 32 { + return Vec::new(); + } + let mut seen = std::collections::HashSet::new(); + let mut made = Vec::new(); + let mut cur = first; + while let Some(r) = cur { + if !seen.insert(r) || *budget == 0 { + break; + } + *budget -= 1; + let Some(item) = m.src.get(r).as_dict().cloned() else { break }; + cur = item.reference(b"Next"); + let title = item.get(b"Title").map(|t| m.src.resolve(t).as_ref().clone()); + let colour = item.get(b"C").map(|v| m.src.resolve(v).as_ref().clone()); + let flags = item.get(b"F").map(|v| m.src.resolve(v).as_ref().clone()); + let target = item.get(b"Dest").cloned().or_else(|| { + let a = m.src.resolve(item.get(b"A")?); + let a = a.as_dict()?; + (a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten() + }); + let dest = target.and_then(|t| m.map_dest(&t)); + let open = item.int(b"Count").is_some_and(|c| c > 0); + let children = outline_level(m, item.reference(b"First"), depth + 1, budget); + made.push(OutlineNode { title, colour, flags, dest, open, children }); + } + made +} + +/// Write `nodes` as one level under `parent`: each bookmark is created, then its children, depth first +/// and in order, so the object numbers come out as they always did. Returns the level's first and last +/// item and how many items it has (`None` when it is empty). +pub(super) fn write_outline_level( + dst: &mut Document, + parent: ObjRef, + nodes: &[OutlineNode], +) -> Result, OrganizeError> { + let mut made: Vec = Vec::new(); + for node in nodes { + let new = dst.add(Object::Null); + let mut d = Dict::new(); + if let Some(t) = &node.title { + d.set(b"Title".to_vec(), t.clone()); + } + if let Some(c) = &node.colour { + d.set(b"C".to_vec(), c.clone()); + } + if let Some(f) = &node.flags { + d.set(b"F".to_vec(), f.clone()); + } + if let Some(dest) = &node.dest { + d.set(b"Dest".to_vec(), dest.clone()); + } + d.set(b"Parent".to_vec(), Object::Ref(parent)); + if let Some(prev) = made.last() { + d.set(b"Prev".to_vec(), Object::Ref(*prev)); + dst.update_dict(*prev, |p| p.set(b"Next".to_vec(), Object::Ref(new)))?; + } + if let Some((f, l, n)) = write_outline_level(dst, new, &node.children)? { + d.set(b"First".to_vec(), Object::Ref(f)); + d.set(b"Last".to_vec(), Object::Ref(l)); + // Keep the source's open/closed state. + d.set(b"Count".to_vec(), Object::Int(if node.open { n as i64 } else { -(n as i64) })); + } + dst.set(new, Object::Dict(d)); + made.push(new); + } + Ok(match (made.first(), made.last()) { + (Some(first), Some(last)) => Some((*first, *last, made.len())), + _ => None, + }) +} + +/// Replace the outline with one bookmark per source (title → its first page), each holding a collapsed +/// copy of that source's own bookmarks with destinations mapped to the copied pages. +pub(super) fn add_outline(doc: &mut Document, marks: &[Mark]) -> Result<(), OrganizeError> { + if marks.is_empty() { + return Ok(()); + } + let outlines = doc.add(Object::Null); + let items: Vec = marks.iter().map(|_| doc.add(Object::Null)).collect(); + let (Some(&first_item), Some(&last_item)) = (items.first(), items.last()) else { return Ok(()) }; + for (i, (mark, r)) in marks.iter().zip(&items).enumerate() { + let mut d = Dict::new(); + d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&mark.title))); + d.set(b"Parent".to_vec(), Object::Ref(outlines)); + d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(mark.page), Object::name("Fit")])); + if i > 0 { + d.set(b"Prev".to_vec(), Object::Ref(items[i - 1])); + } + if let Some(next) = items.get(i + 1) { + d.set(b"Next".to_vec(), Object::Ref(*next)); + } + if let Some((f, l, n)) = write_outline_level(doc, *r, &mark.outline)? { + d.set(b"First".to_vec(), Object::Ref(f)); + d.set(b"Last".to_vec(), Object::Ref(l)); + d.set(b"Count".to_vec(), Object::Int(-(n as i64))); // collapsed + } + doc.set(*r, Object::Dict(d)); + } + let mut o = Dict::new(); + o.set(b"Type".to_vec(), Object::name("Outlines")); + o.set(b"First".to_vec(), Object::Ref(first_item)); + o.set(b"Last".to_vec(), Object::Ref(last_item)); + o.set(b"Count".to_vec(), Object::Int(items.len() as i64)); + doc.set(outlines, Object::Dict(o)); + let root = doc.root().ok_or(OrganizeError::NoPageTree)?; + doc.update_dict(root, |c| { + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + c.set(b"PageMode".to_vec(), Object::name("UseOutlines")); + })?; + Ok(()) +} diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs new file mode 100644 index 0000000000..06972ae101 --- /dev/null +++ b/crates/organize/src/import/combine.rs @@ -0,0 +1,307 @@ +use super::carry::{Mark, add_outline, collect_attachments, kept_bookmarks, set_attachments, source_outline, write_outline_level}; +use super::{Dict, Document, FieldConflicts, HashMap, ObjRef, Object, OrganizeError, import_pages_mapped, rebuild}; + +/// A new document containing copies of `pages` from `src` (Extract Pages / Split). +/// Document information (title, author…) is carried over, and so is the print standard the +/// source declares (PDF/X: its output intents and identification, #263), and the bookmarks that +/// go to the copied pages. +pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { + let mut out = Document::new_empty(); + // One source: nothing to deduplicate. Its fields keep their names (a source with two + // same-named field roots stays one field, as it was), so Extract and Split never rename. + let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; + // The bookmarks of these pages come along, mapped to the copies (#4020). + let outline = kept_bookmarks(source_outline(src, imported.page_map)); + if !outline.is_empty() { + let root = crate::outline::container(&mut out, &[])?; + if let Some((first, last, _)) = write_outline_level(&mut out, root, &outline)? { + out.update_dict(root, |d| { + d.set(b"First".to_vec(), Object::Ref(first)); + d.set(b"Last".to_vec(), Object::Ref(last)); + })?; + let top = crate::outline::children(&out, root); + crate::outline::relink_and_recount(&mut out, root, &top)?; + } + } + for key in crate::INFO_KEYS { + if let Some(v) = crate::info(src, key) { + crate::set_info(&mut out, key, &v)?; + } + } + // After the title: the XMP packet repeats it. + crate::pdfx::carry(&mut out, Some(src), &crate::pdfx::PrintStandard::of(src), imported.objects)?; + // The pages share their source's resource dictionary, so the part would otherwise carry every + // XObject the source lists — images and all (#204). Keep only what these pages draw. + crate::prune::prune_unused_xobjects(&mut out)?; + Ok(out) +} + +/// How to split a document. +#[derive(Clone, Debug, PartialEq)] +pub enum SplitBy { + /// Every `n` pages. + PageCount(usize), + /// Before each of these 0-based page indices (e.g. `[3, 7]` → 0–2, 3–6, 7–end). + Before(Vec), +} + +/// The page ranges (0-based, end-exclusive) a split produces. +pub fn split_ranges(page_count: usize, by: &SplitBy) -> Vec> { + let mut cuts: Vec = match by { + SplitBy::PageCount(n) => (1..).map(|i| i * (*n).max(1)).take_while(|c| *c < page_count).collect(), + SplitBy::Before(v) => v.iter().copied().filter(|c| *c > 0 && *c < page_count).collect(), + }; + cuts.sort_unstable(); + cuts.dedup(); + let mut ranges = Vec::new(); + let mut start = 0; + for c in cuts.into_iter().chain(std::iter::once(page_count)) { + if c > start { + ranges.push(start..c); + start = c; + } + } + ranges +} + +/// Split a document into several new documents. +pub fn split(src: &Document, by: &SplitBy) -> Result, OrganizeError> { + let n = crate::page_count(src)?; + split_ranges(n, by).into_iter().map(|r| extract_pages(src, &r.collect::>())).collect() +} + +/// What combining produced, beyond the document: the fields renamed to keep colliding names +/// separate (old fully-qualified name → new one), in order across all sources. +pub struct CombineOutcome { + /// The combined document. + pub doc: Document, + /// Every field renamed while combining. + pub renamed_fields: Vec<(String, String)>, +} + +/// Combine whole documents, in order, into a new document. Each source gets a top-level +/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files. +/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level +/// attachments are carried over. When every source declares the same print standard (PDF/X +/// with the same output intent), so does the result. +pub fn combine(sources: &[(&str, &Document)]) -> Result { + combine_with(sources, FieldConflicts::Rename).map(|o| o.doc) +} + +/// [`combine`] reporting what the conflict resolution renamed. +pub fn combine_with(sources: &[(&str, &Document)], conflicts: FieldConflicts) -> Result { + let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect(); + combine_selected_with(&all, conflicts) +} + +/// Combine Files with chosen pages: each source contributes `pages` (0-based, in that order; +/// `None` for all of them). Bookmarks that point at pages left out lose their destination. +pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Result { + combine_selected_with(sources, FieldConflicts::Rename).map(|o| o.doc) +} + +/// [`combine_selected`] reporting what the conflict resolution renamed. +pub fn combine_selected_with(sources: &[(&str, &Document, Option<&[usize]>)], conflicts: FieldConflicts) -> Result { + let mut combiner = Combiner::with_field_conflicts(conflicts); + for (title, src, chosen) in sources { + combiner.add(title, src, *chosen)?; + } + combiner.finish_with_renames() +} + +/// Combine Files one source at a time, with the same result as [`combine_selected`]. [`Combiner::add`] +/// copies a source's pages into the result and keeps only what the rest of the combine needs: its +/// bookmarks (read and mapped to the copied pages), its attachments (copied), and its document only +/// when it declares output intents. So each source can be dropped as soon as it is added, and a long +/// list holds one source at a time rather than all of them. +pub struct Combiner { + out: Document, + marks: Vec, + attachments: Vec<(Vec, Object)>, + /// The first source's print standard, and what its pages brought along. + standard: Option, + /// Whether every source so far declares the first source's print standard. + agreed: bool, + /// How colliding field names are resolved as each source is added. + field_conflicts: FieldConflicts, + /// What adding sources renamed (old → new fully-qualified field name), in order. + renamed_fields: Vec<(String, String)>, +} + +/// The first source's print standard (see [`Combiner`]). +struct Standard { + declared: crate::pdfx::PrintStandard, + /// The source objects the copied pages brought along, and their copies. + objects: HashMap, + /// The source itself, kept only for its output intents, which are copied from it. + source: Option, +} + +impl Default for Combiner { + fn default() -> Self { + Self::new() + } +} + +impl Combiner { + /// A combine with no sources yet, renaming colliding field names as sources are added. + pub fn new() -> Self { + Self::with_field_conflicts(FieldConflicts::Rename) + } + + /// [`Combiner::new`] resolving colliding field names as `conflicts` says. + pub fn with_field_conflicts(field_conflicts: FieldConflicts) -> Self { + Self { + out: Document::new_empty(), + marks: Vec::new(), + attachments: Vec::new(), + standard: None, + agreed: true, + field_conflicts, + renamed_fields: Vec::new(), + } + } + + /// What adding sources has renamed so far (old → new fully-qualified field name). + pub fn renamed_fields(&self) -> &[(String, String)] { + &self.renamed_fields + } + + /// Add `src`'s `pages` (0-based, in that order; `None` for all of them) after the pages added so far, + /// under a bookmark titled `title`. `src` need not outlive the call. + pub fn add(&mut self, title: &str, src: &Document, pages: Option<&[usize]>) -> Result<(), OrganizeError> { + let n = crate::page_count(src)?; + let pages: Vec = match pages { + Some(p) => { + if let Some(bad) = p.iter().find(|i| **i >= n) { + return Err(OrganizeError::NoSuchPage(*bad)); + } + p.to_vec() + } + None => (0..n).collect(), + }; + let at = crate::page_count(&self.out)?; + let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?; + self.renamed_fields.extend(imported.renamed_fields); + if let Some(first) = imported.pages.first() { + let outline = source_outline(src, imported.page_map); + self.marks.push(Mark { title: title.to_string(), page: *first, outline }); + } + collect_attachments(&mut self.out, src, &mut self.attachments)?; + let declared = crate::pdfx::PrintStandard::of(src); + match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) { + Some(same) => self.agreed &= same, + None => { + let source = declared.declares_output_intents().then(|| src.clone()); + self.standard = Some(Standard { declared, objects: imported.objects, source }); + } + } + Ok(()) + } + + /// The combined document: the pages as added, the print standard of the first source when every + /// source declares the same one, shared resources stored once, then the bookmarks and attachments. + pub fn finish(self) -> Result { + self.finish_with_renames().map(|o| o.doc) + } + + /// [`Combiner::finish`] reporting what the combine renamed. + pub fn finish_with_renames(self) -> Result { + let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self; + if let Some(first) = standard.filter(|_| agreed) { + crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?; + } + // Sources often share fonts, images and profiles (or are the same file): store them once. + let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); + crate::dedupe::dedupe_resources(&mut out, &all, false); + add_outline(&mut out, &marks)?; + set_attachments(&mut out, attachments)?; + Ok(CombineOutcome { doc: out, renamed_fields }) + } +} + +/// One run of pages for [`combine_grouped`]: the file it comes from (`group`: runs with the same +/// group are one file split around, and give the same document), its title, the document, and +/// its pages (0-based, in that order; `None` for all of them). +pub type Run<'a> = (usize, &'a str, &'a Document, Option<&'a [usize]>); + +/// Combine Files where a file's pages may be split into several runs with other files' pages +/// between them. Each file (group) is copied once, all its pages together, then the pages are put +/// in the runs' order: its links between its own pages, its form fields and its attachments stay +/// whole, and it gets one top-level bookmark (the title of its first run) at its first page, its +/// own bookmarks nested. Otherwise as [`combine_selected`] (print standard, shared resources). +pub fn combine_grouped(runs: &[Run<'_>]) -> Result { + combine_grouped_with(runs, FieldConflicts::Rename).map(|o| o.doc) +} + +/// [`combine_grouped`] reporting what the conflict resolution renamed. +pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Result { + // Each file's pages, in the order its runs list them, and each run's place in that list. + struct File<'a> { + title: &'a str, + src: &'a Document, + pages: Vec, + } + let mut files: Vec<(usize, File<'_>)> = Vec::new(); + let mut slices: Vec<(usize, usize, usize)> = Vec::with_capacity(runs.len()); + for (group, title, src, chosen) in runs { + let at = match files.iter().position(|(g, _)| g == group) { + Some(at) => at, + None => { + files.push((*group, File { title, src, pages: Vec::new() })); + files.len() - 1 + } + }; + let Some((_, file)) = files.get_mut(at) else { continue }; + let n = crate::page_count(file.src)?; + let pages: Vec = match chosen { + Some(p) => { + if let Some(bad) = p.iter().find(|i| **i >= n) { + return Err(OrganizeError::NoSuchPage(*bad)); + } + p.to_vec() + } + None => (0..n).collect(), + }; + slices.push((at, file.pages.len(), pages.len())); + file.pages.extend(pages); + } + let mut out = Document::new_empty(); + let mut marks = Vec::new(); + let mut attachments = Vec::new(); + let mut copies: Vec> = Vec::with_capacity(files.len()); + let mut renamed_fields = Vec::new(); + let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap)> = None; + let mut agreed = true; + for (_, file) in &files { + let at = crate::page_count(&out)?; + let imported = import_pages_mapped(&mut out, file.src, &file.pages, at, conflicts)?; + copies.push(imported.pages.clone()); + renamed_fields.extend(imported.renamed_fields); + if let Some(first) = imported.pages.first() { + marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) }); + } + collect_attachments(&mut out, file.src, &mut attachments)?; + let declared = crate::pdfx::PrintStandard::of(file.src); + match &standard { + None => standard = Some((declared, file.src, imported.objects)), + Some((first, _, _)) => agreed &= first.same_as(&declared), + } + } + // The pages in the runs' order. A file's first run comes before its others, and its pages + // were copied in run order, so its bookmark (its first copied page) is its first page shown. + let mut order: Vec<(ObjRef, Dict)> = Vec::new(); + for (file, start, len) in slices { + let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default(); + order.extend(run.iter().map(|r| (*r, Dict::new()))); + } + rebuild(&mut out, &order)?; + if let Some((declared, src, objects)) = standard.filter(|_| agreed) { + crate::pdfx::carry(&mut out, Some(src), &declared, objects)?; + } + let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); + crate::dedupe::dedupe_resources(&mut out, &all, false); + add_outline(&mut out, &marks)?; + set_attachments(&mut out, attachments)?; + Ok(CombineOutcome { doc: out, renamed_fields }) +} diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs new file mode 100644 index 0000000000..5e4be54150 --- /dev/null +++ b/crates/organize/src/import/copy.rs @@ -0,0 +1,220 @@ +use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest}; + +/// Keys that must not be followed when copying: they point back up or across the source +/// document, and following them would drag in unrelated objects. +pub(super) const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"]; + +const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; + +/// How deep a copy follows references and nested arrays and dictionaries, counted together. +/// Real documents stay well under this (a page's resources, fonts and descriptors, nested +/// form XObjects and field trees are a few levels each); a chain of thousands of indirect +/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). +const MAX_COPY_DEPTH: usize = 256; + +/// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's +/// appearance, placement and page. Field state (name, value, flags, default appearance) moves to +/// the new parent field, where the widgets inherit it. +pub(super) const WIDGET_KEYS: &[&[u8]] = &[ + b"Type", + b"Subtype", + b"Rect", + b"P", + b"AP", + b"AS", + b"MK", + b"BS", + b"Border", + b"C", + b"CA", + b"F", + b"H", + b"R", + b"NM", + b"M", + b"StructParent", + b"StructParents", + b"OC", + b"Tabs", + b"A", + b"Contents", + b"BM", + b"Lang", + b"AF", +]; + +pub(crate) struct Copier<'a> { + pub(super) src: &'a Document, + /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. + depth: usize, + pub(crate) map: HashMap, + /// Source page → destination page, for rewriting `/P` and destinations. + pub(super) pages: HashMap, + /// Destination objects that are annotation dictionaries (fixed up after copying). + pub(super) annots: Vec, + /// Top-level form fields reached through widgets. + pub(super) fields: Vec, + /// Optional content groups copied: (source, destination). + pub(crate) ocgs: Vec<(ObjRef, ObjRef)>, +} + +impl<'a> Copier<'a> { + pub(crate) fn new(src: &'a Document, map: HashMap, pages: HashMap) -> Self { + Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() } + } + + /// Copy an indirect object (once), returning its destination reference. + pub(super) fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> Result { + if let Some(d) = self.map.get(&r) { + return Ok(*d); + } + // Reserve the number first so cycles (field ↔ widget) terminate. + let new = dst.add(Object::Null); + self.map.insert(r, new); + let obj = self.src.get(r); + if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) { + self.ocgs.push((r, new)); + } + let copied = self.copy_value(dst, &obj, Some(new))?; + dst.set(new, copied); + Ok(new) + } + + /// One level deeper into the object graph, or the refusal when there are too many. + fn descend(&mut self) -> Result<(), OrganizeError> { + if self.depth >= MAX_COPY_DEPTH { + return Err(OrganizeError::Invalid(format!("the pages refer to objects nested more than {MAX_COPY_DEPTH} levels deep"))); + } + self.depth += 1; + Ok(()) + } + + pub(crate) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option) -> Result { + if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) { + return Ok(o.clone()); + } + self.descend()?; + let copied = match o { + Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref), + Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::, _>>().map(Object::Array), + Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict), + Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| { + let mut s = s.clone(); + s.dict = dict; + Object::Stream(s) + }), + other => Ok(other.clone()), + }; + self.depth = self.depth.saturating_sub(1); + copied + } + + fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option) -> Result { + let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type")); + let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")); + let mut out = Dict::new(); + for (k, v) in d.iter() { + if is_annot && SKIP_ON_ANNOT.contains(&k.as_slice()) { + continue; + } + // Destinations are rewritten afterwards (they may target pages copied later). + if is_annot && (k.as_slice() == b"Dest" || k.as_slice() == b"A") { + out.set(k.clone(), v.clone()); + continue; + } + out.set(k.clone(), self.copy_value(dst, v, None)?); + } + if let Some(this) = this { + if is_annot { + self.annots.push(this); + } + // A field (or widget) without a parent is a top-level field. + if is_field && !d.contains(b"Parent") { + self.fields.push(this); + } + } + Ok(out) + } + + /// Map a source destination (explicit array or reference to one) to the destination + /// document; `None` if it targets a page that was not copied. + pub(super) fn map_dest(&self, dest: &Object) -> Option { + self.map_dest_depth(dest, 0) + } + + fn map_dest_depth(&self, dest: &Object, depth: u8) -> Option { + if depth > 4 { + return None; + } + match dest { + Object::Name(n) => self.map_dest_depth(&named_dest(self.src, n)?, depth + 1), + Object::String(s) => self.map_dest_depth(&named_dest(self.src, &s.bytes)?, depth + 1), + Object::Ref(_) => self.map_dest_depth(&self.src.resolve(dest), depth + 1), + Object::Dict(d) => self.map_dest_depth(d.get(b"D")?, depth + 1), + Object::Array(a) => { + let page = a.first()?.as_ref()?; + let new_page = *self.pages.get(&page)?; + let mut a = a.clone(); + a[0] = Object::Ref(new_page); + Some(Object::Array(a)) + } + _ => None, + } + } + + /// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations. + pub(crate) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap) -> Result<(), OrganizeError> { + for &a in &std::mem::take(&mut self.annots) { + let src_dict = dst.get(a).as_dict().cloned(); + let Some(d) = src_dict else { continue }; + let mut d = d; + if let Some(p) = annot_pages.get(&a) { + d.set(b"P".to_vec(), Object::Ref(*p)); + } + if let Some(dest) = d.get(b"Dest").cloned() { + let resolved = self.src.resolve(&dest); + match self.map_dest(&resolved) { + Some(nd) => d.set(b"Dest".to_vec(), nd), + None => { + d.remove(b"Dest"); + } + } + } + if let Some(action) = d.get(b"A").cloned() { + let action = self.src.resolve(&action); + if let Some(ad) = action.as_dict() { + if ad.name(b"S") == Some(b"GoTo") { + let target = ad.get(b"D").map(|x| self.src.resolve(x)); + match target.and_then(|t| self.map_dest(&t)) { + Some(nd) => { + let mut na = Dict::new(); + na.set(b"S".to_vec(), Object::name("GoTo")); + na.set(b"D".to_vec(), nd); + d.set(b"A".to_vec(), Object::Dict(na)); + } + None => { + d.remove(b"A"); + } + } + } else { + // Other actions (URI, Launch, JavaScript…) carry no page references. The + // copier lends its map out and takes it back with the new copies in it, + // so actions sharing an object share its one copy. + let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new()); + let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None); + self.map = c.map; + d.set(b"A".to_vec(), copied?); + } + } + } + dst.set(a, Object::Dict(d)); + } + Ok(()) + } +} + +/// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so +/// an object the copied pages already brought along (an ICC profile) is not stored twice. +pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap) -> Result { + Copier::new(src, map, HashMap::new()).copy_value(dst, o, None) +} diff --git a/crates/organize/src/import/fields.rs b/crates/organize/src/import/fields.rs new file mode 100644 index 0000000000..44910d0406 --- /dev/null +++ b/crates/organize/src/import/fields.rs @@ -0,0 +1,297 @@ +use super::copy::WIDGET_KEYS; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, walk}; + +/// Rename the top-level fields about to be registered in `dst` whose fully-qualified names +/// (`parent.T.child`) are already taken, so each imported field stays its own field instead of +/// blurring into the destination's field of the same name (fields of one fully-qualified name +/// are one field). A colliding node's own `/T` becomes the first free `Name-1`, `Name-2`… +/// variant, so everything below it keeps its place in the tree. Returns what was renamed +/// (old fully-qualified name → new one), in order. +pub(super) fn resolve_field_conflicts(dst: &mut Document, fields: &[ObjRef]) -> Result, OrganizeError> { + let mut taken = taken_field_names(dst)?; + let planned = plan_renames(dst, fields, &mut taken); + let mut renamed = Vec::new(); + for (node, new_t, old, new) in planned { + dst.update_dict(node, |d| d.set(b"T".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&new_t))))?; + renamed.push((old, new)); + } + Ok(renamed) +} + +/// What importing `src` into `dst` would rename, computed without changing either document: +/// each fully-qualified name of `src`'s fields that `dst` already uses (or that an earlier +/// field of `src` itself uses) is renamed to the first free variant, exactly as the import +/// would. Unreadable structure previews as nothing: the import itself will say why it failed. +pub fn field_name_conflicts(dst: &Document, src: &Document) -> Vec<(String, String)> { + let Ok(mut taken) = taken_field_names(dst) else { return Vec::new() }; + let Ok(roots) = field_roots(src) else { return Vec::new() }; + plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect() +} + +/// What combining `sources`, in order, would rename in each one: the whole list is simulated +/// with the same first-free-variant rule the combine uses, so a later file's preview sees the +/// earlier files' renames. Unreadable structure previews as nothing (the combine itself will +/// say why it fails); non-colliding names still count as taken for the files after them. +pub fn combine_field_conflicts(sources: &[&Document]) -> Vec> { + let mut taken = std::collections::HashSet::new(); + sources + .iter() + .map(|src| match field_roots(src) { + Ok(roots) => plan_renames(src, &roots, &mut taken).into_iter().map(|(_, _, old, new)| (old, new)).collect(), + Err(_) => Vec::new(), + }) + .collect() +} + +/// Walk field trees `roots` in `doc` and plan the renames that would keep every name separate +/// from `taken`: each node's fully-qualified name joins `taken` (so a later field of the same +/// import collides with an earlier one), and a node already taken is renamed to the first free +/// variant of its own `/T`. Names are compared and renamed as decoded text, not as stored +/// bytes, so one name written in two encodings (PDFDocEncoding, UTF-16BE) is one name. +/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe. +fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet) -> Vec<(ObjRef, String, String, String)> { + let mut planned = Vec::new(); + let mut walk = RenameWalk::default(); + for &root in roots { + plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned); + } + planned +} + +/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name +/// the suffix to try next. +#[derive(Default)] +struct RenameWalk { + seen: HashSet, + next: HashMap, +} + +/// One node of [`plan_renames`]. +fn plan_renames_under( + doc: &Document, + node: ObjRef, + prefix: &str, + taken: &mut std::collections::HashSet, + walk: &mut RenameWalk, + depth: u8, + planned: &mut Vec<(ObjRef, String, String, String)>, +) { + if depth > 32 || !walk.seen.insert(node) { + return; + } + let Some(d) = doc.get(node).as_dict().cloned() else { return }; + let mut name = d.get(b"T").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + if !name.is_empty() { + let fqn = qualified(prefix, &name); + if taken.contains(&fqn) { + // `taken` only grows, so every suffix below the last one handed out for this name is + // still taken: resume there instead of at 1. + let mut n = walk.next.get(&fqn).copied().unwrap_or(1); + let mut variant; + while { + #[cfg(test)] + crate::work(1); + variant = format!("{name}-{n}"); + taken.contains(&qualified(prefix, &variant)) + } { + n = n.saturating_add(1); + } + walk.next.insert(fqn.clone(), n.saturating_add(1)); + let new_fqn = qualified(prefix, &variant); + planned.push((node, variant.clone(), fqn, new_fqn.clone())); + taken.insert(new_fqn); + name = variant; + } else { + taken.insert(fqn); + } + } + let prefix = qualified_optional(prefix, &name); + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + for kid in kids.iter().filter_map(|k| k.as_ref()) { + plan_renames_under(doc, kid, &prefix, taken, walk, depth + 1, planned); + } +} + +/// The field names in use in `dst`: every name in its form, and every name hanging only off a +/// page's widgets — a field the form never lists still owns its name. +fn taken_field_names(dst: &Document) -> Result, OrganizeError> { + let mut taken = std::collections::HashSet::new(); + let mut seen = std::collections::HashSet::new(); + for root in field_roots(dst)? { + collect_names(dst, root, "", &mut seen, 0, &mut taken); + } + Ok(taken) +} + +/// A document's field trees, wherever they are rooted: the `/AcroForm /Fields` entries, and +/// the fields behind widgets on pages (climbed to their root, for forms that never registered +/// them). +fn field_roots(doc: &Document) -> Result, OrganizeError> { + let mut roots: Vec = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"AcroForm").map(|f| doc.resolve(f))) + .and_then(|f| f.as_dict().cloned()) + .and_then(|f| f.get(b"Fields").map(|l| doc.resolve(l))) + .and_then(|l| l.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|f| f.as_ref()) + .collect(); + for (page, _) in walk(doc)? { + let Some(d) = doc.get(page).as_dict().cloned() else { continue }; + let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for a in annots.iter() { + let Some(w) = doc.resolve(a).as_dict().cloned() else { continue }; + if w.name(b"Subtype") != Some(b"Widget") { + continue; + } + // Walk up to the field the widget belongs to, however deep it sits; a widget that + // is its own field is its own root. + let Some(mut root) = a.as_ref() else { continue }; + let mut up = 0; + while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) { + if up >= 32 || root == parent { + break; + } + root = parent; + up += 1; + } + roots.push(root); + } + } + Ok(roots) +} + +/// Record the fully-qualified names of one field tree. Cycle- and depth-safe. +fn collect_names( + dst: &Document, + node: ObjRef, + prefix: &str, + seen: &mut std::collections::HashSet, + depth: u8, + out: &mut std::collections::HashSet, +) { + if depth > 32 || !seen.insert(node) { + return; + } + let Some(d) = dst.get(node).as_dict().cloned() else { return }; + let name = d.get(b"T").and_then(|t| dst.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + let prefix = qualified_optional(prefix, &name); + if !name.is_empty() { + out.insert(prefix.clone()); + } + let kids = d.get(b"Kids").map(|k| dst.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + for kid in kids.iter().filter_map(|k| k.as_ref()) { + collect_names(dst, kid, &prefix, seen, depth + 1, out); + } +} + +/// `prefix.name`, or `name` at the top level. +fn qualified(prefix: &str, name: &str) -> String { + if prefix.is_empty() { name.to_string() } else { format!("{prefix}.{name}") } +} + +/// The prefix a node's children hang under: its own name, when it has one. +fn qualified_optional(prefix: &str, name: &str) -> String { + if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) } +} + +/// Add `kid` to `field`'s `/Kids`, in the array object itself when `/Kids` is one, so the field +/// keeps its other widgets. +pub(super) fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), OrganizeError> { + match dst.get(field).as_dict().and_then(|f| f.get(b"Kids").cloned()) { + Some(Object::Ref(list)) => { + let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + dst.set(list, Object::Array(kids)); + } + _ => dst.update_dict(field, |f| { + let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + f.set(b"Kids".to_vec(), Object::Array(kids)); + })?, + } + Ok(()) +} + +/// PdfCraft patch (#1810): split a widget that doubles as its own field into a parent field +/// holding the field keys (name, value, flags…) and a widget child holding the annotation keys +/// (rectangle, appearance, page). The parent takes the widget's place among the top-level fields; +/// later copies of the page add widget children of it, so every page has its own widget and the +/// field stays one. +pub(super) fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { + let Some(d) = dst.get(widget).as_dict().cloned() else { return }; + let (mut field, mut child) = field_and_widget(dst, &d); + let parent = dst.add(Object::Null); + field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)])); + child.set(b"Parent".to_vec(), Object::Ref(parent)); + dst.set(parent, Object::Dict(field)); + dst.set(widget, Object::Dict(child)); + if let Some(entry) = fields.iter_mut().find(|f| **f == widget) { + *entry = parent; + } +} + +/// A combined field/widget has two additional-action namespaces (ISO 32000-1 tables 194/196). +/// Partition them as well as the ordinary entries, including when copying a later widget. +pub(super) fn field_and_widget(doc: &Document, d: &Dict) -> (Dict, Dict) { + let (mut field, mut child) = (Dict::new(), Dict::new()); + for (k, v) in d.iter() { + if k.as_slice() == b"AA" + && let Some(actions) = doc.resolve(v).as_dict().cloned() + { + let (mut field_actions, mut widget_actions) = (Dict::new(), Dict::new()); + for (trigger, action) in actions.iter() { + if [b"E".as_slice(), b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"].contains(&trigger.as_slice()) { + widget_actions.set(trigger.clone(), action.clone()); + } else { + field_actions.set(trigger.clone(), action.clone()); + } + } + if field_actions.iter().next().is_some() { + field.set(k.clone(), Object::Dict(field_actions)); + } + if widget_actions.iter().next().is_some() { + child.set(k.clone(), Object::Dict(widget_actions)); + } + } else if WIDGET_KEYS.contains(&k.as_slice()) { + child.set(k.clone(), v.clone()); + } else { + field.set(k.clone(), v.clone()); + } + } + (field, child) +} + +/// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). +pub(super) fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { + if fields.is_empty() { + return Ok(()); + } + let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?; + let (form_ref, mut form) = match catalog.get(b"AcroForm") { + Some(Object::Ref(r)) => (Some(*r), dst.get(*r).as_dict().cloned().unwrap_or_default()), + Some(Object::Dict(d)) => (None, d.clone()), + _ => (None, Dict::new()), + }; + let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); + let mut listed: HashSet = list.iter().filter_map(Object::as_ref).collect(); + for f in fields { + #[cfg(test)] + crate::work(1); + if listed.insert(*f) { + list.push(Object::Ref(*f)); + } + } + form.set(b"Fields".to_vec(), Object::Array(list)); + match form_ref { + Some(r) => dst.set(r, Object::Dict(form)), + None => { + let r = dst.add(form); + dst.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Ref(r)))?; + } + } + Ok(()) +} diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 99c228c60f..b890965d0c 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -13,7 +13,9 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, XrefEntry}; +mod attachments; mod boxes; +mod comments; mod dedupe; mod import; mod labels; @@ -24,7 +26,9 @@ mod transition; pub mod view; mod xmp; +pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name}; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; +pub use comments::import_comments; pub use dedupe::dedupe_resources; pub use import::{ CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with, @@ -33,8 +37,8 @@ pub use import::{ }; pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; pub use outline::{ - Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, move_bookmark, - rename_bookmark, set_bookmark_open, set_bookmark_page, top_level_bookmarks, + Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_at, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, + move_bookmark, rename_bookmark, set_bookmark_color, set_bookmark_open, set_bookmark_page, set_bookmark_style, top_level_bookmarks, }; pub use transition::{TRANSITION_DIRECTIONS, TRANSITION_STYLES, Transition, TransitionSpec, set_transition}; pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view}; @@ -72,36 +76,74 @@ fn pages_root(doc: &Document) -> Result { /// All leaf pages in order, with their effective inherited attributes. fn walk(doc: &Document) -> Result, OrganizeError> { - let root = pages_root(doc)?; - let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); - // (node, inherited attributes from ancestors) - let mut stack: Vec<(ObjRef, Dict)> = vec![(root, Dict::new())]; - while let Some((node, inherited)) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; // cycles in broken trees - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - let mut attrs = inherited.clone(); - for k in INHERITABLE { - if let Some(v) = d.get(k) { - attrs.set(k.to_vec(), v.clone()); + Ok(PageWalk::new(doc, pages_root(doc)?).collect()) +} + +/// For each of `INHERITABLE`, the nearest node that sets it, shared rather than copied. +type Owners = [Option>; 4]; + +/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, +/// never one entry per child, and carries inherited attributes as the nodes that set them, so a +/// child listed many times or shared between nodes is skipped by the seen check before anything +/// is queued or copied (`pdfcraft_model::pages` walks the same way). Cycles in broken trees end +/// at the seen check and at most a million nodes are visited. +struct PageWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + pending: Vec<(std::sync::Arc, usize, Owners)>, +} + +impl<'a> PageWalk<'a> { + fn new(doc: &'a Document, top: ObjRef) -> Self { + let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)])); + Self { doc, seen: Default::default(), pending: vec![(top, 0, Default::default())] } + } +} + +impl Iterator for PageWalk<'_> { + type Item = (ObjRef, Dict); + + fn next(&mut self) -> Option<(ObjRef, Dict)> { + while let Some((kids, next, inherited)) = self.pending.last_mut() { + let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else { + self.pending.pop(); + continue; + }; + let node = kid.as_ref(); + *next += 1; + let Some(node) = node else { continue }; + if self.seen.len() >= 1_000_000 { + self.pending.clear(); + return None; } - } - let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); - if is_pages { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for k in kids.iter().rev() { - if let Some(r) = k.as_ref() { - stack.push((r, attrs.clone())); + if !self.seen.insert(node) { + continue; + } + let obj = self.doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + let mut owners = inherited.clone(); + for (k, owner) in INHERITABLE.iter().zip(&mut owners) { + if d.contains(k) { + *owner = Some(obj.clone()); } } - } else { - out.push((node, attrs)); + let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); + if is_pages { + if let Some(kids) = d.get(b"Kids") { + self.pending.push((self.doc.resolve(kids), 0, owners)); + } + } else { + let mut attrs = Dict::new(); + for (k, owner) in INHERITABLE.iter().zip(&owners) { + if let Some(v) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|o| o.get(k)) { + attrs.set(k.to_vec(), v.clone()); + } + } + return Some((node, attrs)); + } } + None } - Ok(out) } /// Leaf pages in document order. @@ -216,6 +258,12 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu } /// Delete pages. Refuses to delete every page. +/// +/// # Errors +/// +/// This operation is not transactional: an error can leave `doc` partially modified. Callers +/// that need atomic edits (including replacement) must use a working clone and keep it only on +/// success; discard the working clone on error. The engine does this for its history boundary. pub fn delete_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { let all = walk(doc)?; check(indices, all.len())?; @@ -365,43 +413,42 @@ fn prune_legacy(doc: &Document, dests: &mut Dict, gone: &[ObjRef], dead: &mut Ve /// Prune a name tree held in `slot` (a reference, or a dictionary written inline). A reference is /// updated in place and yields `None`; an inline dictionary that changed is handed back. fn prune_tree_slot(doc: &mut Document, slot: &Object, gone: &[ObjRef], dead: &mut Vec>) -> Result, OrganizeError> { - let mut seen = Vec::new(); - match slot { + let mut stack = Vec::new(); + let inline = match slot { Object::Ref(r) => { - prune_tree_ref(doc, *r, gone, dead, &mut seen)?; - Ok(None) + stack.push(*r); + None } Object::Dict(d) => { let mut node = d.clone(); - Ok(prune_tree_node(doc, &mut node, gone, dead, &mut seen)?.then_some(node)) + prune_tree_node(doc, &mut node, gone, dead, &mut stack).then_some(node) + } + _ => None, + }; + // Name trees consist of shallow indirect nodes: graph depth must not become call-stack + // depth. Visit each indirect node once, even when cyclic or shared. Unlike the depth-limited + // outline::name_tree lookup, deletion must inspect every reachable node to remove references + // to freed pages. Both walks use seen sets to stop cycles. + let mut seen = std::collections::HashSet::new(); + while let Some(r) = stack.pop() { + if !seen.insert(r) { + continue; + } + let Some(mut node) = doc.get(r).as_dict().cloned() else { continue }; + if prune_tree_node(doc, &mut node, gone, dead, &mut stack) { + doc.update_dict(r, |d| *d = node)?; } - _ => Ok(None), } + Ok(inline) } -fn prune_tree_ref(doc: &mut Document, r: ObjRef, gone: &[ObjRef], dead: &mut Vec>, seen: &mut Vec) -> Result<(), OrganizeError> { - if seen.contains(&r) { - return Ok(()); - } - seen.push(r); - let Some(mut node) = doc.get(r).as_dict().cloned() else { return Ok(()) }; - if prune_tree_node(doc, &mut node, gone, dead, seen)? { - doc.update_dict(r, |d| *d = node)?; - } - Ok(()) -} - -/// Drop the /Names pairs of one name-tree node that go to a `gone` page, then do the same for its -/// /Kids. True when `node` itself changed (kids held by reference are written back here). -fn prune_tree_node( - doc: &mut Document, - node: &mut Dict, - gone: &[ObjRef], - dead: &mut Vec>, - seen: &mut Vec, -) -> Result { +/// Drop the /Names pairs going to a `gone` page, and queue /Kids for the iterative walk. +/// True when `node` itself changed; child nodes are written back by the caller. +fn prune_tree_node(doc: &Document, node: &mut Dict, gone: &[ObjRef], dead: &mut Vec>, stack: &mut Vec) -> bool { let mut changed = false; - if let Some(pairs) = node.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) { + if let Some(names) = node.get(b"Names").map(|n| doc.resolve(n)) + && let Some(pairs) = names.as_array() + { let mut kept = Vec::with_capacity(pairs.len()); for pair in pairs.chunks(2) { match pair { @@ -418,13 +465,16 @@ fn prune_tree_node( changed = true; } } - let kids = node.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for kid in kids { - if let Some(r) = kid.as_ref() { - prune_tree_ref(doc, r, gone, dead, seen)?; + if let Some(children) = node.get(b"Kids").map(|k| doc.resolve(k)) + && let Some(kids) = children.as_array() + { + for kid in kids.iter().rev() { + if let Some(r) = kid.as_ref() { + stack.push(r); + } } } - Ok(changed) + changed } /// Take widgets out of the form: out of their field's /Kids, or out of /AcroForm /Fields when @@ -612,10 +662,21 @@ pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> { Ok(()) } -/// Read a document-information entry as text. +/// Read a document-information entry as text: `/Info`, else the XMP packet (Title, Author, +/// Subject, Keywords, Creator and Producer, see `xmp`). pub fn info(doc: &Document, key: &str) -> Option { - let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?; - info.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())) + let in_info = doc + .trailer() + .get(b"Info") + .map(|o| doc.resolve(o)) + .and_then(|info| info.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text()))); + // Files that state it only in their XMP packet (#2373). + in_info.filter(|v| !v.trim_matches('\0').trim().is_empty()).or_else(|| xmp::info_value(doc, key)) +} + +/// Whether the document information has an entry `key`, of any type. +pub fn has_info(doc: &Document, key: &str) -> bool { + doc.trailer().get(b"Info").map(|o| doc.resolve(o)).is_some_and(|info| info.as_dict().is_some_and(|d| d.get(key.as_bytes()).is_some())) } /// Set (or clear, with an empty value) a document-information entry. @@ -645,12 +706,18 @@ fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option) -> Resu })?; } _ => { - let Some(v) = entry else { return Ok(()) }; let mut d = match doc.trailer().get(b"Info") { Some(Object::Dict(d)) => d.clone(), _ => Dict::new(), }; - d.set(key.to_vec(), v); + match entry { + Some(v) => d.set(key.to_vec(), v), + None => { + if d.remove(key).is_none() { + return Ok(()); + } + } + } let r = doc.add(d); doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r)); } @@ -658,5 +725,59 @@ fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option) -> Resu Ok(()) } +/// A standard a document can declare it meets, for [`remove_standard_id`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Standard { + PdfA, + PdfX, +} + +impl Standard { + pub fn label(self) -> &'static str { + match self { + Standard::PdfA => "PDF/A", + Standard::PdfX => "PDF/X", + } + } +} + +/// Remove what identifies the document as meeting `standard`: the `pdfaid:` XMP properties of +/// ISO 19005, or ISO 15930's `GTS_PDFXVersion` and `GTS_PDFXConformance` in the document +/// information and as `pdfxid:` XMP properties. Output intents and other metadata stay. Refused +/// when the document declares nothing of the kind. +pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(), OrganizeError> { + let (props, info_keys): (&[&str], &[&str]) = match standard { + Standard::PdfA => (&["pdfaid:part", "pdfaid:conformance", "pdfaid:amd", "pdfaid:rev"], &[]), + Standard::PdfX => (&["pdfxid:GTS_PDFXVersion", "pdfxid:GTS_PDFXConformance"], &["GTS_PDFXVersion", "GTS_PDFXConformance"]), + }; + let mut removed = xmp::remove_properties(doc, props)?; + for key in info_keys { + if info(doc, key).is_some() { + set_info_entry(doc, key.as_bytes(), None)?; + removed = true; + } + } + if removed { Ok(()) } else { Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))) } +} + +#[cfg(test)] +thread_local! { + /// Steps taken on this thread by loops whose cost follows the input's shape, so a test can + /// bound the work a hostile document causes without timing it. + static WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Count `n` steps of input-shaped work (see [`WORK`]). +#[cfg(test)] +fn work(n: usize) { + WORK.with(|w| w.set(w.get().saturating_add(n))); +} + +/// The steps counted on this thread since the last call. +#[cfg(test)] +fn take_work() -> usize { + WORK.with(|w| w.replace(0)) +} + #[cfg(test)] mod tests; diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index dca0b35cee..ff7d3d8da7 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -41,6 +41,13 @@ pub struct Bookmark { /// Where on that page it goes: the destination's type (`XYZ`, `Fit`, `FitH`…) and the numbers /// after it, in PDF user space (`None` for `null` or anything that isn't a number). pub view: Option<(Vec, Vec>)>, + /// The title's colour (`/C`, DeviceRGB, ISO 32000-2 §12.3.3 Table 151), each component kept + /// within 0–1; `None` for the default black or a value that isn't three numbers. The + /// inspector's `OutlineItem::color` reads it the same way. + pub color: Option<[f32; 3]>, + /// The title's style (`/F`, Table 152): bit 1 italic, bit 2 bold. + pub italic: bool, + pub bold: bool, } /// One page of the outline listing (see [`bookmark_page`]). @@ -59,6 +66,10 @@ pub enum OutlineError { IntoItself, #[error("the title must not be empty")] EmptyTitle, + #[error("a bookmark's position must be a finite number")] + BadPosition, + #[error("a bookmark's colour must be three numbers from 0 to 1")] + BadColor, #[error("this document has no tagged headings to make bookmarks from")] NoEntries, #[error("{0}")] @@ -254,6 +265,15 @@ fn explicit_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8 } } +/// The explicit destination array `dest` stands for: itself, a dictionary whose `/D` is one, or a +/// named destination (a name or string, ISO 32000-2 §12.3.2.4) looked up in the `/Names /Dests` +/// tree, then the catalog `/Dests`. `None` when a name names nothing. +pub(crate) fn resolve_dest(doc: &Document, dest: &Object) -> Option> { + // Names only: the page map is not needed to look one up. + let mut targets = Targets { pages: HashMap::new(), names: None }; + explicit_dest(doc, &mut targets, dest, 0) +} + /// The page index an explicit destination array names. fn page_of_explicit(targets: &Targets, dest: &[Object]) -> Option { match dest.first()? { @@ -304,7 +324,19 @@ fn describe(doc: &Document, targets: &mut Targets, obj: ObjRef, path: Vec }; let page = dest.as_deref().and_then(|d| page_of_explicit(targets, d)); let view = dest.as_deref().filter(|_| page.is_some()).and_then(|d| view_of_explicit(doc, d)); - Bookmark { obj, path, title, open, page, view } + let color = item.as_dict().and_then(|d| d.get(b"C").map(|c| doc.resolve(c))).and_then(|c| title_color(doc, &c)); + let flags = item.as_dict().and_then(|d| d.get(b"F").and_then(|f| doc.resolve(f).as_int())).unwrap_or(0); + Bookmark { obj, path, title, open, page, view, color, italic: flags & 1 != 0, bold: flags & 2 != 0 } +} + +/// A bookmark title's `/C` as a colour: three numbers, each kept within 0–1 (ISO 32000-2 +/// §12.3.3, Table 151: "An array of three numbers in the range 0.0 to 1.0"); a number past the +/// range, however large, is kept at its edge. Black, the default, and anything else give `None`. +fn title_color(doc: &Document, c: &Object) -> Option<[f32; 3]> { + let [r, g, b] = c.as_array()?.as_slice() else { return None }; + let channel = |v: &Object| doc.resolve(v).as_f64().map(|v| v.clamp(0.0, 1.0) as f32); + let rgb = [channel(r)?, channel(g)?, channel(b)?]; + (rgb != [0.0; 3]).then_some(rgb) } /// Lists up to `limit` bookmarks from position `offset`, entering no level deeper than `max_depth`. @@ -472,24 +504,51 @@ fn recount(doc: &mut Document) -> Result<()> { } fn destination(doc: &Document, page: usize) -> Result { - let pages = walk(doc)?; - let (p, _) = pages.get(page).ok_or(OrganizeError::NoSuchPage(page))?; - Ok(go_to(*p)) + destination_at(doc, page, None, None) } -/// `/XYZ null null null`: go to the page, keeping the reader's zoom (what Acrobat writes for a +/// A destination on `page` (0-based) at the user-space point (`left`, `top`), either of which +/// may be left out to keep the reader's position in that direction. +fn destination_at(doc: &Document, page: usize, left: Option, top: Option) -> Result { + if ![left, top].iter().flatten().all(|v| v.is_finite()) { + return Err(OutlineError::BadPosition); + } + let pages = walk(doc)?; + let (p, _) = pages.get(page).ok_or(OrganizeError::NoSuchPage(page))?; + Ok(go_to_at(*p, left, top)) +} + +/// `/XYZ left top null`: the point (`left`, `top`) at the window's upper-left corner, at the +/// reader's zoom; a coordinate that is left out keeps the reader's position. With neither, +/// `/XYZ null null null` goes to the page, keeping the reader's zoom (what Acrobat writes for a /// new bookmark when the view has no specific position). -fn go_to(page: ObjRef) -> Object { - Object::Array(vec![Object::Ref(page), Object::name("XYZ"), Object::Null, Object::Null, Object::Null]) +fn go_to_at(page: ObjRef, left: Option, top: Option) -> Object { + let coordinate = |v: Option| v.map_or(Object::Null, Object::Real); + Object::Array(vec![Object::Ref(page), Object::name("XYZ"), coordinate(left), coordinate(top), Object::Null]) } /// Add a bookmark titled `title` that goes to `page` (0-based), as child `index` of /// `parent_path` (`[]` = top level; an index past the end appends). Returns its path. pub fn add_bookmark(doc: &mut Document, parent_path: &[usize], index: usize, title: &str, page: usize) -> Result> { + add_bookmark_at(doc, parent_path, index, title, page, None, None) +} + +/// [`add_bookmark`] going to the user-space point (`left`, `top`) of `page`, as `/XYZ left top null` +/// (zoom kept). Either coordinate may be left out to keep the reader's position in that +/// direction; one that is not finite is refused. +pub fn add_bookmark_at( + doc: &mut Document, + parent_path: &[usize], + index: usize, + title: &str, + page: usize, + left: Option, + top: Option, +) -> Result> { if title.trim().is_empty() { return Err(OutlineError::EmptyTitle); } - let dest = destination(doc, page)?; + let dest = destination_at(doc, page, left, top)?; let parent = container(doc, parent_path)?; let mut kids = children_of(doc, parent, &mut HashSet::from([parent])); let mut d = Dict::new(); @@ -499,17 +558,14 @@ pub fn add_bookmark(doc: &mut Document, parent_path: &[usize], index: usize, tit let at = index.min(kids.len()); kids.insert(at, item); relink(doc, parent, &kids)?; - // Show the new bookmark: open its parent (the root is always open). - if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) { - put(doc, parent, b"Count", Some(Object::Int(1)))?; - } + show_children(doc, parent, parent_path)?; recount(doc)?; let mut path = parent_path.to_vec(); path.push(at); Ok(path) } -/// One bookmark of a generated tree (New Bookmarks from Structure). +/// One bookmark of a generated tree (New Bookmarks from Structure, or from text styles). #[derive(Clone, Debug, PartialEq)] pub struct OutlineEntry { /// 1 = directly under the new parent; deeper levels nest under the last shallower entry. @@ -518,6 +574,9 @@ pub struct OutlineEntry { pub page: usize, /// The structure element it stands for (`/SE`). pub element: Option, + /// Where on the page it goes: this height (user space) at the top of the window, at the + /// reader's zoom. `None` goes to the page, keeping the reader's position. + pub top: Option, } /// Add a new top-level bookmark `parent_title` (first in the list) holding `entries` nested by @@ -541,7 +600,7 @@ pub fn add_bookmark_tree(doc: &mut Document, parent_title: &str, entries: &[Outl for (e, page) in entries.iter().zip(targets) { let mut d = Dict::new(); d.set(b"Title".to_vec(), Object::String(PdfString::text(&e.title))); - d.set(b"Dest".to_vec(), go_to(page)); + d.set(b"Dest".to_vec(), go_to_at(page, None, e.top)); if let Some(se) = e.element { d.set(b"SE".to_vec(), Object::Ref(se)); } @@ -579,6 +638,33 @@ pub fn rename_bookmark(doc: &mut Document, path: &[usize], title: &str) -> Resul put(doc, r, b"Title", Some(Object::String(PdfString::text(title)))) } +/// Set the colour of a bookmark's title (`/C`, ISO 32000-2 §12.3.3, Table 151: "An array of three +/// numbers in the range 0.0 to 1.0, representing the components in the DeviceRGB colour space"). +/// Black, the default, and `None` remove `/C`; a component outside 0–1 is refused. +pub fn set_bookmark_color(doc: &mut Document, path: &[usize], color: Option<[f64; 3]>) -> Result<()> { + if color.is_some_and(|c| !c.iter().all(|v| (0.0..=1.0).contains(v))) { + return Err(OutlineError::BadColor); + } + let (r, _) = resolve_path(doc, path)?; + let value = color.filter(|c| *c != [0.0; 3]).map(|c| Object::Array(c.iter().map(|v| Object::Real(*v)).collect())); + put(doc, r, b"C", value) +} + +/// Make a bookmark's title bold or italic or not (`/F`, ISO 32000-2 §12.3.3, Table 152: bit 1 +/// italic, bit 2 bold); `None` keeps that one as it is, and so are the other bits. When no bit is +/// left, `/F` is removed, its default being 0. +pub fn set_bookmark_style(doc: &mut Document, path: &[usize], bold: Option, italic: Option) -> Result<()> { + let (r, _) = resolve_path(doc, path)?; + let old = doc.get(r).as_dict().and_then(|d| d.get(b"F").and_then(|f| doc.resolve(f).as_int())).unwrap_or(0); + let bit = |flags: i64, mask: i64, on: Option| match on { + Some(true) => flags | mask, + Some(false) => flags & !mask, + None => flags, + }; + let flags = bit(bit(old, 1, italic), 2, bold); + put(doc, r, b"F", (flags != 0).then_some(Object::Int(flags))) +} + /// Point a bookmark at `page` (0-based), replacing its destination or GoTo action. pub fn set_bookmark_page(doc: &mut Document, path: &[usize], page: usize) -> Result<()> { let dest = destination(doc, page)?; @@ -618,11 +704,23 @@ pub fn move_bookmark(doc: &mut Document, from: &[usize], to_parent: &[usize], in let at = index.min(kids.len()); kids.insert(at, r); relink(doc, parent, &kids)?; + if parent != old_parent { + show_children(doc, parent, &to_parent)?; + } recount(doc)?; to_parent.push(at); Ok(to_parent) } +/// Open `parent` so a bookmark just added or moved into it shows (the root is always open). +/// Only the sign of `/Count` matters here: [`recount`] sets its value. +fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> { + if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) { + put(doc, parent, b"Count", Some(Object::Int(1)))?; + } + Ok(()) +} + /// Expand or collapse a bookmark that has children. pub fn set_bookmark_open(doc: &mut Document, path: &[usize], open: bool) -> Result<()> { let (r, _) = resolve_path(doc, path)?; diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index b5f5e65ffe..5a2a319fb4 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ObjRef, Object}; use crate::OrganizeError; @@ -29,7 +29,7 @@ enum XKey { } /// Who owns the resources currently being walked, for the key drawn names are recorded under. -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] enum Owner { /// An indirect resources object (a page's, or a form XObject's own). Resources(ObjRef), @@ -50,6 +50,9 @@ struct Pruner<'a> { glyph_seen: HashSet, /// An indirect font's explicit resources have one owner regardless of which page uses it. glyph_fonts: HashSet, + /// Forms already analysed, with the owner of the resources they drew against: drawing one + /// again records nothing new, and re-reading it on every `Do` is exponential in the nesting. + forms_seen: HashSet<(ObjRef, Owner)>, } impl Pruner<'_> { @@ -211,6 +214,11 @@ impl Pruner<'_> { // A form without its own resources draws against the ones it was invoked with. None => (resources.clone(), owner), }; + if !self.forms_seen.insert((r, inner_owner)) { + continue; + } + #[cfg(test)] + crate::work(1); match s.decoded() { Ok(data) => self.content(&data, &inner, inner_owner, depth + 1), // What the form draws is unknown: keep everything it could reach. @@ -223,23 +231,17 @@ impl Pruner<'_> { } } -/// The page's content, concatenated (`/Contents` may be one stream or an array). `None` when a -/// stream can't be decoded: the page may draw names that can't be seen. -fn content_bytes(doc: &Document, contents: &Object) -> Option> { - match &*doc.resolve(contents) { - Object::Stream(s) => s.decoded().ok(), - Object::Array(a) => { - let mut out = Vec::new(); - for c in a { - if let Object::Stream(s) = &*doc.resolve(c) { - out.extend(s.decoded().ok()?); - out.push(b'\n'); - } - } - Some(out) - } - _ => Some(Vec::new()), +/// The page's content, concatenated (`/Contents` may be one stream or an array) with a space +/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`]. An +/// error when a stream can't be decoded or the budget runs out: the page may draw names that +/// can't be seen. +pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result, CosError> { + let mut out = Vec::new(); + for data in DecodeBudget::page().decode_contents(doc, contents) { + out.extend(data?); + out.push(b' '); } + Ok(out) } /// The resources of a page (as `walk` reports them) with their owner. @@ -314,6 +316,7 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result Result {} - Some(data) => pruner.content(&data, &res_dict, owner, 0), - None => { + Ok(data) if data.is_empty() => {} + Ok(data) => pruner.content(&data, &res_dict, owner, 0), + Err(_) => { pruner.protect_type3_resources(&res_dict, owner); pruner.distrust(&res_dict, owner); } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 9bd3eb396a..33dc262fce 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -202,6 +202,119 @@ fn delete_keeps_inherited_attributes_on_survivors() { assert_eq!(root.as_dict().unwrap().int(b"Count"), Some(2)); } +#[test] +fn deleting_a_page_prunes_deep_named_destination_trees() { + let mut doc = doc_a(); + let original_pages = pages(&doc).unwrap(); + let mut leaf = Dict::new(); + leaf.set( + b"Names".to_vec(), + Object::Array(vec![ + Object::String(PdfString::text("gone")), + Object::Array(vec![Object::Ref(original_pages[0].obj), Object::name("Fit")]), + Object::String(PdfString::text("kept")), + Object::Array(vec![Object::Ref(original_pages[2].obj), Object::name("Fit")]), + ]), + ); + let leaf = doc.add(leaf); + let mut tree = leaf; + for _ in 0..16_384 { + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)])); + tree = doc.add(node); + } + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + + // Every node is a shallow indirect object: the page operation must not turn the + // document's graph depth into call-stack depth. + delete_pages(&mut doc, &[0]).unwrap(); + for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(bytes); + assert_eq!(labels(&reopened), ["A2", "A3"]); + let catalog = reopened.get(reopened.root().unwrap()); + let names = reopened.resolve(catalog.as_dict().unwrap().get(b"Names").unwrap()); + let mut node = reopened.resolve(names.as_dict().unwrap().get(b"Dests").unwrap()); + for _ in 0..16_384 { + let child = node.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0].clone(); + node = reopened.resolve(&child); + } + let pairs = node.as_dict().unwrap().get(b"Names").and_then(Object::as_array).cloned().unwrap(); + assert_eq!(pairs.len(), 2); + assert_eq!(pairs[0].as_string().unwrap().to_text(), "kept"); + assert_eq!(pairs[1].as_array().unwrap()[0].as_ref(), Some(pages(&reopened).unwrap()[1].obj)); + } +} + +#[test] +fn destination_pruning_visits_shared_and_cyclic_nodes_once() { + for inline_names in [false, true] { + for inline_tree in [false, true] { + let mut doc = doc_a(); + let gone = pages(&doc).unwrap()[0].obj; + let mut leaf = Dict::new(); + leaf.set( + b"Names".to_vec(), + Object::Array(vec![Object::String(PdfString::text("gone")), Object::Array(vec![Object::Ref(gone), Object::name("Fit")])]), + ); + leaf.set(b"Unknown".to_vec(), Object::Int(42)); + let leaf = doc.add(leaf); + let cycle = doc.add(Object::Null); + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(cycle), Object::Ref(leaf), Object::Ref(leaf)])); + doc.set(cycle, Object::Dict(node.clone())); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), if inline_tree { Object::Dict(node) } else { Object::Ref(cycle) }); + let names = if inline_names { Object::Dict(names) } else { Object::Ref(doc.add(names)) }; + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), names)).unwrap(); + + assert_eq!(drop_named_destinations(&mut doc, &[gone]).unwrap(), [b"gone".to_vec()]); + let leaf = doc.get(leaf); + assert_eq!(leaf.as_dict().unwrap().get(b"Names").and_then(Object::as_array).unwrap().len(), 0); + assert_eq!(leaf.as_dict().unwrap().int(b"Unknown"), Some(42)); + } + } +} + +#[test] +fn deleting_a_page_keeps_sixty_thousand_named_destinations_usable() { + let mut doc = doc_a(); + let original_pages = pages(&doc).unwrap(); + let pairs: Vec = (0..60_000) + .flat_map(|i| { + let page = if i % 2 == 0 { original_pages[0].obj } else { original_pages[2].obj }; + [Object::String(PdfString::text(&format!("dest-{i:05}"))), Object::Array(vec![Object::Ref(page), Object::name("Fit")])] + }) + .collect(); + let mut leaf = Dict::new(); + leaf.set(b"Names".to_vec(), Object::Array(pairs)); + leaf.set(b"Unknown".to_vec(), Object::Int(42)); + let leaf = doc.add(leaf); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(leaf)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + + // A valid large book exceeds the former 100,000-entry budget (two entries per name). + delete_pages(&mut doc, &[0]).unwrap(); + for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(bytes); + assert_eq!(labels(&reopened), ["A2", "A3"]); + let catalog = reopened.get(reopened.root().unwrap()); + let names = reopened.resolve(catalog.as_dict().unwrap().get(b"Names").unwrap()); + let leaf = reopened.resolve(names.as_dict().unwrap().get(b"Dests").unwrap()); + let leaf = leaf.as_dict().unwrap(); + assert_eq!(leaf.int(b"Unknown"), Some(42)); + let pairs = leaf.get(b"Names").unwrap().as_array().unwrap(); + assert_eq!(pairs.len(), 60_000, "30,000 surviving name/value pairs"); + let page = pages(&reopened).unwrap()[1].obj; + for (pair, i) in pairs.as_chunks::<2>().0.iter().zip((1..60_000).step_by(2)) { + assert_eq!(pair[0].as_string().unwrap().to_text(), format!("dest-{i:05}")); + assert_eq!(pair[1].as_array().unwrap()[0].as_ref(), Some(page)); + } + } +} + #[test] fn deleted_pages_are_not_kept_by_what_points_at_them() { // A1 links to A3, a bookmark goes to A3, and A2 holds the form's only field. After deleting @@ -415,6 +528,29 @@ fn custom_document_properties_are_listed_set_removed_and_their_names_checked() { assert!(custom_info(&doc).is_empty()); } +/// An `/Info` dictionary written directly in the trailer: a cleared entry is removed, and the +/// other entries are kept. +#[test] +fn document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer() { + let b = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(), + body("X"), + ], + "/Root 1 0 R /Info << /Title (Report) /Project (Apollo) /Revision (7) >>", + ); + let mut doc = open(b); + set_info(&mut doc, "Missing", "").unwrap(); + assert!(matches!(doc.trailer().get(b"Info"), Some(Object::Dict(_))), "clearing an absent entry changes nothing"); + set_info(&mut doc, "Project", "").unwrap(); + set_info(&mut doc, "Title", "").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())]); + assert_eq!(info(&doc, "Title"), None); +} + /// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only /// padding is not listed, and such entries don't count towards the 256 listed: a real property /// after 300 blank ones is still there. @@ -515,6 +651,43 @@ fn document_info_edits_update_the_xmp_packet() { assert_eq!((dict.name(b"Type"), dict.name(b"Subtype"), dict.get(b"Filter")), (Some(&b"Metadata"[..]), Some(&b"XML"[..]), None)); } +/// A file that states its title, author and so on only in XMP (PDF 2.0 deprecates `/Info`) shows +/// them in Document Properties (#2373); `/Info` wins where it has a value. +#[test] +fn document_info_falls_back_to_the_xmp_packet() { + let xmp = LIBREOFFICE_XMP + .replace( + "Untitled 1", + "BerichtReport & “notes”", + ) + .replace("Old Author", "Ada\nGrace") + .replace("Writer 1.0", "Writer 1.0"); + let mut doc = doc_with_xmp(xmp.as_bytes()); + // Both: /Info's own values, XMP for what /Info lacks. + assert_eq!(info(&doc, "Title").as_deref(), Some("Untitled 1")); + assert_eq!(info(&doc, "Subject").as_deref(), Some("Old subject")); + // XMP only. + doc.trailer_mut().remove(b"Info"); + let doc = full_roundtrip(&doc); + assert_eq!(info(&doc, "Title").as_deref(), Some("Report & \u{201C}notes\u{201D}")); + assert_eq!(info(&doc, "Author").as_deref(), Some("Ada; Grace")); + assert_eq!(info(&doc, "Subject").as_deref(), Some("Old subject")); + assert_eq!(info(&doc, "Producer").as_deref(), Some("Writer 1.0")); + assert_eq!(info(&doc, "Keywords"), None, "an empty property is no value"); + assert_eq!(info(&doc, "Rating"), None, "no XMP counterpart"); + // The attribute form, and a packet that isn't UTF-8 is not read. + let attrs = "\ + "; + let mut doc = doc_with_xmp(attrs.as_bytes()); + doc.trailer_mut().remove(b"Info"); + assert_eq!(info(&doc, "Keywords").as_deref(), Some("a, b & c")); + let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec(); + latin1.extend_from_slice(b""); + let mut doc = doc_with_xmp(&latin1); + doc.trailer_mut().remove(b"Info"); + assert_eq!(info(&doc, "Subject"), None); +} + #[test] fn document_info_edits_leave_files_without_usable_xmp_alone() { // No packet: none is added. @@ -774,6 +947,33 @@ fn resources_split_between_page_and_tree_travel_together() { } } +#[test] +fn a_scan_whose_xref_misses_the_image_combines_with_the_image() { + // A cheap image-to-PDF converter's table: "0 5" followed by the entries of objects 1 to 5, + // so the image (object 5) has none. Viewers rebuild the table and show the scan; the + // combined copy got a null image and came out blank (#1628). + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>".into(), + "<< /Length 26 >>\nstream\n100 0 0 100 0 0 cm /Im0 Do\nendstream".into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream".into(), + ]; + let good = build(&b, "/Root 1 0 R"); + let text = String::from_utf8_lossy(&good).into_owned(); + let at = text.rfind("\nxref\n").unwrap() + 1; + let entries: Vec<&str> = text[at..].lines().skip(3).take(5).collect(); + let mut bytes = good[..at].to_vec(); + bytes.extend_from_slice(format!("xref\n0 5\n{}\ntrailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{at}\n%%EOF\n", entries.join("\n")).as_bytes()); + let src = open(bytes); + let out = full_roundtrip(&combine(&[("scan", &src)]).unwrap()); + let res = out.dict(page_dict(&out, 0).get(b"Resources").unwrap()).unwrap(); + let image = res.get(b"XObject").and_then(|x| out.dict(x)).and_then(|d| d.get(b"Im0").map(|v| out.resolve(v))).expect("the page lists its image"); + let Object::Stream(image) = &*image else { panic!("the image is a stream, not {image:?}") }; + assert_eq!(image.dict.name(b"Subtype"), Some(&b"Image"[..])); + assert_eq!(image.decoded().unwrap(), b"A"); +} + #[test] fn links_are_rewired_to_copied_pages_or_dropped() { let a = doc_a(); @@ -791,9 +991,111 @@ fn links_are_rewired_to_copied_pages_or_dropped() { assert!(!link.contains(b"Dest") && !link.contains(b"A")); } -/// A widget annotation that is its own form field (no `/Parent`). -fn own_field(doc: &Document, r: ObjRef) -> bool { - doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent")) +/// Issue #1810: a widget that is its own field was shared between both copies of a repeated +/// page, so only the last page could identify it (its `/P`), and per-page annotation edits were +/// impossible. Each copy must get its own widget sharing one parent field (name and value). +#[test] +fn repeated_page_splits_a_combined_field_widget() { + let out = full_roundtrip(&extract_pages(&doc_a(), &[1, 1]).unwrap()); + let ps = pages(&out).unwrap(); + let widget = |i: usize| -> ObjRef { + let list = page_dict(&out, i).get(b"Annots").map(|a| out.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default(); + list.iter() + .filter_map(|a| a.as_ref()) + .find(|r| out.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) + .expect("a widget annotation") + }; + let (w1, w2) = (widget(0), widget(1)); + assert_ne!(w1, w2, "each copy of the page has its own widget"); + for (i, w) in [(0, w1), (1, w2)] { + let d = out.get(w).as_dict().unwrap().clone(); + for key in [b"T".as_slice(), b"V", b"FT"] { + assert!(!d.contains(key), "a widget inherits field entries: {key:?}"); + } + assert_eq!(d.reference(b"P"), Some(ps[i].obj), "/P names the page holding the widget"); + let parent = d.reference(b"Parent").expect("the widget has a parent field"); + let f = out.get(parent).as_dict().unwrap().clone(); + assert_eq!(f.get(b"T").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("name")); + assert_eq!(f.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada")); + let kids: Vec = + f.get(b"Kids").and_then(|k| out.resolve(k).as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); + assert!(kids.contains(&w1) && kids.contains(&w2), "one field holds both widgets: {kids:?}"); + let fields: Vec = catalog(&out) + .get(b"AcroForm") + .map(|f| out.resolve(f)) + .and_then(|f| f.as_dict().cloned()) + .and_then(|f| f.get(b"Fields").map(|x| out.resolve(x))) + .and_then(|f| f.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|f| f.as_ref()) + .collect(); + assert_eq!(fields, [parent], "the parent is the one top-level field"); + } +} + +#[test] +fn splitting_a_combined_widget_keeps_annotation_actions_and_field_triggers() { + let mut src = doc_a(); + let page = pages(&src).unwrap()[1].obj; + let list = page_dict(&src, 1).get(b"Annots").and_then(|a| a.as_array()).cloned().unwrap(); + let widget = + list.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap(); + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("GoTo")); + action.set(b"D".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")])); + let mut named = Dict::new(); + named.set(b"S".to_vec(), Object::name("Named")); + named.set(b"N".to_vec(), Object::name("NextPage")); + let mut aa = Dict::new(); + aa.set(b"E".to_vec(), Object::Dict(named.clone())); + aa.set(b"K".to_vec(), Object::Dict(named)); + let aa = src.add(Object::Dict(aa)); + src.update_dict(widget, |d| { + d.set(b"A".to_vec(), Object::Dict(action)); + d.set(b"AA".to_vec(), Object::Ref(aa)); + }) + .unwrap(); + let out = full_roundtrip(&extract_pages(&src, &[1, 1]).unwrap()); + let ps = pages(&out).unwrap(); + for i in 0..2 { + let w = annots(&out, i).into_iter().find(|d| d.name(b"Subtype") == Some(b"Widget")).unwrap(); + let action = out.resolve(w.get(b"A").expect("the widget keeps its action")); + let dest = action.as_dict().unwrap().get(b"D").and_then(|a| a.as_array()).unwrap()[0].as_ref().unwrap(); + assert!(ps.iter().any(|p| p.obj == dest), "the action targets a copied page"); + let waa = out.resolve(w.get(b"AA").expect("widget triggers")); + assert!(waa.as_dict().unwrap().contains(b"E")); + assert!(!waa.as_dict().unwrap().contains(b"K")); + let parent = out.get(w.reference(b"Parent").unwrap()).as_dict().unwrap().clone(); + assert!(!parent.contains(b"A")); + let faa = out.resolve(parent.get(b"AA").expect("field triggers")); + assert!(faa.as_dict().unwrap().contains(b"K")); + assert!(!faa.as_dict().unwrap().contains(b"E")); + } +} + +#[test] +fn splitting_a_combined_widget_keeps_its_annotation_only_keys() { + let mut src = doc_a(); + let list = page_dict(&src, 1).get(b"Annots").and_then(|a| a.as_array()).cloned().unwrap(); + let widget = + list.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap(); + src.update_dict(widget, |d| { + d.set(b"Contents".to_vec(), Object::String(PdfString::text("alt text"))); + d.set(b"BM".to_vec(), Object::name("Multiply")); + d.set(b"Lang".to_vec(), Object::String(PdfString::text("en-US"))); + d.set(b"AF".to_vec(), Object::Array(Vec::new())); + }) + .unwrap(); + let out = full_roundtrip(&extract_pages(&src, &[1, 1]).unwrap()); + for i in 0..2 { + let w = annots(&out, i).into_iter().find(|d| d.name(b"Subtype") == Some(b"Widget")).unwrap(); + let parent = out.get(w.reference(b"Parent").unwrap()).as_dict().unwrap().clone(); + for key in [b"Contents".as_slice(), b"BM", b"Lang", b"AF"] { + assert!(w.contains(key), "the widget keeps {key:?}"); + assert!(!parent.contains(key), "the parent field does not take {key:?}"); + } + } } #[test] @@ -809,15 +1111,11 @@ fn repeated_page_gets_independent_annotations() { assert!(!refs[i].is_empty()); assert_eq!(refs[i].len(), refs[i + 1].len()); for (a, b) in refs[i].iter().zip(&refs[i + 1]) { - // A widget that is its own field stays one object on both pages, so the field stays - // one (#790); every other annotation is copied. - if !own_field(&out, *a) { - assert_ne!(a, b, "each copy of the page has its own annotation objects"); - } + assert_ne!(a, b, "each copy of the page has its own annotation objects"); } } for (i, list) in refs.iter().enumerate() { - for a in list.iter().filter(|a| !own_field(&out, **a)) { + for a in list.iter() { assert_eq!(out.get(*a).as_dict().unwrap().reference(b"P"), Some(ps[i].obj), "/P names the page holding the copy"); } } @@ -838,7 +1136,32 @@ fn form_fields_come_along_and_are_registered() { assert_eq!(fields.len(), 1); let field = out.resolve(&fields[0]).as_dict().cloned().unwrap(); assert_eq!(field.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada")); - assert_eq!(field.reference(b"P"), Some(pages(&out).unwrap()[0].obj)); + // The widget child, not the field, names the page (#1810). + let kid = + field.get(b"Kids").map(|k| out.resolve(k)).and_then(|k| k.as_array().cloned()).and_then(|k| k.first().and_then(|k| k.as_ref())).unwrap(); + assert_eq!(out.get(kid).as_dict().unwrap().reference(b"P"), Some(pages(&out).unwrap()[0].obj)); +} + +/// #4020: Extract keeps the bookmarks that go to the pages it copies, mapped to the copies. One whose +/// page was left out goes, unless a bookmark under it stays. +#[test] +fn extract_keeps_the_bookmarks_of_the_pages_it_copies() { + let mut src = doc_a(); + crate::add_bookmark(&mut src, &[], 0, "One", 0).unwrap(); + crate::add_bookmark(&mut src, &[], 1, "Two", 1).unwrap(); + crate::add_bookmark(&mut src, &[1], 0, "Two.a", 2).unwrap(); + crate::add_bookmark(&mut src, &[], 2, "Three", 2).unwrap(); + let marks = |d: &Document| -> Vec<(String, usize, Option)> { bookmarks(d).into_iter().map(|b| (b.title, b.path.len(), b.page)).collect() }; + let s = |t: &str| t.to_string(); + + let all = full_roundtrip(&extract_pages(&src, &[0, 1, 2]).unwrap()); + assert_eq!(marks(&all), [(s("One"), 1, Some(0)), (s("Two"), 1, Some(1)), (s("Two.a"), 2, Some(2)), (s("Three"), 1, Some(2))]); + + let last = full_roundtrip(&extract_pages(&src, &[2]).unwrap()); + assert_eq!(marks(&last), [(s("Two"), 1, None), (s("Two.a"), 2, Some(0)), (s("Three"), 1, Some(0))], "One's page was left out"); + + let none = full_roundtrip(&extract_pages(&doc_a(), &[0]).unwrap()); + assert!(!none.get(none.root().unwrap()).as_dict().unwrap().contains(b"Outlines"), "no bookmarks, no outline"); } #[test] @@ -1047,6 +1370,37 @@ fn malformed_output_intents_and_identification_do_not_fail() { assert!(!xmp.contains('\0') && !xmp.contains("GTS_PDFXConformance"), "{xmp}"); } +#[test] +fn removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims() { + let mut x4 = doc_x4("FOGRA51", "icc"); + assert!(matches!(remove_standard_id(&mut x4, Standard::PdfA), Err(OrganizeError::Invalid(_)))); + assert!(!x4.is_modified(), "a refused removal changes nothing"); + remove_standard_id(&mut x4, Standard::PdfX).unwrap(); + let x = pdfx_of(&full_roundtrip(&x4)); + assert_eq!((x.version, x.intents), (None, vec![("FOGRA51".to_string(), b"icc".to_vec())])); + assert!(x.xmp.is_some_and(|x| !x.contains("pdfxid:GTS") && x.contains("pdfuaid:part=\"1\"")), "PDF/UA's claim stays"); + assert!(matches!(remove_standard_id(&mut x4, Standard::PdfX), Err(OrganizeError::Invalid(_))), "nothing left to remove"); + + let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001)", None); + remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); + assert_eq!((info(&x1a, "GTS_PDFXVersion"), info(&x1a, "GTS_PDFXConformance")), (None, None)); +} + +#[test] +fn removing_the_pdfa_identification_removes_its_xmp_properties_in_either_form() { + let xmp = "\ + 2Report\ + "; + let mut doc = doc_print("sRGB IEC61966-2.1", "icc", "/Title (Report)", Some(xmp)); + remove_standard_id(&mut doc, Standard::PdfA).unwrap(); + let x = pdfx_of(&full_roundtrip(&doc)); + let packet = x.xmp.unwrap(); + assert!(!packet.contains("pdfaid:part") && !packet.contains("pdfaid:conformance"), "{packet}"); + assert!(packet.contains(">Report"), "the rest of the packet stays: {packet}"); + assert_eq!(x.intents.len(), 1, "the output intent stays"); +} + #[test] fn duplicate_pages_become_independent_copies() { let out = full_roundtrip(&extract_pages(&doc_b(), &[0, 0]).unwrap()); @@ -1394,6 +1748,18 @@ fn bookmarks_add_rename_move_delete() { assert_eq!(crate::move_bookmark(&mut d, &[0], &[2], 9).unwrap(), vec![1, 1]); assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Table,Figure 1]"]); assert_eq!(crate::move_bookmark(&mut d, &[1], &[1, 0], 0), Err(crate::OutlineError::IntoItself)); + // Into a bookmark with no children yet (Indent): it opens, so the moved one shows. + assert_eq!(crate::move_bookmark(&mut d, &[1, 1], &[0], 0).unwrap(), vec![0, 0]); + let intro = crate::bookmarks(&d)[0].obj; + assert_eq!(count_of(&d, intro), Some(1)); + // Reordering inside a closed parent leaves it closed. + crate::add_bookmark(&mut d, &[0], 1, "Second", 0).unwrap(); + crate::set_bookmark_open(&mut d, &[0], false).unwrap(); + crate::move_bookmark(&mut d, &[0, 1], &[0], 0).unwrap(); + assert_eq!(count_of(&d, intro), Some(-2)); + crate::delete_bookmark(&mut d, &[0, 0]).unwrap(); + crate::move_bookmark(&mut d, &[0, 0], &[1], 9).unwrap(); + assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Table,Figure 1]"]); crate::delete_bookmark(&mut d, &[1, 0]).unwrap(); assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Figure 1]"]); @@ -1423,7 +1789,7 @@ fn bookmarks_add_rename_move_delete() { fn a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark() { let mut d = doc_a(); crate::add_bookmark(&mut d, &[], 0, "Existing", 0).unwrap(); - let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element }; + let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element, top: None }; let se = ObjRef::new(4242, 0); let entries = [e(1, "One", 0, Some(se)), e(3, "Deep", 1, None), e(2, "Two", 1, None), e(1, "Next", 2, None)]; assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &entries).unwrap(), vec![0]); @@ -1521,7 +1887,7 @@ fn outline_with(items: &[&str]) -> Document { format!("<< /Type /Outlines /First 5 0 R /Count {} >>", items.len()), "<< /Type /Page /Parent 2 0 R >>".into(), ]; - b.extend(items.iter().map(|s| s.to_string())); + b.extend(items.iter().map(ToString::to_string)); open(build(&b, "/Root 1 0 R")) } @@ -2061,6 +2427,125 @@ fn initial_view_round_trips_and_keeps_scripts() { assert!(crate::set_initial_view(&mut doc, &crate::InitialView { page: 9, ..Default::default() }).is_err()); } +#[test] +fn an_unchanged_opening_destination_keeps_its_position_and_name() { + use crate::view::{Magnification, Navigation}; + let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + let xyz = Object::Array(vec![page2.clone(), Object::name("XYZ"), Object::Int(100), Object::Int(500), Object::Int(2)]); + let mut named = pdfcraft_cos::Dict::new(); + named.set(b"S".to_vec(), Object::name("GoTo")); + named.set(b"D".to_vec(), Object::name("there")); + let mut dests = pdfcraft_cos::Dict::new(); + dests.set(b"there".to_vec(), Object::Array(vec![page2, Object::name("Fit")])); + let mut prefs = pdfcraft_cos::Dict::new(); + prefs.set(b"NonFullScreenPageMode".to_vec(), Object::name("UseOutlines")); + for action in [xyz, Object::Dict(named)] { + let mut doc = open(fixture()); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"OpenAction".to_vec(), action.clone()); + c.set(b"Dests".to_vec(), Object::Dict(dests.clone())); + c.set(b"PageMode".to_vec(), Object::name("FullScreen")); + c.set(b"ViewerPreferences".to_vec(), Object::Dict(prefs.clone())); + }) + .unwrap(); + // Another option changes: the opening action is written back as it was, and so is the + // full-screen panel, which the dialog doesn't edit. + let v = crate::initial_view(&doc); + assert_eq!(v.non_full_screen, Navigation::Bookmarks); + crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert_eq!(catalog.get(b"OpenAction"), Some(&action)); + let doc = save_and_reopen(&doc); + assert_eq!(crate::initial_view(&doc), crate::InitialView { hide_toolbar: true, ..v.clone() }); + // A changed magnification writes a destination of its own. + let mut doc = doc; + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitWidth, ..v }).unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert_ne!(catalog.get(b"OpenAction"), Some(&action)); + assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitWidth); + } +} + +/// A document whose `/OpenAction` is `action`, with the named destination `there` (page 3 at +/// 200%) in the catalog `/Dests` and `here` (page 1, fit) in a `/Names /Dests` tree. +fn doc_opening_at(action: Object) -> Document { + let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)); + let page3 = Object::Ref(pdfcraft_cos::ObjRef::new(6, 0)); + let mut dests = pdfcraft_cos::Dict::new(); + dests.set(b"there".to_vec(), Object::Array(vec![page3, Object::name("XYZ"), Object::Null, Object::Null, Object::Int(2)])); + let mut tree = pdfcraft_cos::Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![Object::String(PdfString::text("here")), Object::Array(vec![page1, Object::name("Fit")])])); + let mut names = pdfcraft_cos::Dict::new(); + names.set(b"Dests".to_vec(), Object::Dict(tree)); + let mut doc = open(fixture()); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"OpenAction".to_vec(), action); + c.set(b"Dests".to_vec(), Object::Dict(dests)); + c.set(b"Names".to_vec(), Object::Dict(names)); + }) + .unwrap(); + doc +} + +fn open_action(doc: &Document) -> Option { + doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"OpenAction").cloned()) +} + +/// ISO 32000-2 §12.3.2.4: a named destination stands for the explicit one it names. Choosing +/// page 1 in Document Properties on a document that opens at a named destination elsewhere +/// writes a destination to page 1; it used to read the name as page 1 and change nothing. +#[test] +fn choosing_page_one_replaces_a_named_opening_destination() { + use crate::view::Magnification; + let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)); + let mut goto = pdfcraft_cos::Dict::new(); + goto.set(b"S".to_vec(), Object::name("GoTo")); + goto.set(b"D".to_vec(), Object::name("there")); + for action in [Object::Dict(goto), Object::name("there"), Object::String(PdfString::text("there"))] { + let mut doc = doc_opening_at(action.clone()); + // The dialog shows where the document really opens. + let v = crate::initial_view(&doc); + assert_eq!((v.page, v.magnification), (2, Magnification::Percent(200.0)), "{action:?}"); + // Page 1 at the same magnification: an explicit destination to page 1. + crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, ..v.clone() }).unwrap(); + let written = Object::Array(vec![page1.clone(), Object::name("XYZ"), Object::Null, Object::Null, Object::Real(2.0)]); + assert_eq!(open_action(&doc), Some(written), "{action:?}"); + let reopened = save_and_reopen(&doc); + assert_eq!(crate::initial_view(&reopened), crate::InitialView { page: 0, ..v.clone() }, "{action:?}"); + // Page 1 at the default magnification: no opening action, so the first page opens. + let mut doc = doc_opening_at(action.clone()); + crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, magnification: Magnification::Default, ..v }).unwrap(); + assert_eq!(open_action(&doc), None, "{action:?}"); + assert_eq!(crate::initial_view(&save_and_reopen(&doc)).page, 0, "{action:?}"); + } +} + +/// A named opening destination that already goes to the chosen page and magnification is kept, +/// and so is one that names nothing (it reads as page 1) until another page is chosen. +#[test] +fn a_named_opening_destination_is_kept_while_it_still_holds() { + use crate::view::Magnification; + let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + for (action, magnification) in + [(Object::String(PdfString::text("here")), Magnification::FitPage), (Object::name("nowhere"), Magnification::Default)] + { + let mut doc = doc_opening_at(action.clone()); + let v = crate::initial_view(&doc); + assert_eq!((v.page, v.magnification), (0, magnification), "{action:?}"); + crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap(); + assert_eq!(open_action(&doc), Some(action.clone()), "{action:?}"); + crate::set_initial_view(&mut doc, &crate::InitialView { page: 1, ..v.clone() }).unwrap(); + let kind = if magnification == Magnification::FitPage { "Fit" } else { "XYZ" }; + assert_eq!( + open_action(&doc).as_ref().and_then(Object::as_array).map(|a| (a.first().cloned(), a.get(1).cloned())), + Some((Some(page2.clone()), Some(Object::name(kind)))), + "{action:?}" + ); + } +} + // ── Split / Extract keep only the resources the part draws (#204) ────────────────────────────── /// A content stream with no text operators (an image draw). @@ -2679,8 +3164,9 @@ fn import_renames_a_field_colliding_with_another_incoming_field() { assert_eq!(form_field_names(&dst), ["Other", "Text1", "Text1-1"]); } -/// A page listed twice shows its widget on both copies — also when the field was renamed: both -/// pages' widgets belong to the one renamed field, and no second Text1 appears. +/// A page listed twice shows its widget on both copies — also when the field was renamed: each +/// copy has its own widget, both widgets belong to the one renamed field (#1810), and no second +/// Text1 appears. #[test] fn a_repeated_page_joins_the_renamed_field() { let mut dst = doc_with_field("Text1"); @@ -2697,10 +3183,14 @@ fn a_repeated_page_joins_the_renamed_field() { .and_then(|l| l.as_array().cloned()) .unwrap(); let renamed = fields[1].clone(); + let mut widgets = Vec::new(); for page in [1, 2] { let list = page_dict(&dst, page).get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); - assert!(list.contains(&renamed), "page {page}'s widget belongs to the renamed field"); + let widget = list.iter().find_map(|a| a.as_ref()).expect("a widget on the page"); + widgets.push(widget); + assert_eq!(dst.get(widget).as_dict().unwrap().reference(b"Parent"), renamed.as_ref(), "page {page}'s widget belongs to the renamed field"); } + assert_ne!(widgets[0], widgets[1], "each copy has its own widget"); } /// Combining forms keeps same-named fields separate — the second file's colliding field is @@ -2750,27 +3240,31 @@ fn combine_field_conflicts_previews_each_file_in_order() { assert_eq!(preview[2], [("Text1".to_string(), "Text1-2".to_string())], "the cascade continues past the earlier rename"); } +/// A one-page source whose page refers (`/Foo`) to object 6, followed by `tail` as objects 6, 7, … +fn chained(tail: &[String]) -> Document { + let mut b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Foo 6 0 R >>".into(), + body("C1"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + b.extend_from_slice(tail); + open(build(&b, "/Root 1 0 R")) +} + +/// [`chained`] with `n` objects, each referring to the next. +fn chain(n: usize) -> Document { + let mut tail: Vec = (0..n).map(|i| format!("<< /Next {} 0 R >>", 7 + i)).collect(); + tail.push("<< /End true >>".into()); + chained(&tail) +} + /// #1800: thousands of shallow objects, each referring to the next, used to recurse once per /// object and exhaust the stack. The copy is refused instead, and the destination is as it was; /// a chain within the budget and a short cycle copy as before. #[test] fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alone() { - fn chained(tail: &[String]) -> Document { - let mut b: Vec = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), - "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Foo 6 0 R >>".into(), - body("C1"), - "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), - ]; - b.extend_from_slice(tail); - open(build(&b, "/Root 1 0 R")) - } - fn chain(n: usize) -> Document { - let mut tail: Vec = (0..n).map(|i| format!("<< /Next {} 0 R >>", 7 + i)).collect(); - tail.push("<< /End true >>".into()); - chained(&tail) - } // On a thread with less stack than any platform's main thread, so the budget is proven. let run = std::thread::Builder::new() .stack_size(512 << 10) @@ -2790,6 +3284,162 @@ fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alo assert!(run.is_ok(), "the copy must not overflow the stack"); } +/// The fixture after an update that adds object 20 and declares `/Size 4294967295`. No number is +/// left above that, so [`Document::add`] fills free numbers from 12 on, the gap below object 20 first. +fn fixture_with_an_exhausted_size() -> Document { + let mut bytes = fixture(); + let text = String::from_utf8_lossy(&bytes).into_owned(); + let prev: usize = text.rsplit("startxref").next().and_then(|t| t.split_whitespace().next()).and_then(|n| n.parse().ok()).expect("startxref"); + let added = bytes.len(); + bytes.extend_from_slice(b"20 0 obj\n<< /Unused true >>\nendobj\n"); + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n20 1\n{added:010} 00000 n \ntrailer\n<< /Size 4294967295 /Root 1 0 R /Info 11 0 R /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + let doc = open(bytes); + assert!(doc.repair_log().iter().any(|l| l.contains("exhausted trailer")), "{:?}", doc.repair_log()); + doc +} + +/// The fixture holding object 4294967295, as a document does once an edit writes to an object of +/// that number (a damaged file's catalog, say). [`Document::add`] then fills free numbers below it. +fn fixture_holding_object_4294967295() -> Document { + let mut doc = open(fixture()); + doc.set(ObjRef::new(u32::MAX, 0), Object::String(PdfString::text("top"))); + doc +} + +/// Documents whose new objects are numbered in the gaps below their highest number. +fn documents_numbering_new_objects_below_their_highest() -> [(&'static str, Document); 2] { + [("an exhausted /Size", fixture_with_an_exhausted_size()), ("object 4294967295", fixture_holding_object_4294967295())] +} + +/// Duplicate, Replace and Insert Pages took the objects numbered above the destination's highest +/// number for the ones the import added. When new objects fill gaps below it, the copies escaped +/// resource sharing (each import stored the fonts again), and with object 4294967295 the number +/// after the highest overflowed: a panic in debug builds, and in release a wrap to 0 that counted +/// every object as added. The import now compares the object numbers before and after. +#[test] +fn an_import_shares_the_resources_it_added_wherever_they_are_numbered() { + let mut plain = open(fixture()); + duplicate_pages(&mut plain, &[0], None).unwrap(); + for (what, mut doc) in documents_numbering_new_objects_below_their_highest() { + let before = doc.object_numbers(); + duplicate_pages(&mut doc, &[0], None).unwrap(); + assert_eq!(labels(&doc), ["Page 1", "Page 1", "Page 2", "Page 3"], "{what}"); + assert_eq!(count_fonts(&doc), count_fonts(&plain), "{what}: the copy shares the font, as it does in the plain fixture"); + insert_file_pages(&mut doc, &open(fixture()), "Inserted", &[2], 0, FieldConflicts::Rename).unwrap(); + assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 1", "Page 2", "Page 3"], "{what}"); + assert_eq!(count_fonts(&doc), count_fonts(&plain), "{what}: the inserted page shares the font too"); + let after = doc.object_numbers(); + assert!(before.iter().all(|n| after.contains(n)), "{what}: every object the document had is still there"); + assert_eq!(labels(&full_roundtrip(&doc)), ["Page 3", "Page 1", "Page 1", "Page 2", "Page 3"], "{what}"); + } +} + +/// A refused import takes back what it added, which in these documents is numbered below their +/// highest object, and leaves the objects they had. +#[test] +fn a_refused_import_takes_back_what_it_added_wherever_it_is_numbered() { + let run = std::thread::Builder::new() + .stack_size(512 << 10) + .spawn(|| { + for (what, mut dst) in documents_numbering_new_objects_below_their_highest() { + let before = (labels(&dst), dst.object_numbers()); + let err = import_pages(&mut dst, &chain(20_000), &[0], 0).unwrap_err(); + assert!(matches!(&err, OrganizeError::Invalid(m) if m.contains("levels deep")), "{what}: {err:?}"); + assert_eq!((labels(&dst), dst.object_numbers()), before, "{what}: the destination is as it was"); + } + }) + .unwrap() + .join(); + assert!(run.is_ok(), "a refused import must leave the document as it was, without panicking"); +} + +/// What Edit ▸ Attach file added survives Combine files (upstream's writer), names +/// with accents included, and the combined document takes more attachments and deletions. +#[test] +fn attachments_added_in_the_app_survive_combine_and_more_can_be_attached() { + let embedded = |doc: &Document, key: &str| -> (Vec, Option) { + let names = doc.resolve(catalog(doc).get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = doc.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap(); + let pairs = tree.get(b"Names").and_then(|n| n.as_array()).cloned().unwrap(); + let at = pairs.chunks(2).position(|p| p[0].as_string().unwrap().to_text() == key).unwrap_or_else(|| panic!("no attachment {key}")); + let spec = doc.resolve(&pairs[at * 2 + 1]).as_dict().cloned().unwrap(); + let desc = spec.get(b"Desc").and_then(Object::as_string).map(|s| s.to_text()); + let ef = spec.get(b"EF").and_then(Object::as_dict).unwrap().get(b"F").cloned().unwrap(); + let Object::Stream(stream) = doc.resolve(&ef).as_ref().clone() else { panic!("the embedded file is a stream") }; + (stream.decoded().unwrap(), desc) + }; + let mut a = Document::open(Arc::new(fixture())).unwrap(); + add_attachment(&mut a, "invoice.csv", b"a,b", Some("March"), Some("text/csv"), None).unwrap(); + add_attachment(&mut a, "año.txt", b"uno", None, None, None).unwrap(); + let mut b = Document::open(Arc::new(fixture())).unwrap(); + add_attachment(&mut b, "invoice.csv", b"x,y", None, None, None).unwrap(); + add_attachment(&mut b, "año.txt", b"dos", None, None, None).unwrap(); + let a = save_and_reopen(&a); + let b = save_and_reopen(&b); + let mut out = full_roundtrip(&combine(&[("A", &a), ("B", &b)]).unwrap()); + // Both files' attachments, the second copies numbered as text (not bytes glued onto UTF-16). + assert_eq!(attachment_keys(&out), ["invoice.csv", "invoice.csv (2)", "año.txt", "año.txt (2)"]); + assert_eq!(embedded(&out, "invoice.csv"), (b"a,b".to_vec(), Some("March".into()))); + assert_eq!(embedded(&out, "invoice.csv (2)"), (b"x,y".to_vec(), None)); + assert_eq!(embedded(&out, "año.txt (2)").0, b"dos"); + // The combined document goes on taking attachments, and losing them, like any other. + assert_eq!(add_attachment(&mut out, "invoice.csv", b"z", None, None, None).unwrap(), "invoice.csv (3)"); + delete_attachment(&mut out, "invoice.csv (2)").unwrap(); + let out = full_roundtrip(&out); + assert_eq!(attachment_keys(&out), ["invoice.csv", "invoice.csv (3)", "año.txt", "año.txt (2)"]); + assert_eq!(embedded(&out, "invoice.csv (3)").0, b"z"); +} + +#[test] +fn attachments_are_added_listed_and_deleted() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + assert!(attachment_keys(&doc).is_empty()); + // A path loses its folder; the key is the file name. + let key = add_attachment(&mut doc, "/Users/eva/Desktop/invoice.csv", b"a,b\n1,2\n", Some("March"), Some("text/csv"), Some("D:20261012120000Z")) + .unwrap(); + assert_eq!(key, "invoice.csv"); + // The same name again is kept apart. + assert_eq!(add_attachment(&mut doc, "invoice.csv", b"x", None, None, None).unwrap(), "invoice.csv (2)"); + assert_eq!(add_attachment(&mut doc, " ", b"x", None, None, None), Err(OrganizeError::Invalid("the attachment needs a file name".into()))); + let doc = save_and_reopen(&doc); + assert_eq!(attachment_keys(&doc), ["invoice.csv", "invoice.csv (2)"]); + // What was written: a Filespec with the embedded stream, its size and type, and the description. + let root = doc.root().unwrap(); + let names = doc.get(root).as_dict().unwrap().get(b"Names").cloned().unwrap(); + let tree = doc.resolve(&names).as_dict().unwrap().get(b"EmbeddedFiles").cloned().unwrap(); + let tree = doc.resolve(&tree); + let pairs = tree.as_dict().unwrap().get(b"Names").cloned().unwrap(); + let pairs = doc.resolve(&pairs).as_array().cloned().unwrap(); + assert_eq!(pairs.len(), 4); + let spec = doc.resolve(&pairs[1]); + let spec = spec.as_dict().unwrap(); + assert_eq!(spec.get(b"Type"), Some(&Object::name("Filespec"))); + assert_eq!(spec.get(b"UF").and_then(Object::as_string).map(PdfString::to_text).as_deref(), Some("invoice.csv")); + assert_eq!(spec.get(b"Desc").and_then(Object::as_string).map(PdfString::to_text).as_deref(), Some("March")); + let ef = spec.get(b"EF").and_then(Object::as_dict).unwrap().get(b"F").cloned().unwrap(); + let ef = doc.resolve(&ef); + let Object::Stream(stream) = ef.as_ref() else { panic!("the embedded file is a stream") }; + assert_eq!(stream.dict.get(b"Subtype"), Some(&Object::Name(b"text/csv".to_vec()))); + let params = stream.dict.get(b"Params").and_then(Object::as_dict).unwrap(); + assert_eq!(params.get(b"Size"), Some(&Object::Int(8))); + assert_eq!(stream.decoded().unwrap(), b"a,b\n1,2\n"); + // Deleting: the one named goes, the other stays; the last one takes the tree with it. + let mut doc = doc; + delete_attachment(&mut doc, "invoice.csv").unwrap(); + assert_eq!(attachment_keys(&doc), ["invoice.csv (2)"]); + assert_eq!(delete_attachment(&mut doc, "invoice.csv"), Err(OrganizeError::Invalid("there is no attachment named invoice.csv".into()))); + delete_attachment(&mut doc, "invoice.csv (2)").unwrap(); + let doc = save_and_reopen(&doc); + assert!(attachment_keys(&doc).is_empty()); + assert!(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Names").is_none()); +} + #[test] fn rotate_repeated_indices_accumulate_from_the_current_page_value() { for level in 0..3 { @@ -2821,3 +3471,345 @@ fn rotate_repeated_indices_accumulate_from_the_current_page_value() { } } } + +#[test] +fn a_bookmark_can_go_to_a_point_on_its_page_and_survives_save() { + let mut d = doc_a(); + // No position: as before, `/XYZ null null null`, the page top at the reader's zoom. + crate::add_bookmark(&mut d, &[], 9, "Top", 0).unwrap(); + // Only a height (a bookmark made at the top of the window), a full point, and only a left. + assert_eq!(crate::add_bookmark_at(&mut d, &[], 9, "Section", 1, None, Some(612.25)).unwrap(), vec![1]); + crate::add_bookmark_at(&mut d, &[], 9, "Table", 1, Some(72.5), Some(300.0)).unwrap(); + crate::add_bookmark_at(&mut d, &[1], 9, "Inside", 2, Some(10.0), None).unwrap(); + let view = |d: &Document, title: &str| crate::bookmarks(d).into_iter().find(|b| b.title == title).unwrap().view; + let xyz = |left, top| Some((b"XYZ".to_vec(), vec![left, top, None])); + let check = |d: &Document| { + assert_eq!(view(d, "Top"), xyz(None, None)); + assert_eq!(view(d, "Section"), xyz(None, Some(612.25))); + assert_eq!(view(d, "Table"), xyz(Some(72.5), Some(300.0))); + assert_eq!(view(d, "Inside"), xyz(Some(10.0), None)); + let page_of = |title: &str| crate::bookmarks(d).into_iter().find(|b| b.title == title).unwrap().page; + assert_eq!((page_of("Top"), page_of("Section"), page_of("Table"), page_of("Inside")), (Some(0), Some(1), Some(1), Some(2))); + }; + check(&d); + check(&full_roundtrip(&d)); +} + +#[test] +fn a_bookmark_position_that_is_not_a_number_is_refused_and_changes_nothing() { + let mut d = doc_a(); + crate::add_bookmark(&mut d, &[], 9, "Keep", 0).unwrap(); + for (left, top) in [(Some(f64::NAN), None), (None, Some(f64::INFINITY)), (Some(1.0), Some(f64::NEG_INFINITY))] { + assert_eq!(crate::add_bookmark_at(&mut d, &[], 9, "Bad", 0, left, top), Err(crate::OutlineError::BadPosition), "{left:?} {top:?}"); + } + assert_eq!(titles(&crate::bookmarks(&d)), ["Keep"]); + // The page is still checked, and so is the title. + assert!(crate::add_bookmark_at(&mut d, &[], 9, "Nowhere", 7, Some(1.0), Some(1.0)).is_err()); + assert_eq!(crate::add_bookmark_at(&mut d, &[], 9, " ", 0, Some(1.0), Some(1.0)), Err(crate::OutlineError::EmptyTitle)); + assert_eq!(titles(&crate::bookmarks(&d)), ["Keep"]); +} + +#[test] +fn page_walk_skips_repeated_and_cyclic_kids_without_copying_attributes() { + let mut doc = open(fixture()); + let (root, branch) = (ObjRef { num: 2, generation: 0 }, ObjRef { num: 3, generation: 0 }); + let (first, second) = (ObjRef { num: 4, generation: 0 }, ObjRef { num: 5, generation: 0 }); + // The branch node carries Resources for its pages: a cycle back to it, then both pages + // 128 times over. + let mut kids = vec![Object::Ref(branch)]; + kids.extend([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(256)); + doc.update_dict(branch, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap(); + let mut walk = PageWalk::new(&doc, root); + let (page, attrs) = walk.next().unwrap(); + assert_eq!(page, first); + assert!(walk.pending.len() <= 3, "repeated kids retained {} pending entries", walk.pending.len()); + assert!(attrs.get(b"Resources").and_then(|r| r.as_dict()).is_some_and(|r| r.contains(b"Font")), "Resources from the branch"); + assert_eq!(attrs.get(b"Rotate").and_then(Object::as_int), Some(90), "Rotate from the root"); + assert_eq!(walk.next().map(|(p, _)| p), Some(second)); + let (_, last) = walk.next().unwrap(); + let media_box = last.get(b"MediaBox").and_then(Object::as_array).map(|m| m.iter().filter_map(Object::as_int).collect::>()); + assert_eq!(media_box, Some(vec![0, 0, 500, 500]), "own MediaBox kept over the root's"); + assert!(walk.next().is_none()); + assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]); +} + +/// A one-page document whose page carries `annots` (object bodies 5…), with `extra` bodies after them. +fn page_with_annots(annots: &[String], extra: &[String], catalog_extra: &str) -> Document { + let n = annots.len(); + let refs: String = (0..n).map(|i| format!("{} 0 R ", i + 5)).collect(); + let mut b: Vec = vec![ + format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Annots [{refs}] >>"), + body("P1"), + ]; + b.extend(annots.iter().cloned()); + b.extend(extra.iter().cloned()); + open(build(&b, "/Root 1 0 R")) +} + +#[test] +fn registering_many_imported_fields_is_linear() { + let n = 2_000; + let widgets: Vec = (0..n).map(|i| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [0 0 10 10] >>")).collect(); + let src = page_with_annots(&widgets, &[], ""); + take_work(); + let out = extract_pages(&src, &[0]).unwrap(); + let work = take_work(); + assert!(work <= 4 * n, "{work} steps registering {n} fields"); + let form = out.resolve(catalog(&out).get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(form.get(b"Fields").and_then(|f| f.as_array()).map(Vec::len), Some(n)); +} + +#[test] +fn renaming_many_same_named_fields_resumes_the_suffix_search() { + let n = 2_000; + let widgets: Vec = (0..n).map(|_| "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [0 0 10 10] >>".to_string()).collect(); + let src = page_with_annots(&widgets, &[], ""); + take_work(); + let renames = combine_field_conflicts(&[&src]).remove(0); + let work = take_work(); + assert!(work <= 4 * n, "{work} suffix attempts for {n} fields"); + // Still the first free variant, in order. + assert_eq!(renames.len(), n - 1); + assert_eq!(renames.first(), Some(&("x".to_string(), "x-1".to_string()))); + assert_eq!(renames.last(), Some(&("x".to_string(), format!("x-{}", n - 1)))); +} + +#[test] +fn annotation_actions_share_the_objects_they_copy() { + // Fifty links whose URI actions all point at one string object. + let n = 50; + let links: Vec = + (0..n).map(|_| format!("<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI {} 0 R >> >>", n + 5)).collect(); + let src = page_with_annots(&links, &["(https://example.com/shared)".into()], ""); + let out = extract_pages(&src, &[0]).unwrap(); + let copies = out + .object_numbers() + .into_iter() + .filter(|&o| out.get(ObjRef::new(o, out.generation(o))).as_string().is_some_and(|s| s.bytes == b"https://example.com/shared")) + .count(); + assert_eq!(copies, 1, "the shared URI is copied once, not once per action"); +} + +#[test] +fn name_trees_with_shared_kids_are_walked_once_per_node() { + // Each level lists the next one twice: 2^depth paths through `depth` nodes. + let depth = 10; + let first = 6; // after the catalog, pages, page, content and the link + let mut extra = Vec::new(); + for tree in 0..2 { + let base = first + tree * (depth + 1); + for level in 0..depth { + let next = base + level + 1; + extra.push(format!("<< /Kids [{next} 0 R {next} 0 R] >>")); + } + extra.push(if tree == 0 { + "<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".into() + } else { + "<< /Names [(other) [3 0 R /Fit]] >>".into() + }); + } + let link = "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest (missing) >>".to_string(); + let catalog_extra = format!("/Names << /EmbeddedFiles {first} 0 R /Dests {} 0 R >>", first + depth + 1); + let src = page_with_annots(&[link], &extra, &catalog_extra); + take_work(); + let out = combine(&[("a", &src)]).unwrap(); + let work = take_work(); + assert!(work <= 8 * (depth + 1), "{work} name-tree nodes visited"); + let names = out.resolve(catalog(&out).get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = out.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(tree.get(b"Names").and_then(|n| n.as_array()).map(Vec::len), Some(2), "one attachment, listed once"); +} + +#[test] +fn a_form_drawing_itself_repeatedly_is_analysed_once() { + let form = "/F Do /F Do /F Do /F Do"; + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /F 5 0 R /Unused 6 0 R >> >> >>".into(), + "<< /Length 5 >>\nstream\n/F Do\nendstream".into(), + format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << /XObject << /F 5 0 R >> >> /Length {} >>\nstream\n{form}\nendstream", + form.len() + ), + "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 0 >>\nstream\n\nendstream".into(), + ]; + let src = open(build(&b, "/Root 1 0 R")); + take_work(); + let out = extract_pages(&src, &[0]).unwrap(); + let work = take_work(); + assert!(work <= 4, "{work} form analyses"); + let page = out.get(pages(&out).unwrap()[0].obj).as_dict().cloned().unwrap(); + let res = out.resolve(page.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let x = out.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + assert!(x.contains(b"F") && !x.contains(b"Unused"), "pruning still keeps what is drawn and drops the rest"); +} + +/// One page whose /Contents names the same 1 MiB stream 300 times (a few kilobytes on disk). +fn repeated_contents() -> Document { + use pdfcraft_cos::{Dict, Stream}; + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let pages = doc.add(Object::Null); + let page: Dict = [ + (b"Type".to_vec(), Object::Name(b"Page".to_vec())), + (b"Parent".to_vec(), Object::Ref(pages)), + (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())), + (b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300])), + ] + .into_iter() + .collect(); + let page = doc.add(page); + let tree: Dict = [ + (b"Type".to_vec(), Object::Name(b"Pages".to_vec())), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(); + doc.set(pages, Object::Dict(tree)); + let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect(); + let catalog = doc.add(catalog); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + doc +} + +#[test] +fn repeated_content_references_share_one_decode_budget() { + let src = repeated_contents(); + let mut dst = Document::new_empty(); + if let Ok((form, _)) = page_as_form(&mut dst, &src, 0) { + let Object::Stream(s) = &*dst.get(form) else { panic!("not a stream") }; + let len = s.decoded().unwrap().len(); + assert!(len <= pdfcraft_cos::MAX_PAGE_DECODED + 300, "{len} bytes"); + } +} + +/// #3193: the bookmark list reads a bookmark's colour and style as the inspector does (ISO +/// 32000-2 §12.3.3, Tables 151 and 152): `/C` within 0–1, black and malformed values as none, +/// `/F` bit 1 italic and bit 2 bold, through indirect values too. +#[test] +fn bookmarks_report_their_colour_and_style() { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 400] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Outlines /First 5 0 R /Last 10 0 R /Count 6 >>".into(), + ]; + objs.push("<< /Title (Plain) /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>".into()); // 5 + objs.push("<< /Title (Red bold) /Parent 4 0 R /Prev 5 0 R /Next 7 0 R /Dest [3 0 R /Fit] /C [1 0 0] /F 2 >>".into()); // 6 + objs.push("<< /Title (Indirect) /Parent 4 0 R /Prev 6 0 R /Next 8 0 R /Dest [3 0 R /Fit] /C 11 0 R /F 12 0 R >>".into()); // 7 + objs.push("<< /Title (Black) /Parent 4 0 R /Prev 7 0 R /Next 9 0 R /Dest [3 0 R /Fit] /C [0 0 0] /F 1 >>".into()); // 8 + objs.push("<< /Title (Bright) /Parent 4 0 R /Prev 8 0 R /Next 10 0 R /Dest [3 0 R /Fit] /C [2 -1 0.5] >>".into()); // 9 + objs.push("<< /Title (Short) /Parent 4 0 R /Prev 9 0 R /Dest [3 0 R /Fit] /C [1 0] /F 4 >>".into()); // 10 + objs.push("[0 0.5 0]".into()); // 11 + objs.push("3".into()); // 12 + let doc = open(build(&objs, "/Root 1 0 R")); + let got: Vec<(String, Option<[f32; 3]>, bool, bool)> = bookmarks(&doc).into_iter().map(|b| (b.title, b.color, b.italic, b.bold)).collect(); + assert_eq!( + got, + [ + ("Plain".into(), None, false, false), + ("Red bold".into(), Some([1.0, 0.0, 0.0]), false, true), + ("Indirect".into(), Some([0.0, 0.5, 0.0]), true, true), + ("Black".into(), None, true, false), + ("Bright".into(), Some([1.0, 0.0, 0.5]), false, false), + ("Short".into(), None, false, false), + ] + ); +} + +/// #3193: setting a bookmark's style and colour writes what ISO 32000-2 §12.3.3 asks for: bold is +/// `/F 2` and bold italic `/F 3` (Table 152), and neither removes `/F`, whose default is 0; a +/// style change leaves `/C` alone and a colour change leaves `/F`; black, the default (Table 151), +/// removes `/C`. Bits the table doesn't define are kept, and a colour outside 0–1 is refused. +#[test] +fn bookmark_style_and_colour_are_set_as_the_table_says() { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 400] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Outlines /First 5 0 R /Last 6 0 R /Count 2 >>".into(), + ]; + objs.push("<< /Title (Plain) /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>".into()); // 5 + objs.push("<< /Title (Odd bits) /Parent 4 0 R /Prev 5 0 R /Dest [3 0 R /Fit] /F 4 /C [1 0 0] >>".into()); // 6 + let mut doc = open(build(&objs, "/Root 1 0 R")); + let raw = |doc: &Document, n: u32, key: &[u8]| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.get(key).cloned()); + crate::set_bookmark_style(&mut doc, &[0], Some(true), None).unwrap(); + assert_eq!(raw(&doc, 5, b"F"), Some(Object::Int(2)), "bold"); + crate::set_bookmark_style(&mut doc, &[0], None, Some(true)).unwrap(); + assert_eq!(raw(&doc, 5, b"F"), Some(Object::Int(3)), "bold italic"); + crate::set_bookmark_color(&mut doc, &[0], Some([0.0, 0.5, 0.0])).unwrap(); + assert_eq!(raw(&doc, 5, b"C"), Some(Object::Array(vec![Object::Real(0.0), Object::Real(0.5), Object::Real(0.0)]))); + assert_eq!(raw(&doc, 5, b"F"), Some(Object::Int(3)), "a colour change keeps the style"); + crate::set_bookmark_style(&mut doc, &[0], Some(false), Some(false)).unwrap(); + assert_eq!(raw(&doc, 5, b"F"), None, "regular removes /F"); + assert!(raw(&doc, 5, b"C").is_some(), "a style change keeps the colour"); + crate::set_bookmark_color(&mut doc, &[0], Some([0.0; 3])).unwrap(); + assert_eq!(raw(&doc, 5, b"C"), None, "black removes /C"); + + crate::set_bookmark_style(&mut doc, &[1], Some(true), None).unwrap(); + assert_eq!(raw(&doc, 6, b"F"), Some(Object::Int(6)), "bold beside a bit the table doesn't define"); + crate::set_bookmark_style(&mut doc, &[1], Some(false), Some(false)).unwrap(); + assert_eq!(raw(&doc, 6, b"F"), Some(Object::Int(4)), "that bit stays"); + crate::set_bookmark_color(&mut doc, &[1], None).unwrap(); + assert_eq!(raw(&doc, 6, b"C"), None, "no colour is black"); + for bad in [[1.5, 0.0, 0.0], [0.0, -0.1, 0.0], [f64::NAN, 0.0, 0.0]] { + assert_eq!(crate::set_bookmark_color(&mut doc, &[1], Some(bad)), Err(crate::OutlineError::BadColor), "{bad:?}"); + } + assert_eq!(crate::set_bookmark_style(&mut doc, &[7], Some(true), None), Err(crate::OutlineError::NoSuchBookmark(vec![7]))); + + crate::set_bookmark_style(&mut doc, &[0], Some(true), Some(true)).unwrap(); + crate::set_bookmark_color(&mut doc, &[0], Some([0.0, 0.0, 1.0])).unwrap(); + let saved = open(write_full(&doc, &SaveOptions::default()).unwrap()); + let got: Vec<_> = bookmarks(&saved).into_iter().map(|b| (b.color, b.bold, b.italic)).collect(); + assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]); +} + +/// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a +/// `%` comment at the end of a piece without a line break continues through the first line of the +/// next piece in both resource pruning (`extract_pages`) and `page_as_form`. +#[test] +fn contents_pieces_join_with_a_space_across_a_trailing_comment() { + let build_doc = |first_piece: &str| { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] /Resources 6 0 R >>".into(), // 3 + raw(first_piece), // 4 + raw("/ImHidden Do\n/ImShown Do"), // 5 + "<< /XObject << /ImHidden 7 0 R /ImShown 8 0 R >> >>".into(), // 6 + image(1, 1, 1), // 7 + image(1, 1, 1), // 8 + ]; + open(build(&b, "/Root 1 0 R")) + }; + + // Without a trailing newline in piece 1, `% comment` swallows `/ImHidden Do` on the first + // line of piece 2; only `/ImShown` is drawn. + let commented = build_doc("q Q % comment without newline"); + let extracted = full_roundtrip(&extract_pages(&commented, &[0]).unwrap()); + assert_eq!(xobject_names(&extracted, 0), [b"ImShown".to_vec()], "commented-out Do across piece boundary is pruned"); + + let mut form_dst = Document::new_empty(); + let (form_ref, _) = crate::page_as_form(&mut form_dst, &commented, 0).unwrap(); + let Object::Stream(form_stream) = &*form_dst.get(form_ref) else { panic!("form stream") }; + let form_bytes = form_stream.decoded().unwrap(); + let drawn: Vec> = pdfcraft_content::parse(&form_bytes) + .ops + .into_iter() + .filter(|op| op.op == b"Do") + .filter_map(|op| op.operands.first().and_then(Object::as_name).map(|n| n.to_vec())) + .collect(); + assert_eq!(drawn, [b"ImShown".to_vec()], "page_as_form keeps the first line of piece 2 inside the comment"); + + // Control: when piece 1 ends the comment with a newline, `/ImHidden Do` on piece 2's first + // line is a real operator and stays in both the extracted page and the form XObject. + let newline_ended = build_doc("q Q % comment with newline\n"); + let extracted_ctrl = full_roundtrip(&extract_pages(&newline_ended, &[0]).unwrap()); + let mut ctrl_names = xobject_names(&extracted_ctrl, 0); + ctrl_names.sort(); + assert_eq!(ctrl_names, [b"ImHidden".to_vec(), b"ImShown".to_vec()]); +} diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs index efca2ce4d9..899293b980 100644 --- a/crates/organize/src/view.rs +++ b/crates/organize/src/view.rs @@ -63,6 +63,10 @@ pub struct InitialView { pub language: Option, /// Right-edge binding (`/Direction /R2L`). pub right_to_left: bool, + /// The navigation panel on leaving full screen (`/ViewerPreferences /NonFullScreenPageMode`, + /// ISO 32000-2 Table 147), read only when `full_screen` is set. It is read, not written: + /// [`set_initial_view`] leaves the entry as it is. + pub non_full_screen: Navigation, } fn catalog(doc: &Document) -> Result<(pdfcraft_cos::ObjRef, Dict), OrganizeError> { @@ -96,7 +100,7 @@ pub fn initial_view(doc: &Document) -> InitialView { ..InitialView::default() }; if let Some(vp) = c.get(b"ViewerPreferences").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) { - let flag = |k: &[u8]| matches!(vp.get(k), Some(Object::Bool(true))); + let flag = |k: &[u8]| matches!(vp.get(k).map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))); v.fit_window = flag(b"FitWindow"); v.center_window = flag(b"CenterWindow"); v.display_title = flag(b"DisplayDocTitle"); @@ -104,11 +108,22 @@ pub fn initial_view(doc: &Document) -> InitialView { v.hide_toolbar = flag(b"HideToolbar"); v.hide_window_ui = flag(b"HideWindowUI"); v.right_to_left = name(&vp, b"Direction").as_deref() == Some(b"R2L"); + // Table 147: meaningful only with `/PageMode /FullScreen`, and only these four values. + if v.full_screen { + v.non_full_screen = match name(&vp, b"NonFullScreenPageMode").as_deref() { + Some(b"UseOutlines") => Navigation::Bookmarks, + Some(b"UseThumbs") => Navigation::Pages, + Some(b"UseOC") => Navigation::Layers, + _ => Navigation::PageOnly, + }; + } } - // The opening destination: an explicit array, or a GoTo action's. + // The opening destination: an explicit array, a GoTo action's `/D`, or a named destination (a + // name or string, §12.3.2.4) as the explicit one it names, so that the page and magnification + // read here are where the document opens. A name that names nothing reads as page 1. let dest = c.get(b"OpenAction").map(|o| doc.resolve(o)).and_then(|o| match &*o { - Object::Array(a) => Some(a.clone()), - Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()), + Object::Array(_) | Object::Name(_) | Object::String(_) => crate::outline::resolve_dest(doc, &o), + Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").and_then(|x| crate::outline::resolve_dest(doc, x)), _ => None, }); if let Some(d) = dest { @@ -149,8 +164,13 @@ pub fn displays_doc_title(doc: &Document) -> bool { } /// Write the initial view. An opening action that isn't a plain destination (a script, say) is -/// kept unless the page or magnification asks for a destination. +/// kept unless the page or magnification asks for a destination, and an opening action whose page +/// and magnification `v` leaves as [`initial_view`] reads them is kept as it is. pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), OrganizeError> { + let same_destination = { + let now = initial_view(doc); + now.page == v.page && now.magnification == v.magnification + }; let (root, c) = catalog(doc)?; let pages = crate::walk(doc)?; if v.page >= pages.len() { @@ -177,9 +197,12 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ } else { vp.remove(b"Direction"); } + // A destination, explicit or named (§12.3.2.4), or a GoTo action to one. let open_action_is_dest = match c.get(b"OpenAction").map(|o| doc.resolve(o)) { None => true, - Some(o) => matches!(&*o, Object::Array(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")), + Some(o) => { + matches!(&*o, Object::Array(_) | Object::Name(_) | Object::String(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")) + } }; let dest = match v.magnification { Magnification::Default if v.page == 0 => None, @@ -238,6 +261,9 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ } } match (&dest, open_action_is_dest) { + // The opening page and magnification are as read: the action stays as written, with + // its position or named destination (§12.3.2.4) that the view model doesn't hold. + _ if same_destination => {} (Some(d), _) => c.set(b"OpenAction".to_vec(), Object::Array(d.clone())), (None, true) => { c.remove(b"OpenAction"); @@ -248,3 +274,86 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ })?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_cos::{SaveOptions, write_full}; + use std::sync::Arc; + + fn flags(view: InitialView) -> [bool; 6] { + [view.fit_window, view.center_window, view.display_title, view.hide_menubar, view.hide_toolbar, view.hide_window_ui] + } + + #[test] + fn initial_view_resolves_indirect_boolean_preferences() { + let keys: [&[u8]; 6] = [b"FitWindow", b"CenterWindow", b"DisplayDocTitle", b"HideMenubar", b"HideToolbar", b"HideWindowUI"]; + for enabled in [true, false] { + for indirect_dictionary in [false, true] { + let mut doc = Document::new_empty(); + let boolean = doc.add(Object::Bool(enabled)); + let mut preferences = Dict::new(); + for key in keys { + preferences.set(key.to_vec(), Object::Ref(boolean)); + } + let preferences = if indirect_dictionary { Object::Ref(doc.add(preferences)) } else { Object::Dict(preferences) }; + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), preferences)).unwrap(); + assert_eq!(flags(initial_view(&doc)), [enabled; 6]); + let saved = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(flags(initial_view(&reopened)), [enabled; 6]); + assert_eq!(displays_doc_title(&reopened), enabled); + } + } + } + + #[test] + fn initial_view_malformed_boolean_preferences_keep_false_defaults() { + for value in [Object::Null, Object::Int(1), Object::name("true"), Object::Ref(pdfcraft_cos::ObjRef::new(999_999, 0))] { + let mut doc = Document::new_empty(); + let value = doc.add(value); + let mut preferences = Dict::new(); + preferences.set(b"FitWindow".to_vec(), Object::Ref(value)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), Object::Dict(preferences))).unwrap(); + assert_eq!(flags(initial_view(&doc)), [false; 6]); + } + } + + /// A catalog with `/PageMode page_mode` and `/ViewerPreferences << /NonFullScreenPageMode mode >>`. + fn with_modes(page_mode: &str, mode: Object) -> Document { + let mut doc = Document::new_empty(); + let mode = doc.add(mode); + let mut preferences = Dict::new(); + preferences.set(b"NonFullScreenPageMode".to_vec(), Object::Ref(mode)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"PageMode".to_vec(), Object::name(page_mode)); + c.set(b"ViewerPreferences".to_vec(), Object::Dict(preferences)); + }) + .unwrap(); + doc + } + + #[test] + fn non_full_screen_page_mode_is_read_with_full_screen_only() { + for (mode, want) in [ + ("UseOutlines", Navigation::Bookmarks), + ("UseThumbs", Navigation::Pages), + ("UseOC", Navigation::Layers), + ("UseNone", Navigation::PageOnly), + // Not a value Table 147 lists. + ("UseAttachments", Navigation::PageOnly), + ("FullScreen", Navigation::PageOnly), + ] { + let v = initial_view(&with_modes("FullScreen", Object::name(mode))); + assert!(v.full_screen); + assert_eq!(v.non_full_screen, want, "{mode}"); + // Ignored unless the document opens in full screen. + let v = initial_view(&with_modes("UseThumbs", Object::name(mode))); + assert_eq!((v.navigation, v.non_full_screen), (Navigation::Pages, Navigation::PageOnly), "{mode}"); + } + assert_eq!(initial_view(&with_modes("FullScreen", Object::Int(1))).non_full_screen, Navigation::PageOnly); + } +} diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index 3efa8d2653..5b6f3930c8 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -54,21 +54,164 @@ fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> { /// that can't be edited safely. pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { let Some((name, ns, shape)) = property(key) else { return Ok(()) }; - let Some(root) = doc.root() else { return Ok(()) }; + edit_packet(doc, |xmp| set_property(xmp, name, ns, shape, value)).map(|_| ()) +} + +/// Remove every occurrence of the properties `names` from the catalog's XMP packet; whether any +/// was there. +pub(crate) fn remove_properties(doc: &mut Document, names: &[&str]) -> Result { + edit_packet(doc, |xmp| { + let mut out: Option = None; + for name in names { + // Each removal shortens the packet, so this ends. + while let Some(next) = set_property(out.as_deref().unwrap_or(xmp), name, "", Shape::Text, "") { + out = Some(next); + } + } + out + }) +} + +/// Rewrite the catalog's XMP packet with `edit` (`None`: unchanged); whether it changed. Files +/// without XMP and packets that can't be edited safely are left alone. +fn edit_packet(doc: &mut Document, edit: impl FnOnce(&str) -> Option) -> Result { + let Some(root) = doc.root() else { return Ok(false) }; // A metadata stream is always indirect (§7.3.8); anything else is left alone. - let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(()) }; + let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(false) }; let obj = doc.get(r); - let Object::Stream(stream) = &*obj else { return Ok(()) }; - let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(()) }; - let Ok(xmp) = String::from_utf8(bytes) else { return Ok(()) }; - let Some(updated) = set_property(&xmp, name, ns, shape, value) else { return Ok(()) }; + let Object::Stream(stream) = &*obj else { return Ok(false) }; + let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(false) }; + let Ok(xmp) = String::from_utf8(bytes) else { return Ok(false) }; + let Some(updated) = edit(&xmp) else { return Ok(false) }; // Written unfiltered, as PDF/A asks of metadata streams, so other tools can read it. let mut dict = stream.dict.clone(); for k in [&b"Filter"[..], b"DecodeParms", b"DL", b"Length"] { dict.remove(k); } doc.set(r, Object::Stream(Stream::from_raw(dict, updated.into_bytes()))); - Ok(()) + Ok(true) +} + +/// The value the catalog's XMP packet gives the document information key `key`, for files that +/// state their title, author and so on only there (`/Info` is deprecated in PDF 2.0, and some +/// writers leave it out). A language alternative gives its `x-default` item (else the first), +/// several authors are joined with "; ". `None` for keys XMP doesn't mirror, files without XMP, +/// packets that can't be read safely, and empty values. +pub(crate) fn info_value(doc: &Document, key: &str) -> Option { + let (name, _, shape) = property(key)?; + let root = doc.root()?; + let r = doc.get(root).as_dict()?.reference(b"Metadata")?; + let obj = doc.get(r); + let Object::Stream(stream) = &*obj else { return None }; + let xmp = String::from_utf8(stream.decoded_within(MAX_XMP).ok()?).ok()?; + get_property(&xmp, name, shape) +} + +/// The text of the property `name` in `xmp`, element or attribute form (see [`info_value`]). +fn get_property(xmp: &str, name: &str, shape: Shape) -> Option { + let value = if let Some((start, end)) = element_span(xmp, name) { + let element = xmp.get(start..end)?; + let open_end = element.find('>')?; + // ``: no value. + if element.get(..open_end)?.ends_with('/') { + return None; + } + let inner = element.get(open_end + 1..)?.strip_suffix(&format!(""))?; + let items = list_items(inner); + if items.is_empty() { + // An empty `` or `` has no value, not its markup. + text_value(inner)? + } else if shape == Shape::LangAlt { + let (_, text) = items.iter().find(|(lang, _)| lang.as_deref() == Some("x-default")).or(items.first())?; + text_value(text)? + } else { + items.iter().filter_map(|(_, text)| text_value(text)).filter(|t| !t.is_empty()).collect::>().join("; ") + } + } else { + let (_, value_start, value_end, _) = attribute_span(xmp, name)?; + unescape(xmp.get(value_start..value_end)?.trim()) + }; + Some(value).filter(|v| !v.trim().is_empty()) +} + +/// Character data `text` as a value, unescaped; `None` when it still holds markup (a `<`, which +/// a value escapes as `<`), so a structure the reader doesn't take apart never shows as text. +fn text_value(text: &str) -> Option { + let text = text.trim(); + (!text.contains('<')).then(|| unescape(text)) +} + +/// The `rdf:li` items of a property's content: each one's `xml:lang` (if any) and its text. +fn list_items(inner: &str) -> Vec<(Option, &str)> { + let mut items = Vec::new(); + let mut rest = inner; + while let Some(i) = rest.find("' || c == '/' || c.is_whitespace()) { + rest = after; + continue; + } + let Some(tag_end) = after.find('>') else { break }; + let tag = after.get(..tag_end).unwrap_or_default(); + let lang = tag.find("xml:lang=").and_then(|at| { + let v = tag.get(at + "xml:lang=".len()..)?; + let quote = v.chars().next().filter(|c| matches!(c, '"' | '\''))?; + let v = v.get(1..)?; + Some(v.get(..v.find(quote)?)?.to_string()) + }); + let Some(body) = after.get(tag_end + 1..) else { break }; + if tag.ends_with('/') { + items.push((lang, "")); + rest = body; + continue; + } + let Some(close) = body.find("") else { break }; + items.push((lang, body.get(..close).unwrap_or_default())); + rest = body.get(close..).unwrap_or_default(); + } + items +} + +/// XML text with its character and entity references resolved (an unknown one is kept as is). +fn unescape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let after = rest.get(i..).unwrap_or_default(); + let Some(semi) = after.find(';').filter(|&n| n <= 12) else { + out.push('&'); + rest = after.get(1..).unwrap_or_default(); + continue; + }; + let entity = after.get(1..semi).unwrap_or_default(); + let c = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => entity + .strip_prefix("#x") + .or_else(|| entity.strip_prefix("#X")) + .and_then(|h| u32::from_str_radix(h, 16).ok()) + .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok())) + .and_then(char::from_u32), + }; + match c { + Some(c) => { + out.push(c); + rest = after.get(semi + 1..).unwrap_or_default(); + } + None => { + out.push('&'); + rest = after.get(1..).unwrap_or_default(); + } + } + } + out.push_str(rest); + out } /// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is @@ -151,11 +294,24 @@ fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> { /// start and end of its value, and its quote character. Only matches inside a start tag. fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> { let mut from = 0; + // The last `<` and `>` before the match, found by one pass over the packet rather than by + // searching back from every match. + let (mut scanned, mut last_open, mut last_close) = (0, None, None); while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) { from = i + name.len(); let before = xmp.get(..i)?; + let gap = xmp.get(scanned..i)?; + #[cfg(test)] + crate::work(gap.len()); + if let Some(at) = gap.rfind('<') { + last_open = Some(scanned + at); + } + if let Some(at) = gap.rfind('>') { + last_close = Some(scanned + at); + } + scanned = i; // Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`). - if !before.ends_with(char::is_whitespace) || before.rfind('<') < before.rfind('>') || before.rfind('<').is_none() { + if !before.ends_with(char::is_whitespace) || last_open < last_close || last_open.is_none() { continue; } let rest = xmp.get(from..)?; @@ -185,6 +341,28 @@ mod tests { Old subject\n\ \n \n\n"; + #[test] + fn values_are_read_back_unescaped() { + assert_eq!(unescape("a & b <c> AB &bogus; � & end"), "a & b AB &bogus; � & end"); + assert_eq!(get_property(PACKET, "dc:title", Shape::LangAlt).as_deref(), Some("Untitled 1")); + assert_eq!(get_property(PACKET, "dc:creator", Shape::Seq).as_deref(), Some("Old Author")); + assert_eq!(get_property(PACKET, "pdf:Producer", Shape::Text).as_deref(), Some("Writer 1.0")); + assert_eq!(get_property(PACKET, "pdf:Keywords", Shape::Text), None); + // No x-default: the first language; empty and self-closed items are skipped. + let alt = "RapportReport"; + assert_eq!(get_property(alt, "dc:title", Shape::LangAlt).as_deref(), Some("Rapport")); + let seq = " Ada"; + assert_eq!(get_property(seq, "dc:creator", Shape::Seq).as_deref(), Some("Ada")); + // Unfinished markup reads as nothing rather than as a wrong value. + assert_eq!(get_property("Cut", "dc:title", Shape::LangAlt), None); + // An empty alternative or list, or an item holding markup, has no value: never the markup. + assert_eq!(get_property("", "dc:title", Shape::LangAlt), None); + assert_eq!(get_property("", "dc:title", Shape::LangAlt), None); + assert_eq!(get_property("", "dc:creator", Shape::Seq), None); + let nested = "Ada"; + assert_eq!(get_property(nested, "dc:creator", Shape::Seq).as_deref(), Some("Ada")); + } + #[test] fn element_values_are_replaced_in_place() { let out = set_property(PACKET, "dc:title", DC, Shape::LangAlt, "Guide & ").unwrap(); @@ -248,4 +426,18 @@ mod tests { } } } + + #[test] + fn attribute_search_scans_the_packet_once() { + // Thousands of attribute-like words in one text node: none is inside a tag. + let packet = format!("{}", " rdf:about".repeat(5_000)); + crate::take_work(); + assert_eq!(attribute_span(&packet, "rdf:about"), None); + let work = crate::take_work(); + assert!(work <= 2 * packet.len(), "{work} bytes scanned in a {}-byte packet", packet.len()); + // A real attribute after them is still found. + let packet = packet.replace("", ""); + let (_, s, e, q) = attribute_span(&packet, "rdf:about").unwrap(); + assert_eq!((packet.get(s..e), q), (Some("u"), '\'')); + } } diff --git a/crates/organize/tests/attachment_names_cleanup.rs b/crates/organize/tests/attachment_names_cleanup.rs new file mode 100644 index 0000000000..b24a4611bc --- /dev/null +++ b/crates/organize/tests/attachment_names_cleanup.rs @@ -0,0 +1,44 @@ +//! Deleting the last document attachment when the catalog's `/Names` is an indirect dictionary +//! (as some producers write it) must not leave an empty `/Names` behind. + +use std::sync::Arc; + +use pdfcraft_cos::{Document, Object}; +use pdfcraft_organize::{add_attachment, attachment_keys, delete_attachment}; + +/// One page, and a catalog whose `/Names` is the indirect (empty) dictionary 4 0 R. +fn indirect_names_pdf() -> Vec { + let objs: [&[u8]; 4] = [ + b"<< /Type /Catalog /Pages 2 0 R /Names 4 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>", + b"<< >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn deleting_the_last_attachment_removes_an_indirect_empty_names_dictionary() { + let mut doc = Document::open(Arc::new(indirect_names_pdf())).unwrap(); + let key = add_attachment(&mut doc, "notes.txt", b"hello", None, None, None).unwrap(); + assert_eq!(attachment_keys(&doc), ["notes.txt"]); + delete_attachment(&mut doc, &key).unwrap(); + assert!(attachment_keys(&doc).is_empty()); + let root = doc.root().unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert!(catalog.get(b"Names").is_none(), "the catalog keeps no empty /Names: {:?}", catalog.get(b"Names").map(|n| matches!(n, Object::Ref(_)))); +} diff --git a/crates/platform/README.md b/crates/platform/README.md index 6b23752b5f..fea358e508 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -1,7 +1,11 @@ # pdfcraft-platform -Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It uses only the standard library and has no UI dependencies. +Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It has no UI dependencies; macOS filesystem calls use the safe `rustix` wrapper. `staging` creates the temporary file an atomic save writes before renaming it over the target. The file is opened with `create_new`, so anything already at the name (a file, a hard link, a symbolic link even when dangling, a folder) is skipped and never written through. On Windows a folder at the name fails with "access denied" rather than "already exists", and is skipped only when something is actually there. Names carry an unpredictable 64-bit suffix from `staging_suffixes` (the standard library's keyed hash, currently SipHash-1-3, keyed from the operating system's random source), which stops a name being planted in advance but is not a CSPRNG; fixed suffixes are only for tests. At most 16 names are tried, and then the save is refused. The target's name is cut to 128 bytes on a character boundary so the staging name fits every system's limit. Each caller keeps its own name form (`.{name}.{suffix}.pdfcraft-tmp` for automation, `.{name}.pdfcraft-{suffix}.tmp` for the desktop save). Writing, closing the file before the rename, renaming and removing the staging file on failure stay with the caller. Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and refusal when no name is left. The planted-file, link, folder and failed-rename tests stay with each caller, where they exercise the whole save. + +On macOS, `write_atomically_with` copies the existing regular destination's accessible extended attributes to the open staging file after writing the data and before flushing and renaming. Finder tags, comments, binary values and empty attributes therefore survive both incremental and full saves (#2658). Save As retains attributes of an existing destination; a new destination does not inherit metadata from the source PDF. Missing files and filesystems without xattr support keep working. The metadata read does not follow the destination's symlink or block on a FIFO. macOS manages the new file's `com.apple.provenance`; it is not copied. Kernel-hidden compression attributes are not requested. + +Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions, the Windows hidden and system attributes (the staging file is created with them, so a hidden file is never published visible and a plain one never hidden, #4593) and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved. diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index fee0a16336..4316051352 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -31,16 +31,17 @@ pub fn create_staging( create_staging_as(dir, name, form, suffixes, None) } -/// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable -/// and writable by its owner only and then given `keep`, so its mode is never more open than that -/// of the file it will replace, not even while it is still empty (a handle opened then would keep -/// reading what is written later). Only the mode is copied: the owner and group are the saver's. +/// [`create_staging`], giving the new file the mode and group of the file `keep` describes on +/// Unix. It is created readable and writable by its owner only and then given them, so its mode +/// is never more open than that of the file it will replace, not even while it is still empty (a +/// handle opened then would keep reading what is written later). The owner is the saver's. On +/// Windows it is created with that file's [`KEPT_ATTRIBUTES`]. fn create_staging_as( dir: &Path, name: &str, form: StagingName, suffixes: impl IntoIterator, - keep: Option<&std::fs::Permissions>, + keep: Option<&std::fs::Metadata>, ) -> std::io::Result<(PathBuf, std::fs::File)> { // At most 128 bytes of the target's name, cut between characters, so the staging name fits // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. @@ -63,12 +64,19 @@ fn create_staging_as( use std::os::unix::fs::OpenOptionsExt as _; options.mode(0o600); } + // Given as the file is created, so a hidden target's staging file is never visible. + #[cfg(windows)] + if let Some(keep) = keep { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + options.attributes(keep.file_attributes() & KEPT_ATTRIBUTES); + } match options.open(&tmp) { Ok(file) => { + #[cfg(unix)] if let Some(keep) = keep { // A volume that can't change modes (FAT, some network shares) leaves the file // owner-only: stricter than the file it replaces, never looser. - let _ = file.set_permissions(keep.clone()); + let _ = file.set_permissions(kept_permissions(&file, keep)); } return Ok((tmp, file)); } @@ -78,9 +86,33 @@ fn create_staging_as( Err(e) => return Err(e), } } + #[cfg(not(any(unix, windows)))] + let _ = keep; Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken")) } +/// The Windows attributes a replaced file passes on to its staging file (#4593): +/// `FILE_ATTRIBUTE_HIDDEN` and `FILE_ATTRIBUTE_SYSTEM`, the two that say who should see the +/// file. A read-only file is refused before anything is staged, and the archive bit is the +/// system's to set on a changed file; the other attributes describe how the volume stores it. +#[cfg(windows)] +const KEPT_ATTRIBUTES: u32 = 0x2 | 0x4; + +/// The permissions a staging file gets to replace the file `keep` describes. The mode's group bits +/// are meant for that file's group, so the staging file joins it first. Where it can't (the saver +/// isn't a member), group and others get nothing: someone the group bits shut out would +/// otherwise read the file as one of the others. +#[cfg(unix)] +fn kept_permissions(file: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let mut mode = keep.permissions(); + let joined = file.metadata().is_ok_and(|m| m.gid() == keep.gid()) || std::os::unix::fs::fchown(file, None, Some(keep.gid())).is_ok(); + if !joined { + mode.set_mode(mode.mode() & !0o077); + } + mode +} + /// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the /// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3), /// keyed from the operating system's random source. That is all a staging name needs, but it is @@ -98,9 +130,12 @@ pub fn staging_suffixes() -> impl Iterator { /// and the target is untouched until the new file is complete. The target's folder must exist; /// a folder at the target is refused by the rename. Flushing is the staging file's own /// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file -/// that is replaced keeps its permissions, so a private file stays private; a new file gets the -/// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where +/// that is replaced keeps its permissions and group, so a private file stays private; a new file gets the +/// usual mode for a new file. On Windows a replaced file stays hidden or system if it was, and +/// plain if it was plain. On Windows and macOS a replaced file keeps its creation date, where /// the volume stores one. +/// On macOS, accessible extended attributes of an existing regular destination are copied +/// before flushing; a copy failure refuses the save rather than losing metadata (#2658). pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> { write_atomically_with(path, bytes, form, staging_suffixes(), std::fs::File::sync_all) } @@ -123,11 +158,15 @@ pub fn write_atomically_with( return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the file is read-only; save a copy with Save As")); } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); - // A file that is replaced keeps its permissions (#1790). Unix modes only: Windows is as before. + let name = path.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("save"); + // A file that is replaced keeps its permissions (#1790) and group on Unix, and its hidden and + // system attributes on Windows (#4593). A save replaces a symbolic link, not the file it + // points to, so on Windows the attributes are the link's own. #[cfg(unix)] - let keep = std::fs::metadata(path).ok().map(|m| m.permissions()); - #[cfg(not(unix))] + let keep = std::fs::metadata(path).ok(); + #[cfg(windows)] + let keep = std::fs::symlink_metadata(path).ok(); + #[cfg(not(any(unix, windows)))] let keep = None; // A saved file is the same document: it keeps its creation date (#2658). #[cfg(any(windows, target_os = "macos"))] @@ -138,6 +177,10 @@ pub fn write_atomically_with( { let mut file = file; file.write_all(bytes)?; + #[cfg(target_os = "macos")] + preserve_target_xattrs(path, &file).map_err(|e| { + std::io::Error::new(e.kind(), format!("could not preserve the file's extended attributes; save a copy with Save As: {e}")) + })?; #[cfg(any(windows, target_os = "macos"))] keep_created(&file, created); sync_staged(&file, sync_all)?; @@ -150,6 +193,73 @@ pub fn write_atomically_with( result } +/// Copy accessible macOS attributes to the staging descriptor before publication. The kernel +/// hides compression bookkeeping unless SHOWCOMPRESSION is requested; leave that option off. +/// Provenance is managed by macOS for the new file, not writable document metadata. +#[cfg(target_os = "macos")] +fn preserve_target_xattrs(path: &Path, staged: &std::fs::File) -> std::io::Result<()> { + use rustix::fs::{Mode, OFlags, XattrFlags, fgetxattr, flistxattr, fsetxattr, open}; + use rustix::io::Errno; + + // A new destination has nothing to retain. Replacing a symlink has always replaced the + // link, not its referent: do not open it to read metadata from a different file. NONBLOCK + // also prevents a FIFO at the destination from hanging the metadata read. + let source = match open(path, OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, Mode::empty()) { + Ok(fd) => std::fs::File::from(fd), + Err(Errno::NOENT | Errno::LOOP) => return Ok(()), + Err(e) => return Err(e.into()), + }; + if !source.metadata()?.is_file() { + return Ok(()); + } + + // Metadata is untrusted too: bound both allocations and total copying, including forks. + const MAX_NAMES_BYTES: usize = 64 * 1024; + const MAX_ATTRIBUTES: usize = 1024; + const MAX_VALUE_BYTES: usize = 16 * 1024 * 1024; + const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024; + let mut names = vec![0u8; MAX_NAMES_BYTES]; + let len = match flistxattr(&source, names.as_mut_slice()) { + Ok(len) => len, + Err(Errno::NOTSUP) => return Ok(()), // No attributes exist on this filesystem. + Err(Errno::RANGE) => return Err(std::io::Error::other("extended attribute name list exceeds 64 KiB")), + Err(e) => return Err(e.into()), + }; + let names = names.get(..len).ok_or_else(|| std::io::Error::other("extended attribute name list exceeds 64 KiB"))?; + let mut total = 0usize; + for (index, name) in names.split(|b| *b == 0).filter(|name| !name.is_empty()).enumerate() { + if index >= MAX_ATTRIBUTES { + return Err(std::io::Error::other("extended attribute count exceeds 1024")); + } + if name == b"com.apple.provenance" { + continue; + } + let name = std::ffi::CString::new(name).map_err(|_| std::io::Error::other("invalid extended attribute name"))?; + let size = fgetxattr(&source, &name, &mut [] as &mut [u8])?; + if size > MAX_VALUE_BYTES || size > MAX_TOTAL_BYTES.saturating_sub(total) { + return Err(std::io::Error::other("extended attributes exceed the 16 MiB per-value or 64 MiB total limit")); + } + // Resource forks may return a prefix instead of ERANGE on growth; check the size again. + let value = read_xattr_value(size, |buffer| Ok(fgetxattr(&source, &name, buffer)?))?; + fsetxattr(staged, &name, &value, XattrFlags::empty())?; + total = total.saturating_add(value.len()); + } + Ok(()) +} + +/// Read an already size-bounded attribute, refusing a partial result or a changed post-read size. +#[cfg(target_os = "macos")] +fn read_xattr_value(size: usize, mut read: impl FnMut(&mut [u8]) -> std::io::Result) -> std::io::Result> { + let mut value = vec![0u8; size.max(1)]; + let len = read(value.as_mut_slice())?; + let after = read(&mut [])?; + if len != size || after != size { + return Err(std::io::Error::other("extended attribute changed size while saving")); + } + value.truncate(len); + Ok(value) +} + /// Give the staging file the creation date of the file it replaces, where the system keeps one /// that a program can set (Windows, macOS). Best effort: a volume that can't store it leaves the /// new file's own date, as before. @@ -182,183 +292,4 @@ fn sync_staged(f: &std::fs::File, sync_all: impl FnOnce(&std::fs::File) -> std:: } #[cfg(test)] -mod tests { - use super::*; - - /// A fresh, empty folder for one test. - fn test_dir(test: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("pdfcraft-platform-staging-{}-{test}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn file_name(p: &Path) -> String { - p.file_name().unwrap().to_string_lossy().into_owned() - } - - /// The callers' tests plant files at these exact names; if a name changed, those tests would - /// plant where nothing writes and pass without testing anything. - #[test] - fn each_form_keeps_its_exact_name() { - let dir = test_dir("forms"); - let (tools, _) = create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, [0xab]).unwrap(); - assert_eq!(file_name(&tools), ".out.pdf.00000000000000ab.pdfcraft-tmp"); - let (desktop, _) = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, [0xab]).unwrap(); - assert_eq!(file_name(&desktop), ".out.pdf.pdfcraft-00000000000000ab.tmp"); - assert_eq!(std::fs::read(&tools).unwrap(), b"", "a new, empty file"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_long_name_is_cut_to_128_bytes_between_characters() { - let dir = test_dir("long"); - // One byte, then four-byte characters: the 128-byte limit falls inside the 32nd one. - let name = format!("a{}.pdf", "\u{1F600}".repeat(40)); - let (tmp, _) = create_staging(&dir, &name, StagingName::SuffixThenTag, [1]).unwrap(); - assert_eq!(file_name(&tmp), format!(".a{}.0000000000000001.pdfcraft-tmp", "\u{1F600}".repeat(31))); - // One-byte characters: exactly 128 of them are kept. - let (tmp, _) = create_staging(&dir, &"x".repeat(200), StagingName::SuffixThenTag, [2]).unwrap(); - assert_eq!(file_name(&tmp), format!(".{}.0000000000000002.pdfcraft-tmp", "x".repeat(128))); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would - /// replace it) as on Windows (where the rename fails). - #[test] - fn a_read_only_target_is_refused_and_kept() { - let dir = test_dir("readonly"); - let target = dir.join("out.pdf"); - std::fs::write(&target, "OLD").unwrap(); - let mut perms = std::fs::metadata(&target).unwrap().permissions(); - perms.set_readonly(true); - std::fs::set_permissions(&target, perms.clone()).unwrap(); - let staged = std::cell::Cell::new(false); - let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { - staged.set(true); - Ok(()) - }) - .unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); - assert!(!staged.get(), "nothing was staged"); - assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); - assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); - #[allow(clippy::permissions_set_readonly_false)] - perms.set_readonly(false); - std::fs::set_permissions(&target, perms).unwrap(); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn running_out_of_suffixes_refuses_and_creates_nothing() { - let dir = test_dir("empty"); - let e = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, []).unwrap_err(); - assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); - assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn only_the_first_sixteen_names_are_tried() { - let dir = test_dir("cap"); - let name = |s: u64| dir.join(format!(".out.pdf.{s:016x}.pdfcraft-tmp")); - for s in 1..=STAGING_ATTEMPTS as u64 { - std::fs::write(name(s), "PLANTED").unwrap(); - } - assert!(create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, 1..).is_err()); - assert!(!name(STAGING_ATTEMPTS as u64 + 1).exists(), "a 17th name was tried"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// `write_atomically`: the target holds the new bytes only once they are complete; a refused - /// flush leaves the old file and removes the staging file; a missing folder writes nothing. - #[test] - fn an_atomic_write_replaces_the_target_only_when_complete() { - let dir = test_dir("write"); - let target = dir.join("out.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - let names = |dir: &Path| { - let mut v: Vec = std::fs::read_dir(dir).unwrap().map(|e| file_name(&e.unwrap().path())).collect(); - v.sort(); - v - }; - // The staging file is written and flushed before the target changes. - let mut seen_old = false; - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { - seen_old = std::fs::read(&target).unwrap() == b"OLD" && f.metadata().unwrap().len() == 3; - Ok(()) - }) - .unwrap(); - assert!(seen_old, "flushed before publication, with the target still the old file"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(names(&dir), ["out.pdf"], "no staging file is left"); - // A refused flush keeps the target and cleans up. - let e = write_atomically_with(&target, b"NEWER", StagingName::TagThenSuffix, [8], |_| Err(std::io::Error::other("refused"))).unwrap_err(); - assert!(e.to_string().contains("refused")); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(names(&dir), ["out.pdf"]); - // A folder that does not exist: nothing is written anywhere. - assert!(write_atomically(&dir.join("missing").join("out.pdf"), b"X", StagingName::SuffixThenTag).is_err()); - assert_eq!(names(&dir), ["out.pdf"]); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1790: a replaced file keeps its permissions, and its staging file already has them while - /// it is written; a new file gets the same mode as any other new file. - #[cfg(unix)] - #[test] - fn a_replaced_file_keeps_its_permissions() { - use std::os::unix::fs::PermissionsExt as _; - let dir = test_dir("modes"); - let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; - let target = dir.join("out.pdf"); - for keep in [0o600, 0o640, 0o664] { - std::fs::write(&target, b"OLD").unwrap(); - std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); - let mut staged = 0; - write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { - staged = f.metadata()?.permissions().mode() & 0o777; - Ok(()) - }) - .unwrap(); - assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); - assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - } - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); - let plain = dir.join("plain"); - std::fs::write(&plain, b"").unwrap(); - assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #2658: a save replaces the file, but the document is the same one: it keeps its creation - /// date where the system lets a program set it. - #[cfg(any(windows, target_os = "macos"))] - #[test] - fn a_replaced_file_keeps_its_creation_date() { - #[cfg(target_os = "macos")] - use std::os::macos::fs::FileTimesExt as _; - #[cfg(windows)] - use std::os::windows::fs::FileTimesExt as _; - let dir = test_dir("created"); - let target = dir.join("doc.pdf"); - std::fs::write(&target, b"OLD").unwrap(); - // 2020-01-02 03:04:05 UTC. - let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); - let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); - file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap(); - drop(file); - assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set"); - write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap(); - assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); - assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date"); - // A new file gets a date of its own. - let fresh = dir.join("new.pdf"); - write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap(); - assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/platform/src/staging/tests.rs b/crates/platform/src/staging/tests.rs new file mode 100644 index 0000000000..b5552a2c7b --- /dev/null +++ b/crates/platform/src/staging/tests.rs @@ -0,0 +1,450 @@ +use super::*; + +/// A fresh, empty folder for one test. +fn test_dir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-platform-staging-{}-{test}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn file_name(p: &Path) -> String { + p.file_name().unwrap().to_string_lossy().into_owned() +} + +/// The callers' tests plant files at these exact names; if a name changed, those tests would +/// plant where nothing writes and pass without testing anything. +#[test] +fn each_form_keeps_its_exact_name() { + let dir = test_dir("forms"); + let (tools, _) = create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, [0xab]).unwrap(); + assert_eq!(file_name(&tools), ".out.pdf.00000000000000ab.pdfcraft-tmp"); + let (desktop, _) = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, [0xab]).unwrap(); + assert_eq!(file_name(&desktop), ".out.pdf.pdfcraft-00000000000000ab.tmp"); + assert_eq!(std::fs::read(&tools).unwrap(), b"", "a new, empty file"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_long_name_is_cut_to_128_bytes_between_characters() { + let dir = test_dir("long"); + // One byte, then four-byte characters: the 128-byte limit falls inside the 32nd one. + let name = format!("a{}.pdf", "\u{1F600}".repeat(40)); + let (tmp, _) = create_staging(&dir, &name, StagingName::SuffixThenTag, [1]).unwrap(); + assert_eq!(file_name(&tmp), format!(".a{}.0000000000000001.pdfcraft-tmp", "\u{1F600}".repeat(31))); + // One-byte characters: exactly 128 of them are kept. + let (tmp, _) = create_staging(&dir, &"x".repeat(200), StagingName::SuffixThenTag, [2]).unwrap(); + assert_eq!(file_name(&tmp), format!(".{}.0000000000000002.pdfcraft-tmp", "x".repeat(128))); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would +/// replace it) as on Windows (where the rename fails). +#[test] +fn a_read_only_target_is_refused_and_kept() { + let dir = test_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let staged = std::cell::Cell::new(false); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { + staged.set(true); + Ok(()) + }) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); + assert!(!staged.get(), "nothing was staged"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn running_out_of_suffixes_refuses_and_creates_nothing() { + let dir = test_dir("empty"); + let e = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, []).unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn only_the_first_sixteen_names_are_tried() { + let dir = test_dir("cap"); + let name = |s: u64| dir.join(format!(".out.pdf.{s:016x}.pdfcraft-tmp")); + for s in 1..=STAGING_ATTEMPTS as u64 { + std::fs::write(name(s), "PLANTED").unwrap(); + } + assert!(create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, 1..).is_err()); + assert!(!name(STAGING_ATTEMPTS as u64 + 1).exists(), "a 17th name was tried"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `write_atomically`: the target holds the new bytes only once they are complete; a refused +/// flush leaves the old file and removes the staging file; a missing folder writes nothing. +#[test] +fn an_atomic_write_replaces_the_target_only_when_complete() { + let dir = test_dir("write"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let names = |dir: &Path| { + let mut v: Vec = std::fs::read_dir(dir).unwrap().map(|e| file_name(&e.unwrap().path())).collect(); + v.sort(); + v + }; + // The staging file is written and flushed before the target changes. + let mut seen_old = false; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + seen_old = std::fs::read(&target).unwrap() == b"OLD" && f.metadata().unwrap().len() == 3; + Ok(()) + }) + .unwrap(); + assert!(seen_old, "flushed before publication, with the target still the old file"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(names(&dir), ["out.pdf"], "no staging file is left"); + // A refused flush keeps the target and cleans up. + let e = write_atomically_with(&target, b"NEWER", StagingName::TagThenSuffix, [8], |_| Err(std::io::Error::other("refused"))).unwrap_err(); + assert!(e.to_string().contains("refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(names(&dir), ["out.pdf"]); + // A folder that does not exist: nothing is written anywhere. + assert!(write_atomically(&dir.join("missing").join("out.pdf"), b"X", StagingName::SuffixThenTag).is_err()); + assert_eq!(names(&dir), ["out.pdf"]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(target_os = "macos")] +#[test] +fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() { + use rustix::fs::{XattrFlags, fgetxattr, setxattr}; + use std::os::macos::fs::FileTimesExt as _; + let dir = test_dir("xattrs"); + let target = dir.join("out.pdf"); + let attributes: &[(&str, &[u8])] = &[ + ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), + ("com.pdfcraft.test.binary", b"\0\xff\x80original\0"), + ("com.pdfcraft.test.empty", b""), + ]; + std::fs::write(&target, b"OLD").unwrap(); + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap(); + assert_eq!(original.metadata().unwrap().created().unwrap(), created); + drop(original); + // Both desktop and automation name forms, including a second save of the replacement. + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + write_atomically_with(&target, b"NEW", form, [7], |staged| { + assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush"); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}"); + } + Ok(()) + }) + .unwrap(); + let saved = std::fs::File::open(&target).unwrap(); + assert_eq!(saved.metadata().unwrap().created().unwrap(), created); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value); + } + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_failed_flush_keeps_the_original_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-flush"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap(); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err(); + assert!(e.to_string().contains("flush refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let mut value = [0u8; 64]; + let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap(); + assert_eq!(&value[..len], b"original"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Model an HFS-style partial read while using real macOS metadata and the atomic writer. +/// The shared read validator must reject growth before the staging file can be published. +#[cfg(target_os = "macos")] +#[test] +fn a_growing_resource_fork_refuses_publication_and_keeps_original() { + use rustix::fs::{XattrFlags, fgetxattr, fsetxattr, getxattr, setxattr}; + let dir = test_dir("xattrs-growing-fork"); + let target = dir.join("out.pdf"); + let name = "com.apple.ResourceFork"; + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + std::fs::write(&target, b"ORIGINAL PDF").unwrap(); + setxattr(&target, name, b"ABC", XattrFlags::empty()).unwrap(); + let source = std::fs::File::open(&target).unwrap(); + let result = write_atomically_with(&target, b"REPLACEMENT PDF", form, [23], |staged| { + let size = fgetxattr(&source, name, &mut [] as &mut [u8])?; + let value = read_xattr_value(size, |buffer| { + if buffer.is_empty() { + return Ok(fgetxattr(&source, name, buffer)?); + } + // HFS can return the prefix that fits instead of ERANGE. APFS need not + // have that behaviour: model the kernel response, not a timing race. + setxattr(&target, name, b"ABCD", XattrFlags::empty())?; + buffer.get_mut(..3).unwrap().copy_from_slice(b"ABC"); + Ok(3) + })?; + fsetxattr(staged, name, &value, XattrFlags::empty())?; + Ok(()) + }); + let error = result.expect_err("a short prefix of the grown fork must not be published"); + assert!(error.to_string().contains("changed size"), "{error}"); + assert_eq!(std::fs::read(&target).unwrap(), b"ORIGINAL PDF"); + let mut bytes = [0u8; 8]; + let len = getxattr(&target, name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"ABCD", "the original's grown fork must remain intact"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "staging files are removed on refusal"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Stable empty/binary values are accepted; short reads, growth and failed rechecks are refused. +#[cfg(target_os = "macos")] +#[test] +fn attribute_reads_require_a_complete_value_and_stable_size() { + for bytes in [&b""[..], &b"\0\xffB"[..]] { + let value = read_xattr_value(bytes.len(), |buffer| { + if !buffer.is_empty() { + buffer.get_mut(..bytes.len()).unwrap().copy_from_slice(bytes); + } + Ok(bytes.len()) + }) + .unwrap(); + assert_eq!(value, bytes); + } + for (before, read_len, after) in [(3, 2, 3), (3, 3, 4), (0, 1, 1), (3, 2, 2)] { + let result = read_xattr_value(before, |buffer| Ok(if buffer.is_empty() { after } else { read_len })); + assert!(result.unwrap_err().to_string().contains("changed size")); + } + let result = read_xattr_value(3, |buffer| { + if buffer.is_empty() { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "size recheck refused")) } else { Ok(3) } + }); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); +} + +#[cfg(target_os = "macos")] +#[test] +fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-limit"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // A resource fork can exceed the ordinary attribute limit on macOS. No fixture file + // is committed; only the OS metadata of this temporary synthetic file holds the bytes. + let fork = vec![42u8; 16 * 1024 * 1024 + 1]; + setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap(); + let mut flushed = false; + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| { + flushed = true; + Ok(()) + }) + .unwrap_err(); + assert!(e.to_string().contains("extended attributes"), "{e}"); + assert!(e.to_string().contains("Save As"), "{e}"); + assert!(!flushed, "metadata failure must stop before flush and rename"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap(); + assert_eq!(size, fork.len()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-symlink"); + let outside = test_dir("xattrs-outside").join("private.pdf"); + std::fs::write(&outside, b"PRIVATE").unwrap(); + setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap(); + let target = dir.join("out.pdf"); + std::os::unix::fs::symlink(&outside, &target).unwrap(); + write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert!(!target.symlink_metadata().unwrap().file_type().is_symlink()); + let mut bytes = [0u8; 64]; + assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"private metadata"); + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); +} + +/// #1790: a replaced file keeps its permissions, and its staging file already has them while +/// it is written; a new file gets the same mode as any other new file. +#[cfg(unix)] +#[test] +fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let dir = test_dir("modes"); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let target = dir.join("out.pdf"); + for keep in [0o600, 0o640, 0o664] { + std::fs::write(&target, b"OLD").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); + let mut staged = 0; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { + staged = f.metadata()?.permissions().mode() & 0o777; + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); + assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + let plain = dir.join("plain"); + std::fs::write(&plain, b"").unwrap(); + assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A replaced file keeps its group as well as its mode, so group permissions never reach a +/// group that couldn't read the file before (the saver's own group, by default). +#[cfg(target_os = "linux")] +#[test] +fn a_replaced_file_keeps_its_group() { + use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; + let dir = test_dir("group"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + let created = std::fs::metadata(&target).unwrap().gid(); + let status = std::fs::read_to_string("/proc/self/status").unwrap(); + let groups = status.lines().find_map(|l| l.strip_prefix("Groups:")).unwrap_or_default(); + let Some(other) = groups.split_whitespace().filter_map(|g| g.parse::().ok()).find(|&g| g != created) else { + eprintln!("skipped: the test needs a second group"); + return; + }; + std::os::unix::fs::chown(&target, None, Some(other)).unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o640)).unwrap(); + let mut staged = (0, 0); + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| { + let m = f.metadata()?; + staged = (m.gid(), m.permissions().mode() & 0o777); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, (other, 0o640), "the staging file has the target's group before it is flushed"); + let m = std::fs::metadata(&target).unwrap(); + assert_eq!((m.gid(), m.permissions().mode() & 0o777), (other, 0o640)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #2658: a save replaces the file, but the document is the same one: it keeps its creation +/// date where the system lets a program set it. +#[cfg(any(windows, target_os = "macos"))] +#[test] +fn a_replaced_file_keeps_its_creation_date() { + #[cfg(target_os = "macos")] + use std::os::macos::fs::FileTimesExt as _; + #[cfg(windows)] + use std::os::windows::fs::FileTimesExt as _; + let dir = test_dir("created"); + let target = dir.join("doc.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // 2020-01-02 03:04:05 UTC. + let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap(); + drop(file); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set"); + write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date"); + // A new file gets a date of its own. + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); + let _ = std::fs::remove_dir_all(&dir); +} + +/// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and +/// its staging file already is while it is written; a plain file stays plain, though the +/// staging name starts with a dot; a new file gets no attribute from anywhere. +#[cfg(windows)] +#[test] +fn a_replaced_file_keeps_its_hidden_and_system_attributes() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + const SYSTEM: u32 = 0x4; + let dir = test_dir("attributes"); + let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM); + for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() { + let target = dir.join(format!("doc{i}.pdf")); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}"); + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + let mut staged = u32::MAX; + write_atomically_with(&target, b"NEW", form, [i as u64], |f| { + staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed"); + assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A save replaces a symbolic link, not the file it points to, so the saved file takes the +/// link's own attributes: a plain link to a hidden file saves as a plain file, and the file +/// it pointed to is left as it was. +#[cfg(windows)] +#[test] +fn a_replaced_link_keeps_its_own_attributes_not_its_targets() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + let dir = test_dir("link-attributes"); + let referent = dir.join("referent.pdf"); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + let link = dir.join("link.pdf"); + if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) { + eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible"); + write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap(); + let saved = std::fs::symlink_metadata(&link).unwrap(); + assert!(!saved.file_type().is_symlink(), "the save replaced the link"); + assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is"); + assert_eq!(std::fs::read(&link).unwrap(), b"NEW"); + assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched"); + assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/preflight/Cargo.toml b/crates/preflight/Cargo.toml index b79575fc0b..99a72d9b6c 100644 --- a/crates/preflight/Cargo.toml +++ b/crates/preflight/Cargo.toml @@ -14,3 +14,7 @@ workspace = true pdfcraft-content = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } +roxmltree = "0.21" + +[dev-dependencies] +flate2 = { workspace = true } diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index a4d90d874b..1df39ebf05 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -165,7 +165,7 @@ fn metadata(doc: &Document) -> Option { let cat = catalog(doc); let m = doc.resolve(cat.get(b"Metadata")?); match &*m { - Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + Object::Stream(s) => s.decoded_within(xmp::MAX_XMP_BYTES).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), _ => None, } } @@ -174,10 +174,10 @@ fn metadata(doc: &Document) -> Option { pub fn declared(doc: &Document) -> Declared { let mut d = Declared::default(); if let Some(x) = metadata(doc) { - let part = xmp::value(&x, "pdfaid:part").and_then(|p| p.parse().ok()); - let conf = xmp::value(&x, "pdfaid:conformance").unwrap_or_default(); + let part = xmp::identifier(&x, xmp::PDFA_ID, "part").and_then(|p| p.parse().ok()); + let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance").unwrap_or_default(); d.pdfa = part.map(|p| (p, conf.to_uppercase())); - d.pdfua = xmp::value(&x, "pdfuaid:part").and_then(|p| p.parse().ok()); + d.pdfua = xmp::identifier(&x, xmp::PDFUA_ID, "part").and_then(|p| p.parse().ok()); } let cat = catalog(doc); if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) { @@ -214,6 +214,9 @@ fn has_pdfa_intent(doc: &Document) -> bool { pdfa_intent_profile(doc).is_some() } +/// Largest output-intent profile decoded to read its header; real ICC profiles stay well under it. +const MAX_ICC_PROFILE: usize = 16 << 20; + /// The PDF/A output intent's destination profile: `Some(n)` with the profile's number of colour /// components (`/N`, else the ICC header's data colour space; `None` when neither says), or /// `None` when the document has no PDF/A output intent. @@ -229,7 +232,7 @@ fn pdfa_intent_profile(doc: &Document) -> Option> { let Object::Stream(s) = &*profile else { return Some(None) }; Some(s.dict.int(b"N").or_else(|| { // ICC.1 header: the data colour space is bytes 16–19. - match s.decoded().ok()?.get(16..20)? { + match s.decoded_within(MAX_ICC_PROFILE).ok()?.get(16..20)? { b"GRAY" => Some(1), b"RGB " => Some(3), b"CMYK" => Some(4), @@ -339,8 +342,17 @@ fn is_content(d: &Dict) -> bool { /// The device colour a content stream sets. It is read operator by operator, so the bytes of a /// string, a comment or an inline image are never taken for an operator, and an operator right /// before a delimiter (`1 k/GS0 gs`) is still found. -fn content_colour(data: &[u8], used: &mut DeviceColour) { - for op in pdfcraft_content::parse(data).ops { +/// +/// Operators are read one at a time and dropped, so a stream of millions of tiny operators never +/// becomes millions of parsed ones in memory, and reading stops once all three spaces are set. +/// Returns how many operators it read. +fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize { + let mut read = 0; + for op in pdfcraft_content::ops(data) { + if used.gray && used.rgb && used.cmyk { + break; + } + read += 1; match op.op.as_slice() { b"g" | b"G" => used.gray = true, b"rg" | b"RG" => used.rgb = true, @@ -366,6 +378,7 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) { _ => {} } } + read } /// Whether any content or object uses a device colour space. @@ -400,12 +413,18 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) && (referenced.contains(r) || is_content(&s.dict)) && let Ok(data) = s.decoded() { - content_colour(&data, &mut used); + let _ = content_colour(&data, &mut used); } } used } +#[cfg(test)] +thread_local! { + /// Page `/Annots` entries read while matching annotations to their pages. + static ANNOT_ENTRIES_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Check `doc` against `level`. pub fn verify(doc: &Document, level: Level) -> Vec { let mut out = Vec::new(); @@ -423,8 +442,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec { match metadata(doc) { None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true), Some(x) => { - let part = xmp::value(&x, "pdfaid:part"); - let conf = xmp::value(&x, "pdfaid:conformance"); + let part = xmp::identifier(&x, xmp::PDFA_ID, "part"); + let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance"); if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") { issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true); } @@ -476,113 +495,182 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } // Pages: annotations and page actions. let pages = pdfcraft_model::pages(doc); - let page_of = |r: ObjRef| { - pages.iter().position(|p| { - p.dict - .get(b"Annots") - .map(|a| doc.resolve(a)) - .and_then(|a| a.as_array().cloned()) - .unwrap_or_default() - .iter() - .any(|x| x.as_ref() == Some(r)) - }) - }; + // Each annotation's page: the first page that lists it. + let mut annot_pages: std::collections::HashMap = std::collections::HashMap::new(); + for (i, p) in pages.iter().enumerate() { + if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) + && let Some(a) = a.as_array() + { + #[cfg(test)] + ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len())); + for r in a.iter().filter_map(Object::as_ref) { + annot_pages.entry(r).or_insert(i); + } + } + } + let page_of = |r: ObjRef| annot_pages.get(&r).copied(); for (i, p) in pages.iter().enumerate() { if p.dict.get(b"AA").is_some() { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } - for (r, o) in &objs { - let Some(d) = dict_of(o) else { continue }; - let ty = d.name(b"Type"); - let sub = d.name(b"Subtype"); - // Actions anywhere (structure elements also have /S: their role). - if let Some(s) = d.name(b"S") - && FORBIDDEN_ACTIONS.contains(&s) - && is_action(d) - { - issue("6.5.1", "A {} action isn't allowed", Some(String::from_utf8_lossy(s).into_owned()), None, true); - } - // Annotations. - if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { - let page = page_of(*r); - let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default(); - if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); - continue; - } - let f = d.int(b"F").unwrap_or(0); - if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) { - issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); - } - let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some()); - if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) { - let zero = d.get(b"Rect").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).is_some_and(|a| { - let v: Vec = a.iter().filter_map(|n| doc.resolve(n).as_f64()).collect(); - v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6 - }); - if !zero { - issue("6.3.3", "A {} annotation has no appearance stream", Some(name.clone()), page, false); - } - } - if d.get(b"AA").is_some() { - issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); - } - } - // Form fields with additional actions (non-widget fields). - if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") { - issue("6.5.2", "A form field has additional actions", None, None, true); - } - // Fonts. - if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() { - check_font(doc, d, &mut issue); - } - // Streams. - if let Object::Stream(s) = &**o { - let filters: Vec> = match s.dict.get(b"Filter").map(|f| doc.resolve(f)) { - Some(f) => match &*f { - Object::Name(n) => vec![n.clone()], - Object::Array(a) => a.iter().filter_map(|x| x.as_name().map(<[u8]>::to_vec)).collect(), - _ => Vec::new(), - }, - None => Vec::new(), - }; - if filters.iter().any(|f| f == b"LZWDecode") { - issue("6.1.7.2", "A stream uses LZW compression", None, None, false); - } - if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() { - issue("6.1.7.1", "A stream refers to an external file", None, None, false); - } - if sub == Some(b"Image") { - if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) { - issue("6.2.8", "An image asks to be interpolated", None, None, true); - } - if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() { - issue("6.2.8", "An image has alternates or OPI information", None, None, true); - } - } - if sub == Some(b"PS") { - issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); - } - } - // Embedded files. - if ty == Some(b"Filespec") && d.get(b"EF").is_some() { - match level { - Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), - Level::A3b => { - if d.get(b"AFRelationship").is_none() { - issue("6.8", "An embedded file has no AFRelationship", None, None, true); - } - } - } - } - } + check_objects(doc, level, &objs, &page_of, &mut issue); // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); out } +fn check_objects( + doc: &Document, + level: Level, + objects: &[(ObjRef, std::sync::Arc)], + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + for (r, o) in objects { + let Some(d) = dict_of(o) else { continue }; + check_one_object(doc, level, *r, d, o, page_of, issue); + } +} + +fn check_one_object( + doc: &Document, + level: Level, + reference: ObjRef, + dict: &Dict, + object: &Object, + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let ty = dict.name(b"Type"); + let subtype = dict.name(b"Subtype"); + check_actions(doc, dict, issue); + if is_annotation(dict, ty, subtype) && check_annotation(doc, dict, subtype, page_of(reference), issue) { + // A forbidden annotation stops every later check for its object, as the scan did before + // it was split into helpers: the rest of its own checks and the form field, font, stream + // and embedded file below all came after that `continue`. + return; + } + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + issue("6.5.2", "A form field has additional actions", None, None, true); + } + if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + check_font(doc, dict, issue); + } + if let Object::Stream(stream) = object { + check_stream(doc, stream, subtype, issue); + } + if ty == Some(b"Filespec") && dict.get(b"EF").is_some() { + check_embedded_file(level, dict, issue); + } +} + +fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + let own = (dict.get(b"S").is_some() && is_action(dict)).then_some(dict); + let direct = [b"A".as_slice(), b"OpenAction"].map(|key| dict.get(key).and_then(|action| action.as_dict())); + for action in [own].into_iter().chain(direct).flatten() { + if let Some(name) = chain_forbidden(doc, action) { + issue("6.5.1", "A {} action isn't allowed", Some(name), None, true); + } + } +} + +fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { + ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +} + +fn check_annotation( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) -> bool { + let name = subtype.map(|bytes| String::from_utf8_lossy(bytes).into_owned()).unwrap_or_default(); + if subtype.is_some_and(|value| FORBIDDEN_ANNOTS.contains(&value)) { + issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); + return true; + } + let flags = dict.int(b"F").unwrap_or(0); + if subtype != Some(b"Popup") && (flags & 4 == 0 || flags & (1 | 2 | 32) != 0) { + issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); + } + check_annotation_appearance(doc, dict, subtype, &name, page, issue); + if dict.get(b"AA").is_some() { + issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); + } + false +} + +fn check_annotation_appearance( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + name: &str, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let has_appearance = dict + .get(b"AP") + .map(|appearance| doc.resolve(appearance)) + .and_then(|appearance| appearance.as_dict().cloned()) + .is_some_and(|appearance| appearance.get(b"N").is_some()); + if has_appearance || matches!(subtype, Some(b"Popup") | Some(b"Link")) { + return; + } + let zero_area = dict.get(b"Rect").map(|rect| doc.resolve(rect)).and_then(|rect| rect.as_array().cloned()).is_some_and(|values| { + let values: Vec = values.iter().filter_map(|value| doc.resolve(value).as_f64()).collect(); + values.len() == 4 && (values[2] - values[0]).abs() < 1e-6 && (values[3] - values[1]).abs() < 1e-6 + }); + if !zero_area { + issue("6.3.3", "A {} annotation has no appearance stream", Some(name.to_owned()), page, false); + } +} + +fn check_stream( + doc: &Document, + stream: &pdfcraft_cos::Stream, + subtype: Option<&[u8]>, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { + Some(filter) => match &*filter { + Object::Name(name) => vec![name.clone()], + Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + if filters.iter().any(|filter| filter == b"LZWDecode") { + issue("6.1.7.2", "A stream uses LZW compression", None, None, false); + } + if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { + issue("6.1.7.1", "A stream refers to an external file", None, None, false); + } + if subtype == Some(b"Image") { + if matches!(stream.dict.get(b"Interpolate"), Some(Object::Bool(true))) { + issue("6.2.8", "An image asks to be interpolated", None, None, true); + } + if stream.dict.get(b"Alternates").is_some() || stream.dict.get(b"OPI").is_some() { + issue("6.2.8", "An image has alternates or OPI information", None, None, true); + } + } + if subtype == Some(b"PS") { + issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); + } +} + +fn check_embedded_file(level: Level, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + match level { + Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), + Level::A3b if dict.get(b"AFRelationship").is_none() => { + issue("6.8", "An embedded file has no AFRelationship", None, None, true); + } + Level::A3b => {} + } +} + fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { let sub = d.name(b"Subtype"); if sub == Some(b"Type3") { @@ -633,10 +721,62 @@ fn is_action(d: &Dict) -> bool { } } -/// Whether `o` is (or refers to) a forbidden action, or to nothing. +/// How many actions one action and the `/Next` actions written inside it may hold before the +/// whole chain counts as forbidden, since it can't be checked. +const MAX_ACTION_CHAIN: usize = 1024; + +/// The first forbidden action type in the action `a` or in the actions written directly in its +/// `/Next` (ISO 32000-2 12.6.2: a dictionary or an array of them, which may be an object of its +/// own). An action `/Next` refers to is an object of its own, checked and removed as one. +fn chain_forbidden(doc: &Document, a: &Dict) -> Option { + let mut read = 0; + let mut seen = std::collections::HashSet::new(); + // Actions written in an indirect `/Next` array, copied out of it. + let mut held: Vec = Vec::new(); + if let Some(s) = walk_chain(doc, a, &mut read, &mut seen, &mut held) { + return Some(s); + } + while let Some(d) = held.pop() { + if let Some(s) = walk_chain(doc, &d, &mut read, &mut seen, &mut held) { + return Some(s); + } + } + None +} + +/// [`chain_forbidden`] for the actions reachable from `a` through direct `/Next` entries. The +/// actions of each indirect `/Next` array (each read once) go to `held`. +fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collections::HashSet, held: &mut Vec) -> Option { + let mut stack = vec![a]; + while let Some(d) = stack.pop() { + *read += 1; + if *read > MAX_ACTION_CHAIN { + return Some("too deeply chained".into()); + } + if let Some(s) = d.name(b"S") + && FORBIDDEN_ACTIONS.contains(&s) + { + return Some(String::from_utf8_lossy(s).into_owned()); + } + match d.get(b"Next") { + Some(Object::Dict(next)) => stack.push(next), + Some(Object::Array(list)) => stack.extend(list.iter().filter_map(Object::as_dict)), + Some(Object::Ref(r)) if seen.insert(*r) => { + if let Object::Array(list) = &*doc.get(*r) { + // More than the chain limit can't be read anyway. + held.extend(list.iter().filter_map(Object::as_dict).take(MAX_ACTION_CHAIN).cloned()); + } + } + _ => {} + } + } + None +} + +/// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing. fn forbidden(doc: &Document, o: &Object) -> bool { match &*doc.resolve(o) { - Object::Dict(a) => a.name(b"S").is_some_and(|s| FORBIDDEN_ACTIONS.contains(&s)), + Object::Dict(a) => chain_forbidden(doc, a).is_some(), Object::Null => true, _ => false, } @@ -782,14 +922,20 @@ pub fn convert(doc: &mut Document, level: Level) -> Result Document { + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "") + } + let attribute = r#""#; + let element = r#"2B"#; + for xml in [attribute, element] { + let doc = with_xmp(xml); + assert_eq!(declared(&doc).pdfa, Some((2, "B".into()))); + assert!(!verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4")); + } + assert_eq!(declared(&with_xmp(attribute)).pdfua, Some(1)); + let wrong = r#""#; + let doc = with_xmp(wrong); + assert_eq!(declared(&doc).pdfa, None); + assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4")); + let nested = format!("{}{}", "".repeat(1100), "".repeat(1100)); + assert_eq!(declared(&with_xmp(&nested)).pdfa, None); +} + +#[test] +fn oversized_xmp_is_refused_while_decoding() { + let xmp = " ".repeat(xmp::MAX_XMP_BYTES + 1); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + assert!(metadata(&doc).is_none()); + assert_eq!(declared(&doc).pdfa, None); + + use std::io::Write as _; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(xmp.as_bytes()).unwrap(); + let compressed = z.finish().unwrap(); + let hex: String = compressed.iter().map(|byte| format!("{byte:02X}")).collect(); + let data = format!("{hex}>"); + let stream = + format!("<< /Type /Metadata /Subtype /XML /Filter [/ASCIIHexDecode /FlateDecode] /Length {} >>\nstream\n{data}\nendstream", data.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + let cat = catalog(&doc); + let raw = doc.resolve(cat.get(b"Metadata").unwrap()); + let Object::Stream(stream) = &*raw else { panic!("fixture has no metadata stream") }; + assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress"); + assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding"); +} + +/// Content is read one operator at a time and no further than it needs: once gray, RGB and CMYK +/// are all set, the rest of the stream is not read. +#[test] +fn content_colour_reads_operators_one_at_a_time_and_stops_when_all_are_found() { + let mut data = b"0 g 0 0 0 rg 0 0 0 0 k\n".to_vec(); + data.extend(b"n\n".repeat(100_000)); + let mut used = DeviceColour::default(); + let read = content_colour(&data, &mut used); + assert!(used.gray && used.rgb && used.cmyk); + assert_eq!(read, 3); +} + +/// A forbidden action chained by `/Next` behind an allowed one, written directly inside its +/// parent, is reported and removed like any other. +#[test] +fn forbidden_actions_chained_behind_allowed_ones_are_found_and_removed() { + let js = "<< /S /JavaScript /JS (app.alert(1)) >>"; + let page = "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>"; + let pages = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>"; + let link = |a: &str| format!("<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R /F 4 /A {a} >>"); + let open_action = format!("<< /Type /Catalog /Pages 2 0 R /OpenAction << /S /GoTo /D [3 0 R /Fit] /Next {js} >> >>"); + let goto = format!("<< /Type /Action /S /GoTo /D [3 0 R /Fit] /Next [{js}] >>"); + let cases = [ + build(&[&open_action, pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] >>")], ""), + build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("5 0 R"), &goto], ""), + build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link(&format!("<< /S /GoTo /D [3 0 R /Fit] /Next {js} >>"))], ""), + // `/Next` refers to an array that holds the action directly. + build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] /Next 5 0 R >>"), &format!("[{js}]")], ""), + ]; + for (i, mut doc) in cases.into_iter().enumerate() { + let found = messages(&verify(&doc, Level::A2b)).contains(&"A JavaScript action isn't allowed"); + assert!(found, "case {i}: {:#?}", verify(&doc, Level::A2b)); + let r = convert(&mut doc, Level::A2b).unwrap(); + assert!(!r.remaining.iter().any(|x| x.clause == "6.5.1"), "case {i}: {:#?}", r.remaining); + let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }; + let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap(); + assert!(!bytes.windows(11).any(|w| w == b"/JavaScript"), "case {i}: {}", String::from_utf8_lossy(&bytes)); + } +} + +/// Each annotation's page is found from one pass over the pages' `/Annots`, not by searching +/// every page's list again for each annotation. +#[test] +fn annotation_pages_are_found_in_one_pass() { + let n = 2_000; + let annots: Vec = (0..n).map(|i| format!("{} 0 R", i + 4)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" ")); + let mut objs = + vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), page]; + objs.extend((0..n).map(|_| "<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R >>".to_string())); + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let doc = build(&refs, ""); + ANNOT_ENTRIES_READ.with(|c| c.set(0)); + let issues = verify(&doc, Level::A2b); + assert!(issues.iter().any(|i| i.clause == "6.3.2" && i.page == Some(0)), "{issues:#?}"); + let read = ANNOT_ENTRIES_READ.with(std::cell::Cell::get); + assert!(read <= 2 * n, "{read} entries read for {n} annotations"); +} + +#[test] +fn an_output_intent_profile_header_is_read_within_a_size_cap() { + let intent = "/OutputIntents [<< /S /GTS_PDFA1 /DestOutputProfile 2 0 R >>]"; + for (size, n) in [(128, Some(4)), (MAX_ICC_PROFILE + 1, None)] { + let mut doc = build(&[&format!("<< /Type /Catalog {intent} >>"), "null"], ""); + let mut profile = vec![0; size]; + profile[16..20].copy_from_slice(b"CMYK"); + doc.set(pdfcraft_cos::ObjRef::new(2, 0), Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &profile))); + assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile"); + } +} diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs index 3057213d1a..7a54d70a1b 100644 --- a/crates/preflight/src/xmp.rs +++ b/crates/preflight/src/xmp.rs @@ -3,6 +3,116 @@ use pdfcraft_cos::Document; +/// The largest XMP packet read; a larger one is treated as missing. +pub(crate) const MAX_XMP_BYTES: usize = 8 * 1024 * 1024; +/// Namespace URIs of the PDF/A and PDF/UA identification schemas. +pub(crate) const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/"; +pub(crate) const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/"; + +/// Read an identification property by its expanded XML name, not its arbitrary prefix. A packet +/// that is not well-formed XML (an undeclared prefix, an illegal character) or is too deep or wide +/// to parse safely is read by the conventional prefix (`pdfaid:`, `pdfuaid:`) instead, as before. +pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option { + if xmp.len() > MAX_XMP_BYTES { + return None; + } + match parsed_identifier(xmp, namespace, local) { + Ok(found) => found, + Err(()) => { + let prefix = match namespace { + PDFA_ID => "pdfaid", + PDFUA_ID => "pdfuaid", + _ => return None, + }; + value(xmp, &format!("{prefix}:{local}")) + } + } +} + +/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits. +fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result, ()> { + if !xml_limits_ok(xmp) { + return Err(()); + } + let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000, ..Default::default() }; + let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?; + for node in doc.descendants().filter(roxmltree::Node::is_element) { + if let Some(value) = node.attribute((namespace, local)) { + return Ok(Some(value.trim().to_owned())); + } + if node.tag_name().namespace() == Some(namespace) && node.tag_name().name() == local { + return Ok(node.text().map(|value| value.trim().to_owned())); + } + } + Ok(None) +} + +/// Reject deep XML and start tags with too many attributes before roxmltree checks duplicates. +/// Each unquoted `=` in a valid start tag introduces an attribute, including namespace bindings. +/// Every `b[i]` and `hay[p..]` below is guarded by `i < b.len()` / `p < hay.len()`. +fn xml_limits_ok(text: &str) -> bool { + fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { + (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) + } + let b = text.as_bytes(); + let (mut i, mut depth, mut total_attributes) = (0usize, 0usize, 0usize); + while i < b.len() { + if b[i] != b'<' { + i += 1; + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 2); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else { + depth += 1; + if depth > 1024 { + return false; + } + // Skip the rest of the start tag, over any quoted attribute value, and remember its last + // significant byte: a tag that ends in `/` closes itself, so it is one level deep while it + // is read rather than a level left open. + i += 1; + let mut quote = 0u8; + let mut last = 0u8; + let mut attributes = 0usize; + while i < b.len() { + let c = b[i]; + if quote != 0 { + if c == quote { + quote = 0; + } + } else if c == b'"' || c == b'\'' { + quote = c; + } else if c == b'>' { + i += 1; + break; + } else if c == b'=' { + attributes += 1; + total_attributes += 1; + if attributes > 128 || total_attributes > 4096 { + return false; + } + last = c; + } else if !c.is_ascii_whitespace() { + last = c; + } + i += 1; + } + if last == b'/' { + depth -= 1; + } + } + } + true +} + /// The value of a simple XMP property, written either as an attribute (`pdfaid:part="2"`) or /// as an element (`2`, or inside an `rdf:Alt`/`rdf:Seq`). pub fn value(xmp: &str, key: &str) -> Option { @@ -41,7 +151,7 @@ fn esc(s: &str) -> String { /// A PDF date (`D:YYYYMMDDHHmmSSOHH'mm'`) as an XMP (ISO 8601) date. pub fn iso_date(pdf: &str) -> Option { let d = pdf.trim().trim_start_matches("D:"); - let digits: String = d.chars().take_while(|c| c.is_ascii_digit()).collect(); + let digits: String = d.chars().take_while(char::is_ascii_digit).collect(); if digits.len() < 4 { return None; } @@ -51,7 +161,7 @@ pub fn iso_date(pdf: &str) -> Option { let tz = match rest.chars().next() { Some('Z') | None => "Z".to_string(), Some(sign @ ('+' | '-')) => { - let t: String = rest[1..].chars().filter(|c| c.is_ascii_digit()).collect(); + let t: String = rest[1..].chars().filter(char::is_ascii_digit).collect(); format!("{sign}{}:{}", t.get(0..2).unwrap_or("00"), t.get(2..4).unwrap_or("00")) } _ => "Z".to_string(), @@ -133,3 +243,28 @@ mod tests { assert_eq!(iso_date("junk"), None); } } + +#[cfg(test)] +mod limit_tests { + use super::*; + + #[test] + fn attribute_and_namespace_budgets_bound_parser_work() { + let prefix = r#"(); + let at_limit = format!("{prefix}{}/>", attrs(126)); + assert_eq!(identifier(&at_limit, PDFA_ID, "part").as_deref(), Some("2")); + let over_limit = format!("{prefix}{}/>", attrs(127)); + assert_eq!(identifier(&over_limit, PDFA_ID, "part"), None); + let namespaces = (0..129).map(|i| format!("xmlns:n{i}=\"urn:{i}\" ")).collect::(); + assert_eq!(identifier(&format!(""), PDFA_ID, "part"), None); + let many_elements = format!("{}", r#""#.repeat(4097)); + assert!(!xml_limits_ok(&many_elements)); + // Not well-formed (the prefix is never declared): read by the conventional prefix, as before. + let undeclared = r#""#; + assert_eq!(identifier(undeclared, PDFA_ID, "part").as_deref(), Some("3")); + assert_eq!(identifier(undeclared, PDFA_ID, "conformance").as_deref(), Some("u")); + let over_bytes = format!("{}", " ".repeat(8 * 1024 * 1024)); + assert_eq!(identifier(&over_bytes, PDFA_ID, "part"), None); + } +} diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 3c9b1071d8..6ffa60b813 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -14,3 +14,6 @@ pdfcraft-cos = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } thiserror = { workspace = true } + +[dev-dependencies] +pdfcraft-render = { workspace = true } diff --git a/crates/print/README.md b/crates/print/README.md index c7d6351fa7..1379be2b42 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -27,10 +27,21 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() - **Poster**: tile scale, overlap shared by neighbouring tiles, cut marks. - **Comments & forms**: document, + markups, + stamps, or form fields only; annotations print only with their Print flag, drawn from their appearance streams (Algorithm 8.1). +- **Properties… on CUPS** reads the queue's PPD and its current defaults (`lpoptions -l`) off + the GUI thread, and gives `lpoptions` ten seconds: a CUPS that has stopped answering leaves + the PPD's own defaults in force instead of holding the dialog (#1818; `spool::output_within` + kills and reaps the command at the deadline). Each source page becomes a Form XObject (its content wrapped in q/Q, plus the printable annotations); sheets place them with a clip. The result is a fresh, unencrypted, garbage-collected file (callers check the print permission). +Annotation appearances receive unused XObject names, preserving existing page resources even when they use the same naming prefix. + +The GUI Print dialog remembers its last selected paper size across application sessions, +including when Print is cancelled. The preference stores a canonical name such as `A4`; +unrecognized saved values retain the current selection (US Letter on a fresh app). +This change does not alter persistence of other print preferences. +Headless `doc_print` defaults are unchanged. `spool` delegates discovery, driver options, preferences and submission to [`craft-print`](https://github.com/storytold/craft-libs/tree/main/crates/print). diff --git a/crates/print/src/error.rs b/crates/print/src/error.rs new file mode 100644 index 0000000000..4b1fe95f21 --- /dev/null +++ b/crates/print/src/error.rs @@ -0,0 +1,84 @@ +/// What can go wrong when printing. +/// +/// The three things a *person* can be wrong about — there is nothing to print, a page number past +/// the end, a range that is not a page — each have their own variant with the numbers apart from +/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest +/// are faults rather than mistakes and carry their text as it is. +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum PrintError { + #[error("there are no pages to print")] + NoPages, + /// A page number past the end of the document: the number asked for, and the page count. + #[error("page {0} is out of range (1–{1})")] + OutOfRange(usize, usize), + /// A range token that is neither a page number nor a page label. + #[error("{0:?} is not a page number or label")] + NotAPage(String), + #[error("{0}")] + Invalid(String), + #[error("{0}")] + Spool(String), + #[error(transparent)] + Cos(#[from] pdfcraft_cos::CosError), +} + +/// How an interface says one of our wordings in its own language, holes and all: given the English +/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. +pub type Words<'a> = &'a dyn Fn(&str) -> String; + +/// The [`Words`] that translate nothing: the wording as it is written here. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +/// What a person can get wrong about what to print, in English, with their holes still in them. +/// +/// An interface that reports these in another language translates these three, which is the whole +/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. +pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; + +impl PrintError { + /// This error as one sentence, said in the language `words` translates into. + /// + /// The wording is translated and the numbers go in afterwards, in a single pass, so what the + /// person typed cannot be read as a hole. With [`in_english`] this is the error's own + /// `Display`, which is what the command line and scripts report. + pub fn said(&self, words: Words) -> String { + match self { + PrintError::NoPages => words("there are no pages to print"), + PrintError::OutOfRange(n, count) => { + say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) + } + // The token goes in quoted, the way it is typed, as the error itself prints it. + PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), + fault => fault.to_string(), + } + } +} + +/// A wording with its holes filled. +/// +/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. +/// Values go in during a single pass, so a value with braces in it is never read as a hole. +fn say(wording: &str, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(wording.len() + 8); + let mut rest = wording; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // `find` counts from the brace and `}` is one byte, so this is a char boundary. + let Some(close) = from_brace.find('}') else { + // A brace with nothing closing it is just text. + out.push_str(from_brace); + return out; + }; + let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; + match values.iter().find(|(name, _)| *name == hole) { + Some((_, value)) => out.push_str(value), + None => out.push_str(hole), + } + rest = from_brace.get(close + 1..).unwrap_or_default(); + } + out.push_str(rest); + out +} diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs new file mode 100644 index 0000000000..348ae151ba --- /dev/null +++ b/crates/print/src/imposition.rs @@ -0,0 +1,349 @@ +use super::error::PrintError; +use super::settings::{Content, Settings}; +use super::sheets::{Sheet, layout}; +use super::{DecodeBudget, Dict, Document, Matrix, ObjRef, Object, SaveOptions, Stream, write_full}; + +// Model-derived transforms and clipping bounds need their full PDF decimal precision. +fn geometry_number(value: f64) -> String { + let mut bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() +} + +fn n(v: f64) -> String { + let s = format!("{v:.4}"); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} + +/// The page's content streams decoded against `budget` and joined with a space as the renderer +/// joins them; streams that don't decode are left out. `None` when the budget ran out, so part of +/// the content is missing. +pub(crate) fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { + let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; + let mut out = Vec::new(); + for data in budget.decode_contents(doc, contents) { + match data { + Ok(data) => { + out.extend_from_slice(&data); + out.push(b' '); + } + Err(_) if budget.remaining() == 0 => return None, + Err(_) => {} + } + } + Some(out) +} + +/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that +/// don't print are dropped, and with [`Content::FormFieldsOnly`] the page content too. For +/// printing pages as pictures, where a renderer draws each page as it stands. +pub fn keep_printed(doc: &mut Document, content: Content) { + for page in pdfcraft_model::pages(doc) { + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let kept: Vec = annots.into_iter().filter(|a| doc.resolve(a).as_dict().is_some_and(|d| prints(doc, d, content))).collect(); + // Fails only when the page object isn't a dictionary, and such a page draws nothing. + let _ = doc.update_dict(page.obj, |d| { + if content == Content::FormFieldsOnly { + d.remove(b"Contents"); + } + if d.contains(b"Annots") { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + }); + } +} + +/// The standard annotation types of ISO 32000-2 Table 171. +const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [ + b"Text", + b"Link", + b"FreeText", + b"Line", + b"Square", + b"Circle", + b"Polygon", + b"PolyLine", + b"Highlight", + b"Underline", + b"Squiggly", + b"StrikeOut", + b"Caret", + b"Stamp", + b"Ink", + b"Popup", + b"FileAttachment", + b"Sound", + b"Movie", + b"Screen", + b"Widget", + b"PrinterMark", + b"TrapNet", + b"Watermark", + b"3D", + b"Redact", + b"Projection", + b"RichMedia", +]; + +/// Does annotation `d` print under `content`? +pub(crate) fn prints(doc: &Document, d: &Dict, content: Content) -> bool { + const INVISIBLE: i64 = 1; + const HIDDEN: i64 = 2; + const PRINT: i64 = 4; + let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); + if flags & PRINT == 0 || flags & HIDDEN != 0 { + return false; + } + // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first. + let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s)); + let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); + match subtype { + b"Widget" => true, + b"Link" | b"Popup" => false, + b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, + // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table + // 171) is not printed even with its Print flag set. + _ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false, + _ => content == Content::DocumentAndMarkups, + } +} + +/// The normal appearance stream to draw for an annotation (honouring `/AS`). +pub(crate) fn appearance(doc: &Document, d: &Dict) -> Option { + let ap = doc.resolve(d.get(b"AP")?); + let normal = ap.as_dict()?.get(b"N")?.clone(); + match &*doc.resolve(&normal) { + Object::Stream(_) => normal.as_ref(), + Object::Dict(states) => { + let state = d.name(b"AS")?; + states.get(state)?.as_ref() + } + _ => None, + } +} + +/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. +pub(crate) fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { + let nums = + |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; + let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; + let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); + let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); + let (bw, bh) = (b[2] - b[0], b[3] - b[1]); + if bw <= 0.0 || bh <= 0.0 { + return None; + } + let a = Matrix([ + (rect[2] - rect[0]) / bw, + 0.0, + 0.0, + (rect[3] - rect[1]) / bh, + rect[0] - b[0] * (rect[2] - rect[0]) / bw, + rect[1] - b[1] * (rect[3] - rect[1]) / bh, + ]); + // The Do that draws the form applies its /Matrix itself, so only the placement goes here. + Some(a) +} + +/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). +/// `pages` are the document's pages, listed once by the caller: listing them for each page +/// printed would make printing quadratic in the page count. +pub(crate) fn page_form( + doc: &mut Document, + pages: &[pdfcraft_model::Page], + index: usize, + content: Content, + promoted: &mut std::collections::HashMap, +) -> Result { + let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?; + let crop = page.crop(doc); + let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut body = if content == Content::FormFieldsOnly { + Vec::new() + } else { + decoded_contents(doc, &page.dict, &mut DecodeBudget::page()) + .ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))? + }; + // Wrap the page content so a state it leaves behind can't affect the annotations. + if !body.is_empty() { + let mut wrapped = b"q\n".to_vec(); + wrapped.extend_from_slice(&body); + wrapped.extend_from_slice(b"\nQ\n"); + body = wrapped; + } + let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); + let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + let mut k = 0usize; + for a in annots { + let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; + if !prints(doc, &d, content) { + continue; + } + let rect: Vec = + d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + if rect.len() != 4 { + continue; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let Some(ap) = appearance(doc, &d) else { continue }; + let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); + let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; + let ap = if !form.contains(b"Subtype") + && let Object::Stream(stream) = &*doc.get(ap) + { + // Some existing annotation appearances omit Subtype, but Do dispatch needs a Form XObject. + // Keep the original intact: other document objects can share this appearance. + if let Some(&cached) = promoted.get(&ap) { + cached + } else { + let mut stream = stream.clone(); + stream.dict.set(b"Subtype".to_vec(), Object::name("Form")); + let copy = doc.add(Object::Stream(stream)); + promoted.insert(ap, copy); + copy + } + } else { + ap + }; + // Keep the page's own XObjects, including names previous print operations generated. + // Check the growing map so each appearance also keeps the name just assigned to it. + let name = loop { + let name = format!("PCPrA{k}"); + k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; + if !xobjects.contains(name.as_bytes()) { + break name; + } + }; + let [a0, b0, c0, d0, e0, f0] = m.0; + body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); + xobjects.set(name.into_bytes(), Object::Ref(ap)); + } + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"Resources".to_vec(), Object::Dict(res)); + if let Some(g) = page.dict.get(b"Group") { + d.set(b"Group".to_vec(), g.clone()); + } + Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) +} + +#[cfg(test)] +thread_local! { + /// Pages listed by [`source_pages`] on this thread. + pub(crate) static PAGES_LISTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The source document's pages, for printing. +fn source_pages(doc: &Document) -> Vec { + let pages = pdfcraft_model::pages(doc); + #[cfg(test)] + PAGES_LISTED.with(|c| c.set(c.get() + pages.len())); + pages +} + +/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a +/// printer or is the user's own copy); callers must check the print permission first. +pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { + let pages = source_pages(src); + let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); + let sheets = layout(&sizes, settings)?; + let mut doc = src.clone(); + // One form per source page used (a page may appear on several sheets: posters). + let mut forms: std::collections::HashMap = std::collections::HashMap::new(); + let mut promoted = std::collections::HashMap::new(); + let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); + let pages_root = doc.add(Object::Null); + let mut kids = Vec::new(); + for sheet in &sheets { + let mut c = String::new(); + let mut xo = Dict::new(); + for (i, pl) in sheet.placed.iter().enumerate() { + let form = match forms.get(&pl.page) { + Some(f) => *f, + None => { + let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?; + forms.insert(pl.page, f); + f + } + }; + let name = format!("P{i}"); + xo.set(name.clone().into_bytes(), Object::Ref(form)); + // Display space → sheet, then clip to the visible part, then user → display. + let [a, b, cc, d, e, f] = pl.matrix.0; + let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); + let [ua, ub, uc, ud, ue, uf] = to_display.0; + let [x0, y0, x1, y1] = pl.clip; + c.push_str(&format!( + "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", + geometry_number(a), + geometry_number(b), + geometry_number(cc), + geometry_number(d), + geometry_number(e), + geometry_number(f), + geometry_number(x0), + geometry_number(y0), + geometry_number(x1 - x0), + geometry_number(y1 - y0), + geometry_number(ua), + geometry_number(ub), + geometry_number(uc), + geometry_number(ud), + geometry_number(ue), + geometry_number(uf) + )); + } + if !sheet.borders.is_empty() || !sheet.lines.is_empty() { + c.push_str("q 0 G 0.5 w\n"); + for r in &sheet.borders { + c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); + } + for l in &sheet.lines { + c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); + } + c.push_str("Q\n"); + } + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages_root)); + p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + kids.push(Object::Ref(doc.add(Object::Dict(p)))); + } + let mut tree = Dict::new(); + tree.set(b"Type".to_vec(), Object::name("Pages")); + tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + tree.set(b"Kids".to_vec(), Object::Array(kids)); + doc.set(pages_root, Object::Dict(tree)); + let mut cat = Dict::new(); + cat.set(b"Type".to_vec(), Object::name("Catalog")); + cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); + // Keep the layers' configuration, or a layer that is off in the document would print: the + // pages' marked content still refers to its groups, and without this nothing says they are off. + let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); + if let Some(layers) = layers { + cat.set(b"OCProperties".to_vec(), layers); + } + let root = doc.add(Object::Dict(cat)); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + doc.trailer_mut().remove(b"Info"); + if doc.trailer().contains(b"Encrypt") { + doc.remove_encryption(); + } + Ok(write_full(&doc, &SaveOptions::default())?) +} + +/// Which source pages land on each sheet (for previews and the sheet count). +pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { + sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() +} diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 802d9595b6..067112b2e7 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -18,7 +18,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use pdfcraft_content::Matrix; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; pub mod range; pub mod spool; @@ -27,872 +27,19 @@ mod tests; pub use range::{Subset, select_listed, select_pages}; -/// What can go wrong when printing. -/// -/// The three things a *person* can be wrong about — there is nothing to print, a page number past -/// the end, a range that is not a page — each have their own variant with the numbers apart from -/// the words, so an interface can say them in its own language with [`PrintError::said`]. The rest -/// are faults rather than mistakes and carry their text as it is. -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum PrintError { - #[error("there are no pages to print")] - NoPages, - /// A page number past the end of the document: the number asked for, and the page count. - #[error("page {0} is out of range (1–{1})")] - OutOfRange(usize, usize), - /// A range token that is neither a page number nor a page label. - #[error("{0:?} is not a page number or label")] - NotAPage(String), - #[error("{0}")] - Invalid(String), - #[error("{0}")] - Spool(String), - #[error(transparent)] - Cos(#[from] pdfcraft_cos::CosError), -} +mod error; +mod imposition; +mod settings; +mod sheets; -/// How an interface says one of our wordings in its own language, holes and all: given the English -/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. -pub type Words<'a> = &'a dyn Fn(&str) -> String; - -/// The [`Words`] that translate nothing: the wording as it is written here. -pub fn in_english(wording: &str) -> String { - wording.to_owned() -} - -/// What a person can get wrong about what to print, in English, with their holes still in them. -/// -/// An interface that reports these in another language translates these three, which is the whole -/// reason they are gathered here; `wordings_are_said` keeps them in step with the errors. -pub const WORDINGS: &[&str] = &["there are no pages to print", "page {n} is out of range (1–{count})", "{page} is not a page number or label"]; - -impl PrintError { - /// This error as one sentence, said in the language `words` translates into. - /// - /// The wording is translated and the numbers go in afterwards, in a single pass, so what the - /// person typed cannot be read as a hole. With [`in_english`] this is the error's own - /// `Display`, which is what the command line and scripts report. - pub fn said(&self, words: Words) -> String { - match self { - PrintError::NoPages => words("there are no pages to print"), - PrintError::OutOfRange(n, count) => { - say(&words("page {n} is out of range (1–{count})"), &[("{n}", n.to_string()), ("{count}", count.to_string())]) - } - // The token goes in quoted, the way it is typed, as the error itself prints it. - PrintError::NotAPage(token) => say(&words("{page} is not a page number or label"), &[("{page}", format!("{token:?}"))]), - fault => fault.to_string(), - } - } -} - -/// A wording with its holes filled. -/// -/// A hole is a name in braces, such as `{n}`; what is not in `values` is left as it is written. -/// Values go in during a single pass, so a value with braces in it is never read as a hole. -fn say(wording: &str, values: &[(&str, String)]) -> String { - let mut out = String::with_capacity(wording.len() + 8); - let mut rest = wording; - while let Some(open) = rest.find('{') { - let (before, from_brace) = rest.split_at(open); - out.push_str(before); - // `find` counts from the brace and `}` is one byte, so this is a char boundary. - let Some(close) = from_brace.find('}') else { - // A brace with nothing closing it is just text. - out.push_str(from_brace); - return out; - }; - let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; - match values.iter().find(|(name, _)| *name == hole) { - Some((_, value)) => out.push_str(value), - None => out.push_str(hole), - } - rest = from_brace.get(close + 1..).unwrap_or_default(); - } - out.push_str(rest); - out -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Orientation { - #[default] - Auto, - Portrait, - Landscape, -} - -#[derive(Clone, Copy, Debug, PartialEq, Default)] -pub enum SizeMode { - #[default] - Fit, - Actual, - Shrink, - /// Percent. - Custom(f64), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageOrder { - #[default] - Horizontal, - HorizontalReversed, - Vertical, - VerticalReversed, - /// Single-sided sheets: cut into cell piles, then stack them in row-major order. - CutStack, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum BookletSubset { - #[default] - BothSides, - FrontOnly, - BackOnly, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Binding { - #[default] - Left, - Right, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Layout { - Size(SizeMode), - Multiple { - cols: usize, - rows: usize, - /// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid - /// with one cell left blank. - per_sheet: usize, - order: PageOrder, - border: bool, - auto_rotate: bool, - }, - Booklet { - subset: BookletSubset, - binding: Binding, - }, - /// Tile scale (percent), overlap (points), cut marks. - Poster { - scale: f64, - overlap: f64, - cut_marks: bool, - }, - /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads - /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. - Label { - flip: bool, - }, -} - -impl Default for Layout { - fn default() -> Self { - Layout::Size(SizeMode::Fit) - } -} - -impl Layout { - /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). - pub fn multiple(per_sheet: usize) -> Layout { - let (cols, rows) = match per_sheet { - 2 => (1, 2), - 4 => (2, 2), - 6 => (2, 3), - 9 => (3, 3), - 16 => (4, 4), - n => { - let c = (n as f64).sqrt().ceil().max(1.0) as usize; - (c, n.div_ceil(c).max(1)) - } - }; - Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } - } -} - -/// Acrobat's Comments & Forms choices. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum Content { - /// Page content and form fields. - Document, - /// Page content, form fields and every printable comment. - #[default] - DocumentAndMarkups, - /// Page content, form fields and stamps. - DocumentAndStamps, - /// Form fields only. - FormFieldsOnly, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Settings { - /// Source pages (0-based) in print order; see [`select_pages`]. - pub pages: Vec, - /// Paper (width, height) in points, portrait. - pub paper: (f64, f64), - pub orientation: Orientation, - pub layout: Layout, - pub content: Content, - /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its - /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a - /// missing entry keeps the whole page. - pub crops: Vec>, - /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this - /// resolution before it is placed on the paper. The engine does the rendering; `impose` is - /// then given the image-only document. - pub raster: Option, -} - -/// How pages are rendered when printing as image. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Raster { - /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). - pub dpi: f64, - /// Render the pictures in grey, so the print-ready PDF itself is grayscale. - pub grayscale: bool, -} - -impl Default for Raster { - fn default() -> Self { - Raster { dpi: 300.0, grayscale: false } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Crop { - pub rect: [f64; 4], - pub text_up: bool, -} - -impl Default for Settings { - fn default() -> Self { - Settings { - pages: Vec::new(), - paper: PAPERS[0].1, - orientation: Orientation::Auto, - layout: Layout::default(), - content: Content::default(), - raster: None, - crops: Vec::new(), - } - } -} - -/// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 20] = [ - ("US Letter", (612.0, 792.0)), - ("US Legal", (612.0, 1008.0)), - ("Tabloid", (792.0, 1224.0)), - ("A3", (841.89, 1190.55)), - ("A4", (595.28, 841.89)), - ("A5", (419.53, 595.28)), - ("A6", (297.64, 419.53)), - ("ARCH A (9 x 12 in)", (648.0, 864.0)), - ("ARCH B (12 x 18 in)", (864.0, 1296.0)), - ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), - ("ARCH D (24 x 36 in)", (1728.0, 2592.0)), - ("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)), - ("ARCH E (36 x 48 in)", (2592.0, 3456.0)), - ("ANSI C (17 x 22 in)", (1224.0, 1584.0)), - ("ANSI D (22 x 34 in)", (1584.0, 2448.0)), - ("ANSI E (34 x 44 in)", (2448.0, 3168.0)), - ("A2", (1190.55, 1683.78)), - ("A1", (1683.78, 2383.94)), - ("A0", (2383.94, 3370.39)), - ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), -]; - -/// `PAPERS` index of the 4 × 6 in shipping label. -pub const LABEL_PAPER: usize = 19; - -/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out -/// on rather than the queue's default (PPD keywords; `w288h432` is the usual 4×6 label). -pub fn cups_media(index: usize) -> Option<&'static str> { - match PAPERS.get(index)?.0 { - "US Letter" => Some("Letter"), - "US Legal" => Some("Legal"), - "Tabloid" => Some("Tabloid"), - "A3" => Some("A3"), - "A4" => Some("A4"), - "A5" => Some("A5"), - _ if index == LABEL_PAPER => Some("w288h432"), - _ => None, - } -} - -/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid -/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects -/// are followed a few levels deep. -pub fn text_reads_up(doc: &Document, index: usize) -> bool { - let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; - let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); - let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut score = 0i64; - text_direction(doc, &decoded_contents(doc, &page.dict), &res, to_display, 0, &mut score); - score > 0 -} - -fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64) { - let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); - for op in pdfcraft_content::parse(data).ops { - match op.op.as_slice() { - // Bounded like the renderer's graphics-state stack: deeper saves are ignored. - b"q" if stack.len() < 256 => stack.push(ctm), - b"Q" => ctm = stack.pop().unwrap_or(base), - b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), - b"BT" => tm = Matrix::IDENTITY, - b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), - b"Tj" | b"TJ" | b"'" | b"\"" => { - let [a, b, ..] = tm.then(&ctm).0; - *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); - } - b"Do" if depth < 4 => { - let Some(name) = op.name(0) else { continue }; - let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; - let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; - if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { - continue; - } - let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); - let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); - if let Ok(body) = form.decoded() { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score); - } - } - _ => {} - } - } -} - -/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed -/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. -pub const LABEL_MARGIN: f64 = 8.0; - -/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by -/// `quarter` quarter turns counter-clockwise (0–3). -fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { - let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); - let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; - let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); - let [a, b, c, d, e, f] = match quarter % 4 { - 0 => [s, 0.0, 0.0, s, x, y], - 1 => [0.0, s, -s, 0.0, x + pw, y], - 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], - _ => [0.0, -s, s, 0.0, x, y + ph], - }; - // Move the crop's corner to the origin first. - Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } -} - -/// Match a displayed PDF page to a standard sheet, independent of orientation. -/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. -pub fn matching_paper(size: (f64, f64)) -> Option { - if !size.0.is_finite() || !size.1.is_finite() { - return None; - } - let size = (size.0.min(size.1), size.0.max(size.1)); - PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) -} - -/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. -pub const MARGIN: f64 = 18.0; - -/// One page as placed on a sheet: the source page, the matrix from its *display space* to the -/// sheet, and the visible part of it in display space (clipping). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Placement { - pub page: usize, - pub matrix: Matrix, - pub clip: [f64; 4], -} - -/// One output sheet. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct Sheet { - pub size: (f64, f64), - pub placed: Vec, - /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. - pub borders: Vec<[f64; 4]>, - pub lines: Vec<[f64; 4]>, -} - -fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { - let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); - match o { - Orientation::Portrait => (w, h), - Orientation::Landscape => (h, w), - Orientation::Auto => { - if landscape_wanted { - (h, w) - } else { - (w, h) - } - } - } -} - -/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` -/// (centred), optionally rotated 90° (counter-clockwise). -fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { - let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); - let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; - let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); - let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; - Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } -} - -fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { - if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } -} - -/// Lay out the sheets for `settings` given each source page's display size. -pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { - let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); - if pages.is_empty() { - return Err(PrintError::NoPages); - } - let paper = settings.paper; - if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { - return Err(PrintError::Invalid("invalid paper size".into())); - } - let mut sheets = Vec::new(); - match settings.layout { - Layout::Size(mode) => { - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); - let s = match mode { - SizeMode::Fit => fit, - SizeMode::Actual => 1.0, - SizeMode::Shrink => fit.min(1.0), - SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, - SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), - }; - sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); - } - } - Layout::Label { flip } => { - let size = oriented(paper, settings.orientation, false); - let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; - for &p in &pages { - let d = sizes[p]; - let crop = settings.crops.get(p).copied().flatten(); - let rect = crop - .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) - .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) - .unwrap_or([0.0, 0.0, d.0, d.1]); - let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); - let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); - let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { - (false, _) => 0, - (true, true) => 3, - (true, false) => 1, - }; - if flip { - quarter = (quarter + 2) % 4; - } - let s = if turn { - fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) - } else { - fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) - }; - sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); - } - } - Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { - let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); - let Some(cells) = cells else { - return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - }; - // The grid can have more cells than the pages asked for on each sheet; the rest stay blank. - let per_sheet = per_sheet.min(cells); - if per_sheet == 0 { - return Err(PrintError::Invalid("invalid number of pages per sheet".into())); - } - // A grid with more columns than rows wants a landscape sheet, and the reverse. - let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; - let page_landscape = first.0 > first.1; - let (gc, gr, sheet_landscape) = if cols == rows { - (cols, rows, page_landscape) - } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { - // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. - (rows, cols, true) - } else { - (cols, rows, false) - }; - let size = oriented(paper, settings.orientation, sheet_landscape); - let gap = 6.0; - let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); - let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); - if cw <= 0.0 || ch <= 0.0 { - return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); - } - let sheet_count = pages.len().div_ceil(per_sheet); - for sheet_index in 0..sheet_count { - let mut sheet = Sheet { size, ..Sheet::default() }; - if order == PageOrder::CutStack { - // Marks align across every sheet, including unoccupied cells. Cut in the - // middle of each gutter; marks stay in the printable area, clear of cells. - for col in 1..gc { - let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; - sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); - } - for row in 1..gr { - let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; - sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); - } - } - for k in 0..per_sheet { - // A cell's pile gets consecutive pages. Missing pages leave blank cells, - // rather than shifting later piles left on the final sheets. - let index = if order == PageOrder::CutStack { - k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) - } else { - sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k)) - }; - let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; - - let (col, row) = match order { - PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), - PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), - PageOrder::Vertical => (k / gr, k % gr), - PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), - }; - let x0 = MARGIN + col as f64 * (cw + gap); - let y1 = size.1 - MARGIN - row as f64 * (ch + gap); - let cell = [x0, y1 - ch, x0 + cw, y1]; - let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; - if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { - return Err(PrintError::Invalid("invalid page size".into())); - } - let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; - let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); - if !s.is_finite() || s <= 0.0 { - return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); - } - let pl = place_in(p, d, cell, s, rotate); - if border { - let b = pl.matrix.bbox(pl.clip); - sheet.borders.push(b); - } - sheet.placed.push(pl); - } - sheets.push(sheet); - } - } - Layout::Booklet { subset, binding } => { - // Pad to a multiple of 4 with blank pages (None). - let n4 = pages.len().div_ceil(4) * 4; - let at = |i: usize| pages.get(i).copied(); - let size = oriented(paper, Orientation::Landscape, true); - let half = (size.0 - 2.0 * MARGIN) / 2.0; - let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; - for k in 0..n4 / 4 { - let front = (at(n4 - 1 - 2 * k), at(2 * k)); - let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); - let mut sides = Vec::new(); - if subset != BookletSubset::BackOnly { - sides.push(front); - } - if subset != BookletSubset::FrontOnly { - sides.push(back); - } - for (l, r) in sides { - let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; - let mut sheet = Sheet { size, ..Sheet::default() }; - for (p, cell) in [(l, cells[0]), (r, cells[1])] { - if let Some(p) = p { - let d = sizes[p]; - let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); - sheet.placed.push(place_in(p, d, cell, s, false)); - } - } - sheets.push(sheet); - } - } - } - Layout::Poster { scale, overlap, cut_marks } => { - if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { - return Err(PrintError::Invalid("invalid tile scale or overlap".into())); - } - let s = scale / 100.0; - for &p in &pages { - let d = sizes[p]; - let size = oriented(paper, settings.orientation, d.0 > d.1); - // Each tile shows `step` of the scaled page plus `overlap` shared with the next. - let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); - if overlap * 2.0 >= tw.min(th) { - return Err(PrintError::Invalid("the overlap is larger than the tile".into())); - } - let (sw, sh) = (d.0 * s, d.1 * s); - let (stepx, stepy) = (tw - overlap, th - overlap); - let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); - let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); - // The counts saturate at usize::MAX for a huge scale, so their product can overflow. - if nx.checked_mul(ny).is_none_or(|n| n > 1024) { - return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); - } - // Tiles from the top-left, row by row. - for ty in 0..ny { - for tx in 0..nx { - // The window into the scaled page (scaled display space, y up). - let wx0 = tx as f64 * stepx; - let wy1 = sh - ty as f64 * stepy; - let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); - let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; - let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; - if cut_marks { - let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); - for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { - let dx = if x == x0 { -1.0 } else { 1.0 }; - let dy = if y == y0 { -1.0 } else { 1.0 }; - sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); - sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); - } - } - sheets.push(sheet); - } - } - } - } - } - Ok(sheets) -} - -// Model-derived transforms and clipping bounds need their full PDF decimal precision. -fn geometry_number(value: f64) -> String { - let mut bytes = Vec::new(); - pdfcraft_cos::serialize(&Object::Real(value), &mut bytes); - String::from_utf8_lossy(&bytes).into_owned() -} - -fn n(v: f64) -> String { - let s = format!("{v:.4}"); - let s = s.trim_end_matches('0').trim_end_matches('.'); - if s == "-0" { "0".into() } else { s.into() } -} - -fn decoded_contents(doc: &Document, page: &Dict) -> Vec { - let list: Vec = match page.get(b"Contents") { - None => Vec::new(), - Some(c) => match &*doc.resolve(c) { - Object::Array(a) => a.clone(), - _ => vec![c.clone()], - }, - }; - let mut out = Vec::new(); - for o in list { - if let Object::Stream(s) = &*doc.resolve(&o) - && let Ok(d) = s.decoded() - { - out.extend_from_slice(&d); - out.push(b'\n'); - } - } - out -} - -/// Does annotation `d` print under `content`? -fn prints(doc: &Document, d: &Dict, content: Content) -> bool { - const HIDDEN: i64 = 2; - const PRINT: i64 = 4; - let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if flags & PRINT == 0 || flags & HIDDEN != 0 { - return false; - } - let subtype = d.name(b"Subtype").unwrap_or(b""); - match subtype { - b"Widget" => true, - b"Link" | b"Popup" => false, - b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, - _ => content == Content::DocumentAndMarkups, - } -} - -/// The normal appearance stream to draw for an annotation (honouring `/AS`). -fn appearance(doc: &Document, d: &Dict) -> Option { - let ap = doc.resolve(d.get(b"AP")?); - let normal = ap.as_dict()?.get(b"N")?.clone(); - match &*doc.resolve(&normal) { - Object::Stream(_) => normal.as_ref(), - Object::Dict(states) => { - let state = d.name(b"AS")?; - states.get(state)?.as_ref() - } - _ => None, - } -} - -/// Algorithm 8.1: the matrix placing an appearance form (BBox × Matrix) onto the annotation rect. -fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option { - let nums = - |k: &[u8]| -> Option> { Some(doc.resolve(form.get(k)?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()) }; - let bbox = nums(b"BBox").filter(|b| b.len() == 4)?; - let m = nums(b"Matrix").filter(|m| m.len() == 6).map(|m| Matrix([m[0], m[1], m[2], m[3], m[4], m[5]])).unwrap_or_default(); - let b = m.bbox([bbox[0], bbox[1], bbox[2], bbox[3]]); - let (bw, bh) = (b[2] - b[0], b[3] - b[1]); - if bw <= 0.0 || bh <= 0.0 { - return None; - } - let a = Matrix([ - (rect[2] - rect[0]) / bw, - 0.0, - 0.0, - (rect[3] - rect[1]) / bh, - rect[0] - b[0] * (rect[2] - rect[0]) / bw, - rect[1] - b[1] * (rect[3] - rect[1]) / bh, - ]); - // The Do that draws the form applies its /Matrix itself, so only the placement goes here. - Some(a) -} - -/// A Form XObject holding page `index` as it prints (user space; BBox = crop box). -fn page_form(doc: &mut Document, index: usize, content: Content) -> Result { - let page = pdfcraft_model::pages(doc).swap_remove(index); - let crop = page.crop(doc); - let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) }; - // Wrap the page content so a state it leaves behind can't affect the annotations. - if !body.is_empty() { - let mut wrapped = b"q\n".to_vec(); - wrapped.extend_from_slice(&body); - wrapped.extend_from_slice(b"\nQ\n"); - body = wrapped; - } - let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); - let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - let mut k = 0; - for a in annots { - let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; - if !prints(doc, &d, content) { - continue; - } - let rect: Vec = - d.get(b"Rect").and_then(|r| doc.resolve(r).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - if rect.len() != 4 { - continue; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let Some(ap) = appearance(doc, &d) else { continue }; - let form = doc.get(ap).as_dict().cloned().unwrap_or_default(); - let Some(m) = appearance_matrix(doc, &form, rect) else { continue }; - let name = format!("PCPrA{k}"); - k += 1; - let [a0, b0, c0, d0, e0, f0] = m.0; - body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); - xobjects.set(name.into_bytes(), Object::Ref(ap)); - } - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("XObject")); - d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"Resources".to_vec(), Object::Dict(res)); - if let Some(g) = page.dict.get(b"Group") { - d.set(b"Group".to_vec(), g.clone()); - } - Ok(doc.add(Object::Stream(Stream::flate(d, &body)))) -} - -/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a -/// printer or is the user's own copy); callers must check the print permission first. -pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError> { - let pages = pdfcraft_model::pages(src); - let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect(); - let sheets = layout(&sizes, settings)?; - let mut doc = src.clone(); - // One form per source page used (a page may appear on several sheets: posters). - let mut forms: std::collections::HashMap = std::collections::HashMap::new(); - let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); - let pages_root = doc.add(Object::Null); - let mut kids = Vec::new(); - for sheet in &sheets { - let mut c = String::new(); - let mut xo = Dict::new(); - for (i, pl) in sheet.placed.iter().enumerate() { - let form = match forms.get(&pl.page) { - Some(f) => *f, - None => { - let f = page_form(&mut doc, pl.page, settings.content)?; - forms.insert(pl.page, f); - f - } - }; - let name = format!("P{i}"); - xo.set(name.clone().into_bytes(), Object::Ref(form)); - // Display space → sheet, then clip to the visible part, then user → display. - let [a, b, cc, d, e, f] = pl.matrix.0; - let to_display = Matrix(views[pl.page]).invert().unwrap_or_default(); - let [ua, ub, uc, ud, ue, uf] = to_display.0; - let [x0, y0, x1, y1] = pl.clip; - c.push_str(&format!( - "q {} {} {} {} {} {} cm {} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do Q\n", - geometry_number(a), - geometry_number(b), - geometry_number(cc), - geometry_number(d), - geometry_number(e), - geometry_number(f), - geometry_number(x0), - geometry_number(y0), - geometry_number(x1 - x0), - geometry_number(y1 - y0), - geometry_number(ua), - geometry_number(ub), - geometry_number(uc), - geometry_number(ud), - geometry_number(ue), - geometry_number(uf) - )); - } - if !sheet.borders.is_empty() || !sheet.lines.is_empty() { - c.push_str("q 0 G 0.5 w\n"); - for r in &sheet.borders { - c.push_str(&format!("{} {} {} {} re S\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1]))); - } - for l in &sheet.lines { - c.push_str(&format!("{} {} m {} {} l S\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); - } - c.push_str("Q\n"); - } - let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), c.as_bytes()))); - let mut res = Dict::new(); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Page")); - p.set(b"Parent".to_vec(), Object::Ref(pages_root)); - p.set(b"MediaBox".to_vec(), Object::Array([0.0, 0.0, sheet.size.0, sheet.size.1].iter().map(|v| Object::Real(*v)).collect())); - p.set(b"Resources".to_vec(), Object::Dict(res)); - p.set(b"Contents".to_vec(), Object::Ref(contents)); - kids.push(Object::Ref(doc.add(Object::Dict(p)))); - } - let mut tree = Dict::new(); - tree.set(b"Type".to_vec(), Object::name("Pages")); - tree.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); - tree.set(b"Kids".to_vec(), Object::Array(kids)); - doc.set(pages_root, Object::Dict(tree)); - let mut cat = Dict::new(); - cat.set(b"Type".to_vec(), Object::name("Catalog")); - cat.set(b"Pages".to_vec(), Object::Ref(pages_root)); - // Keep the layers' configuration, or a layer that is off in the document would print: the - // pages' marked content still refers to its groups, and without this nothing says they are off. - let layers = src.trailer().get(b"Root").and_then(|r| src.resolve(r).as_dict().and_then(|root| root.get(b"OCProperties").cloned())); - if let Some(layers) = layers { - cat.set(b"OCProperties".to_vec(), layers); - } - let root = doc.add(Object::Dict(cat)); - doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); - doc.trailer_mut().remove(b"Info"); - if doc.trailer().contains(b"Encrypt") { - doc.remove_encryption(); - } - Ok(write_full(&doc, &SaveOptions::default())?) -} - -/// Which source pages land on each sheet (for previews and the sheet count). -pub fn sheet_pages(sheets: &[Sheet]) -> Vec> { - sheets.iter().map(|s| s.placed.iter().map(|p| p.page).collect()).collect() -} +pub use error::{PrintError, WORDINGS, Words, in_english}; +#[cfg(test)] +use imposition::{PAGES_LISTED, appearance, appearance_matrix, decoded_contents, page_form, prints}; +pub use imposition::{impose, keep_printed, sheet_pages}; +pub use settings::{ + Binding, BookletSubset, Content, Crop, LABEL_PAPER, Layout, Orientation, PAPERS, PageOrder, Raster, Settings, SizeMode, booklet_duplex, + cups_media, media_option, +}; +#[cfg(test)] +use sheets::text_direction; +pub use sheets::{LABEL_MARGIN, MARGIN, Placement, Sheet, layout, matching_paper, text_reads_up}; diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index 075261945b..f0d8939da6 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -45,8 +45,8 @@ fn hyphenated_label_range(part: &str, count: usize, labels: &[String]) -> Option } /// The pages to print (0-based, in print order). `range` is `None` for all pages; it may list -/// numbers and labels with `-` ranges (open-ended allowed: `5-`, `-3`). The subset counts the -/// selected pages (the first selected is "odd"), as Acrobat does. +/// numbers and labels with `-` ranges (open-ended allowed: `5-`, `-3`). The subset keeps the odd or +/// even pages of those chosen, by page number (see [`narrow`]). pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset: Subset, reverse: bool) -> Result, PrintError> { let mut pages = Vec::new(); match range.map(str::trim).filter(|r| !r.is_empty()) { @@ -70,7 +70,7 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset Some(_) if labels.iter().any(|l| l == part) => pages.push(page_of(part, count, labels)?), Some((a, b)) => { let from = if a.trim().is_empty() { 0 } else { page_of(a, count, labels)? }; - let to = if b.trim().is_empty() { count.saturating_sub(1) } else { page_of(b, count, labels)? }; + let to = if b.trim().is_empty() { count.checked_sub(1).ok_or(PrintError::NoPages)? } else { page_of(b, count, labels)? }; if from <= to { pages.extend(from..=to); } else { @@ -95,12 +95,17 @@ pub fn select_listed(count: usize, pages: &[usize], subset: Subset, reverse: boo narrow(pages.to_vec(), subset, reverse) } -/// Odd or even positions of the chosen pages, then the print order. +/// The odd or even pages of those chosen, by page number (the first page of the document is 1, +/// odd; `pages` are 0-based), then the print order. Acrobat counts the same way (#3984): "if you +/// set the range to 2, 7–10 and choose Even Pages Only, only pages 2, 8, and 10 print" (Adobe +/// Acrobat DC Help, last updated 11/28/2019, Options in the Print dialog box, p. 515; the same +/// example in Using Adobe Acrobat 9 Pro, p. 413). Counting places in the range instead printed +/// 7 and 9 there, and a range starting on an even page started its "even" pages on an odd one. fn narrow(pages: Vec, subset: Subset, reverse: bool) -> Result, PrintError> { let mut out: Vec = match subset { Subset::All => pages, - Subset::Odd => pages.into_iter().step_by(2).collect(), - Subset::Even => pages.into_iter().skip(1).step_by(2).collect(), + Subset::Odd => pages.into_iter().filter(|p| p % 2 == 0).collect(), + Subset::Even => pages.into_iter().filter(|p| p % 2 == 1).collect(), }; if reverse { out.reverse(); @@ -110,3 +115,18 @@ fn narrow(pages: Vec, subset: Subset, reverse: bool) -> Result } Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_ended_range_of_empty_document_is_an_error() { + assert!(select_pages(0, Some("-"), &[], Subset::All, false).is_err()); + } + + #[test] + fn open_ended_range_covers_all_pages() { + assert_eq!(select_pages(5, Some("-"), &[], Subset::All, false).unwrap(), vec![0, 1, 2, 3, 4]); + } +} diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs new file mode 100644 index 0000000000..0506eb5b18 --- /dev/null +++ b/crates/print/src/settings.rs @@ -0,0 +1,250 @@ +use super::spool; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Orientation { + #[default] + Auto, + Portrait, + Landscape, +} + +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum SizeMode { + #[default] + Fit, + Actual, + Shrink, + /// Percent. + Custom(f64), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrder { + #[default] + Horizontal, + HorizontalReversed, + Vertical, + VerticalReversed, + /// Single-sided sheets: cut into cell piles, then stack them in row-major order. + CutStack, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum BookletSubset { + #[default] + BothSides, + FrontOnly, + BackOnly, +} + +/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as +/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, +/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for +/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned +/// sideways; front side only or back side only is one pass of a manual duplex and prints +/// single-sided. +pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { + match subset { + BookletSubset::BothSides => spool::Duplex::ShortEdge, + BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Binding { + #[default] + Left, + Right, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Layout { + Size(SizeMode), + Multiple { + cols: usize, + rows: usize, + /// Source pages on each sheet, at most `cols × rows`: three per sheet uses a 2 × 2 grid + /// with one cell left blank. + per_sheet: usize, + order: PageOrder, + border: bool, + auto_rotate: bool, + }, + Booklet { + subset: BookletSubset, + binding: Binding, + }, + /// Tile scale (percent), overlap (points), cut marks. + Poster { + scale: f64, + overlap: f64, + cut_marks: bool, + }, + /// Shipping label: each page cut to its [`Settings::crops`] box, turned so its text reads + /// upright on the sheet and fitted with [`LABEL_MARGIN`]; `flip` turns it a further 180°. + Label { + flip: bool, + }, +} + +impl Default for Layout { + fn default() -> Self { + Layout::Size(SizeMode::Fit) + } +} + +impl Layout { + /// Acrobat's pages-per-sheet choices as a grid (columns × rows on a portrait sheet). + pub fn multiple(per_sheet: usize) -> Layout { + let (cols, rows) = match per_sheet { + 2 => (1, 2), + 4 => (2, 2), + 6 => (2, 3), + 9 => (3, 3), + 16 => (4, 4), + n => { + let c = (n as f64).sqrt().ceil().max(1.0) as usize; + (c, n.div_ceil(c).max(1)) + } + }; + Layout::Multiple { cols, rows, per_sheet: per_sheet.max(1), order: PageOrder::Horizontal, border: false, auto_rotate: true } + } +} + +/// Acrobat's Comments & Forms choices. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Content { + /// Page content and form fields. + Document, + /// Page content, form fields and every printable comment. + #[default] + DocumentAndMarkups, + /// Page content, form fields and stamps. + DocumentAndStamps, + /// Form fields only. + FormFieldsOnly, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + /// Source pages (0-based) in print order; see [`select_pages`]. + pub pages: Vec, + /// Paper (width, height) in points, portrait. + pub paper: (f64, f64), + pub orientation: Orientation, + pub layout: Layout, + pub content: Content, + /// For [`Layout::Label`], per source page: the area to keep (display space) and whether its + /// text runs bottom-to-top (so a quarter turn clockwise makes it upright). `None` or a + /// missing entry keeps the whole page. + pub crops: Vec>, + /// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this + /// resolution before it is placed on the paper. The engine does the rendering; `impose` is + /// then given the image-only document. + pub raster: Option, +} + +/// How pages are rendered when printing as image. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Raster { + /// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600). + pub dpi: f64, + /// Render the pictures in grey, so the print-ready PDF itself is grayscale. + pub grayscale: bool, +} + +impl Default for Raster { + fn default() -> Self { + Raster { dpi: 300.0, grayscale: false } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Crop { + pub rect: [f64; 4], + pub text_up: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + pages: Vec::new(), + paper: PAPERS[0].1, + orientation: Orientation::Auto, + layout: Layout::default(), + content: Content::default(), + raster: None, + crops: Vec::new(), + } + } +} + +/// Common paper sizes (points, portrait). +pub const PAPERS: [(&str, (f64, f64)); 21] = [ + ("US Letter", (612.0, 792.0)), + ("US Legal", (612.0, 1008.0)), + ("Tabloid", (792.0, 1224.0)), + ("A3", (841.89, 1190.55)), + ("A4", (595.28, 841.89)), + ("A5", (419.53, 595.28)), + ("A6", (297.64, 419.53)), + ("ARCH A (9 x 12 in)", (648.0, 864.0)), + ("ARCH B (12 x 18 in)", (864.0, 1296.0)), + ("ARCH C (18 x 24 in)", (1296.0, 1728.0)), + ("ARCH D (24 x 36 in)", (1728.0, 2592.0)), + ("ARCH E1 (30 x 42 in)", (2160.0, 3024.0)), + ("ARCH E (36 x 48 in)", (2592.0, 3456.0)), + ("ANSI C (17 x 22 in)", (1224.0, 1584.0)), + ("ANSI D (22 x 34 in)", (1584.0, 2448.0)), + ("ANSI E (34 x 44 in)", (2448.0, 3168.0)), + ("A2", (1190.55, 1683.78)), + ("A1", (1683.78, 2383.94)), + ("A0", (2383.94, 3370.39)), + ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), + ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), +]; + +/// `PAPERS` index of the 4 × 6 in shipping label. +pub const LABEL_PAPER: usize = 19; + +/// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out +/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes +/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). +/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the +/// spec and the IANA registry have them: a driverless printer may take only the names it +/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions +/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). +pub fn cups_media(index: usize) -> Option<&'static str> { + Some(match PAPERS.get(index)?.0 { + "US Letter" => "Letter", + "US Legal" => "Legal", + "Tabloid" => "Tabloid", + "A3" => "A3", + "A4" => "A4", + "A5" => "A5", + "A6" => "iso_a6_105x148mm", + "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", + "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", + "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", + "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", + "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", + "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", + "ANSI C (17 x 22 in)" => "na_c_17x22in", + "ANSI D (22 x 34 in)" => "na_d_22x34in", + "ANSI E (34 x 44 in)" => "na_e_34x44in", + "A2" => "iso_a2_420x594mm", + "A1" => "iso_a1_594x841mm", + "A0" => "iso_a0_841x1189mm", + "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + _ if index == LABEL_PAPER => "w288h432", + // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's + // paper stands, as it did for every size before #1583. + _ => return None, + }) +} + +/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print +/// dialog's jobs and `doc_print`'s alike. +pub fn media_option(index: usize) -> Option<(String, String)> { + cups_media(index).map(|m| ("media".to_string(), m.to_string())) +} diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs new file mode 100644 index 0000000000..061a6ed5c3 --- /dev/null +++ b/crates/print/src/sheets.rs @@ -0,0 +1,357 @@ +use super::error::PrintError; +use super::imposition::decoded_contents; +use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; +use super::{DecodeBudget, Dict, Document, Matrix, Object}; + +/// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid +/// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects +/// are followed a few levels deep. +pub fn text_reads_up(doc: &Document, index: usize) -> bool { + let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; + let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); + let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut score = 0i64; + let mut budget = DecodeBudget::page(); + // Content past the budget is left out: the answer is a guess from the text read so far. + let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default(); + text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget); + score > 0 +} + +pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { + let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); + for op in pdfcraft_content::parse(data).ops { + match op.op.as_slice() { + // Bounded like the renderer's graphics-state stack: deeper saves are ignored. + b"q" if stack.len() < 256 => stack.push(ctm), + b"Q" => ctm = stack.pop().unwrap_or(base), + b"cm" => ctm = Matrix::from_operands(&op.operands).unwrap_or_default().then(&ctm), + b"BT" => tm = Matrix::IDENTITY, + b"Tm" => tm = Matrix::from_operands(&op.operands).unwrap_or_default(), + b"Tj" | b"TJ" | b"'" | b"\"" => { + let [a, b, ..] = tm.then(&ctm).0; + *score += i64::from(b > a.abs()) - i64::from(-b > a.abs()); + } + b"Do" if depth < 4 => { + let Some(name) = op.name(0) else { continue }; + let Some(xobj) = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().and_then(|x| x.get(name).cloned())) else { continue }; + let Object::Stream(form) = &*doc.resolve(&xobj) else { continue }; + if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { + continue; + } + let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); + let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); + if !budget.enter_form() { + continue; + } + if let Ok(body) = budget.decode(form) { + text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); + } + } + _ => {} + } + } +} + +/// Margin kept around a label: thermal heads miss the very edge, and many 4×6 drivers feed +/// 100 × 150 mm (283 × 425 pt), a little short of the 288 × 432 pt sheet. +pub const LABEL_MARGIN: f64 = 8.0; + +/// Place `crop` (display space) of `page` centred in `cell`, scaled by `s` and turned by +/// `quarter` quarter turns counter-clockwise (0–3). +fn place_crop(page: usize, crop: [f64; 4], cell: [f64; 4], s: f64, quarter: u8) -> Placement { + let (w, h) = (crop[2] - crop[0], crop[3] - crop[1]); + let (pw, ph) = if quarter % 2 == 1 { (h * s, w * s) } else { (w * s, h * s) }; + let (x, y) = (cell[0] + (cell[2] - cell[0] - pw) / 2.0, cell[1] + (cell[3] - cell[1] - ph) / 2.0); + let [a, b, c, d, e, f] = match quarter % 4 { + 0 => [s, 0.0, 0.0, s, x, y], + 1 => [0.0, s, -s, 0.0, x + pw, y], + 2 => [-s, 0.0, 0.0, -s, x + pw, y + ph], + _ => [0.0, -s, s, 0.0, x, y + ph], + }; + // Move the crop's corner to the origin first. + Placement { page, matrix: Matrix([a, b, c, d, e - a * crop[0] - c * crop[1], f - b * crop[0] - d * crop[1]]), clip: crop } +} + +/// Match a displayed PDF page to a standard sheet, independent of orientation. +/// Half a point allows rounded metric PDF dimensions without confusing distinct sizes. +pub fn matching_paper(size: (f64, f64)) -> Option { + if !size.0.is_finite() || !size.1.is_finite() { + return None; + } + let size = (size.0.min(size.1), size.0.max(size.1)); + PAPERS.iter().position(|(_, p)| (p.0 - size.0).abs() <= 0.5 && (p.1 - size.1).abs() <= 0.5) +} + +/// The unprintable margin assumed around the sheet for Fit, Multiple and Booklet. +pub const MARGIN: f64 = 18.0; + +/// One page as placed on a sheet: the source page, the matrix from its *display space* to the +/// sheet, and the visible part of it in display space (clipping). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub page: usize, + pub matrix: Matrix, + pub clip: [f64; 4], +} + +/// One output sheet. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Sheet { + pub size: (f64, f64), + pub placed: Vec, + /// Page borders (Multiple) and cut marks (Poster), in sheet space: rectangles and lines. + pub borders: Vec<[f64; 4]>, + pub lines: Vec<[f64; 4]>, +} + +fn oriented(paper: (f64, f64), o: Orientation, landscape_wanted: bool) -> (f64, f64) { + let (w, h) = (paper.0.min(paper.1), paper.0.max(paper.1)); + match o { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + Orientation::Auto => { + if landscape_wanted { + (h, w) + } else { + (w, h) + } + } + } +} + +/// Place a page of display size `(dw, dh)` inside `cell` (sheet space), scaled by `s` +/// (centred), optionally rotated 90° (counter-clockwise). +fn place_in(page: usize, (dw, dh): (f64, f64), cell: [f64; 4], s: f64, rotate: bool) -> Placement { + let (cw, ch) = (cell[2] - cell[0], cell[3] - cell[1]); + let (pw, ph) = if rotate { (dh * s, dw * s) } else { (dw * s, dh * s) }; + let (x, y) = (cell[0] + (cw - pw) / 2.0, cell[1] + (ch - ph) / 2.0); + let matrix = if rotate { Matrix([0.0, s, -s, 0.0, x + pw, y]) } else { Matrix([s, 0.0, 0.0, s, x, y]) }; + Placement { page, matrix, clip: [0.0, 0.0, dw, dh] } +} + +fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 { + if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) } +} + +/// Lay out the sheets for `settings` given each source page's display size. +pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, PrintError> { + let pages: Vec = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect(); + if pages.is_empty() { + return Err(PrintError::NoPages); + } + let paper = settings.paper; + if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) { + return Err(PrintError::Invalid("invalid paper size".into())); + } + let mut sheets = Vec::new(); + match settings.layout { + Layout::Size(mode) => { + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN)); + let s = match mode { + SizeMode::Fit => fit, + SizeMode::Actual => 1.0, + SizeMode::Shrink => fit.min(1.0), + SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0, + SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())), + }; + sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() }); + } + } + Layout::Label { flip } => { + sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); + } + Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { + let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); + let Some(cells) = cells else { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + }; + // The grid can have more cells than the pages asked for on each sheet; the rest stay blank. + let per_sheet = per_sheet.min(cells); + if per_sheet == 0 { + return Err(PrintError::Invalid("invalid number of pages per sheet".into())); + } + // A grid with more columns than rows wants a landscape sheet, and the reverse. + let first = pages.first().and_then(|&p| sizes.get(p)).copied().ok_or(PrintError::NoPages)?; + let page_landscape = first.0 > first.1; + let (gc, gr, sheet_landscape) = if cols == rows { + (cols, rows, page_landscape) + } else if settings.orientation == Orientation::Landscape || (settings.orientation == Orientation::Auto && cols < rows) { + // 2 per sheet (1 × 2 on portrait) prints side by side on landscape paper. + (rows, cols, true) + } else { + (cols, rows, false) + }; + let size = oriented(paper, settings.orientation, sheet_landscape); + let gap = 6.0; + let (aw, ah) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + let (cw, ch) = ((aw - gap * (gc - 1) as f64) / gc as f64, (ah - gap * (gr - 1) as f64) / gr as f64); + if cw <= 0.0 || ch <= 0.0 { + return Err(PrintError::Invalid("the page grid does not fit on the paper".into())); + } + let sheet_count = pages.len().div_ceil(per_sheet); + for sheet_index in 0..sheet_count { + let mut sheet = Sheet { size, ..Sheet::default() }; + if order == PageOrder::CutStack { + // Marks align across every sheet, including unoccupied cells. Cut in the + // middle of each gutter; marks stay in the printable area, clear of cells. + for col in 1..gc { + let x = MARGIN + col as f64 * (cw + gap) - gap / 2.0; + sheet.lines.extend([[x, MARGIN, x, MARGIN + 8.0], [x, size.1 - MARGIN - 8.0, x, size.1 - MARGIN]]); + } + for row in 1..gr { + let y = size.1 - MARGIN - row as f64 * (ch + gap) + gap / 2.0; + sheet.lines.extend([[MARGIN, y, MARGIN + 8.0, y], [size.0 - MARGIN - 8.0, y, size.0 - MARGIN, y]]); + } + } + for k in 0..per_sheet { + // A cell's pile gets consecutive pages. Missing pages leave blank cells, + // rather than shifting later piles left on the final sheets. + let index = if order == PageOrder::CutStack { + k.checked_mul(sheet_count).and_then(|n| n.checked_add(sheet_index)) + } else { + sheet_index.checked_mul(per_sheet).and_then(|n| n.checked_add(k)) + }; + let Some(p) = index.and_then(|i| pages.get(i)).copied() else { continue }; + + let (col, row) = match order { + PageOrder::Horizontal | PageOrder::CutStack => (k % gc, k / gc), + PageOrder::HorizontalReversed => (gc - 1 - k % gc, k / gc), + PageOrder::Vertical => (k / gr, k % gr), + PageOrder::VerticalReversed => (gc - 1 - k / gr, k % gr), + }; + let x0 = MARGIN + col as f64 * (cw + gap); + let y1 = size.1 - MARGIN - row as f64 * (ch + gap); + let cell = [x0, y1 - ch, x0 + cw, y1]; + let d = sizes.get(p).copied().ok_or_else(|| PrintError::Invalid("page size is missing".into()))?; + if !(d.0.is_finite() && d.1.is_finite() && d.0 > 0.0 && d.1 > 0.0) { + return Err(PrintError::Invalid("invalid page size".into())); + } + let rotate = auto_rotate && (d.0 > d.1) != (cw > ch) && (d.0 - d.1).abs() > 1.0; + let s = fit_scale(if rotate { (d.1, d.0) } else { d }, (cw, ch)); + if !s.is_finite() || s <= 0.0 { + return Err(PrintError::Invalid("page size cannot be scaled to the grid".into())); + } + let pl = place_in(p, d, cell, s, rotate); + if border { + let b = pl.matrix.bbox(pl.clip); + sheet.borders.push(b); + } + sheet.placed.push(pl); + } + sheets.push(sheet); + } + } + Layout::Booklet { subset, binding } => { + // Pad to a multiple of 4 with blank pages (None). + let n4 = pages.len().div_ceil(4) * 4; + let at = |i: usize| pages.get(i).copied(); + let size = oriented(paper, Orientation::Landscape, true); + let half = (size.0 - 2.0 * MARGIN) / 2.0; + let cells = [[MARGIN, MARGIN, MARGIN + half, size.1 - MARGIN], [MARGIN + half, MARGIN, size.0 - MARGIN, size.1 - MARGIN]]; + for k in 0..n4 / 4 { + let front = (at(n4 - 1 - 2 * k), at(2 * k)); + let back = (at(2 * k + 1), at(n4 - 2 - 2 * k)); + let mut sides = Vec::new(); + if subset != BookletSubset::BackOnly { + sides.push(front); + } + if subset != BookletSubset::FrontOnly { + sides.push(back); + } + for (l, r) in sides { + let (l, r) = if binding == Binding::Right { (r, l) } else { (l, r) }; + let mut sheet = Sheet { size, ..Sheet::default() }; + for (p, cell) in [(l, cells[0]), (r, cells[1])] { + if let Some(p) = p { + let d = sizes[p]; + let s = fit_scale(d, (cell[2] - cell[0], cell[3] - cell[1])); + sheet.placed.push(place_in(p, d, cell, s, false)); + } + } + sheets.push(sheet); + } + } + } + Layout::Poster { scale, overlap, cut_marks } => { + if !(scale.is_finite() && scale > 0.0 && overlap.is_finite() && overlap >= 0.0) { + return Err(PrintError::Invalid("invalid tile scale or overlap".into())); + } + let s = scale / 100.0; + for &p in &pages { + let d = sizes[p]; + let size = oriented(paper, settings.orientation, d.0 > d.1); + // Each tile shows `step` of the scaled page plus `overlap` shared with the next. + let (tw, th) = (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN); + if overlap * 2.0 >= tw.min(th) { + return Err(PrintError::Invalid("the overlap is larger than the tile".into())); + } + let (sw, sh) = (d.0 * s, d.1 * s); + let (stepx, stepy) = (tw - overlap, th - overlap); + let nx = (((sw - overlap) / stepx).ceil() as usize).max(1); + let ny = (((sh - overlap) / stepy).ceil() as usize).max(1); + // The counts saturate at usize::MAX for a huge scale, so their product can overflow. + if nx.checked_mul(ny).is_none_or(|n| n > 1024) { + return Err(PrintError::Invalid("the poster would need more than 1024 sheets".into())); + } + // Tiles from the top-left, row by row. + for ty in 0..ny { + for tx in 0..nx { + // The window into the scaled page (scaled display space, y up). + let wx0 = tx as f64 * stepx; + let wy1 = sh - ty as f64 * stepy; + let matrix = Matrix([s, 0.0, 0.0, s, MARGIN - wx0, size.1 - MARGIN - wy1]); + let clip = [wx0 / s, (wy1 - th) / s, (wx0 + tw) / s, wy1 / s]; + let mut sheet = Sheet { size, placed: vec![Placement { page: p, matrix, clip }], ..Sheet::default() }; + if cut_marks { + let (x0, y0, x1, y1) = (MARGIN, MARGIN, size.0 - MARGIN, size.1 - MARGIN); + for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { + let dx = if x == x0 { -1.0 } else { 1.0 }; + let dy = if y == y0 { -1.0 } else { 1.0 }; + sheet.lines.push([x + dx * 2.0, y, x + dx * (MARGIN - 2.0), y]); + sheet.lines.push([x, y + dy * 2.0, x, y + dy * (MARGIN - 2.0)]); + } + } + sheets.push(sheet); + } + } + } + } + } + Ok(sheets) +} + +fn layout_labels(sizes: &[(f64, f64)], pages: &[usize], paper: (f64, f64), orientation: Orientation, settings: &Settings, flip: bool) -> Vec { + let size = oriented(paper, orientation, false); + let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; + pages + .iter() + .map(|&p| { + let d = sizes[p]; + let crop = settings.crops.get(p).copied().flatten(); + let rect = crop + .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) + .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) + .unwrap_or([0.0, 0.0, d.0, d.1]); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); + let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { + (false, _) => 0, + (true, true) => 3, + (true, false) => 1, + }; + if flip { + quarter = (quarter + 2) % 4; + } + let s = if turn { + fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) + } else { + fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) + }; + Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() } + }) + .collect() +} diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index cf1c0cb76c..5ce3d9736b 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -10,21 +10,105 @@ pub use craft_print::{ /// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it /// when the user asks, not every frame. pub fn printer_options(printer: &str) -> Vec { + printer_options_in(printer, "en") +} + +/// [`printer_options`] with the driver's own names in `lang` (a UI language code such as `bg`, +/// `pt-br` or `zh-hans`) where its PPD has them (`*bg.Translation`, `*bg.InputSlot Tray2/…`); +/// the driver's default names otherwise. +pub fn printer_options_in(printer: &str, lang: &str) -> Vec { #[cfg(all(unix, not(target_arch = "wasm32")))] { - printer_options_via(printer, std::path::Path::new("/etc/cups/ppd"), std::process::Command::new("lpoptions")) + printer_options_via_within(printer, std::path::Path::new("/etc/cups/ppd"), std::process::Command::new("lpoptions"), LPOPTIONS_LIMIT, lang) } #[cfg(not(all(unix, not(target_arch = "wasm32"))))] { + let _ = lang; craft_print::printer_options(printer) } } +/// How long `lpoptions` may take: a CUPS that has stopped answering must not hold whoever asked +/// (#1818). After this the PPD's own defaults stand. +#[cfg(all(unix, not(target_arch = "wasm32")))] +const LPOPTIONS_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); + +/// The most of a command's output that is kept (`lpoptions -l` is a few kilobytes). +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +const OUTPUT_CAP: u64 = 4 << 20; + +/// Run `command` and collect its standard output, giving it `limit`: a command still running +/// then is killed and reaped, and the error is `TimedOut`. Standard input and error are not +/// connected. The exit status is not checked, as `Command::output` leaves it to the caller. +/// Only CUPS runs a command today; the tests cover every desktop platform. +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +pub(crate) fn output_within(command: &mut std::process::Command, limit: std::time::Duration) -> std::io::Result> { + use std::io::Read as _; + use std::process::Stdio; + let mut child = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn()?; + let stop = |child: &mut std::process::Child| { + let _ = child.kill(); + let _ = child.wait(); + }; + let Some(mut stdout) = child.stdout.take() else { + stop(&mut child); + return Err(std::io::Error::other("the command's output could not be read")); + }; + // Read on a thread of its own, so a full pipe never holds the command while the deadline is + // kept here. Past the cap the rest is drained and dropped, so the command still finishes. + // The output comes back over a channel rather than through `join`: a process the command + // started can keep the pipe open after the command itself has exited or been killed, and + // the wait for the output must end at the deadline too. Such a reader is left to finish on + // its own when that process closes the pipe. + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::thread::Builder::new().name("command output".into()).spawn(move || { + let mut out = Vec::new(); + let read = (&mut stdout).take(OUTPUT_CAP).read_to_end(&mut out); + let _ = std::io::copy(&mut stdout, &mut std::io::sink()); + let _ = tx.send(read.map(|_| out)); + }); + if let Err(e) = reader { + stop(&mut child); + return Err(e); + } + let timed_out = || std::io::Error::new(std::io::ErrorKind::TimedOut, format!("the command did not answer within {limit:?}")); + let deadline = std::time::Instant::now() + limit; + loop { + match child.try_wait() { + Ok(Some(_)) => { + // A moment's grace, so output written just before an exit at the deadline counts. + let left = deadline.saturating_duration_since(std::time::Instant::now()).max(std::time::Duration::from_millis(100)); + return match rx.recv_timeout(left) { + Ok(read) => read, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(timed_out()), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other("the command's output was lost")), + }; + } + Ok(None) if std::time::Instant::now() >= deadline => { + stop(&mut child); + return Err(timed_out()); + } + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)), + Err(e) => { + stop(&mut child); + return Err(e); + } + } + } +} + /// Read a destination's options with the PPD directory and command supplied, so tests need /// neither a system queue nor a physical printer. CUPS instances share their queue's PPD but -/// have their own defaults, queried with the complete `queue/instance` destination. +/// have their own defaults, queried with the complete `queue/instance` destination. `limit` is +/// how long the command may take before the PPD's defaults stand on their own. #[cfg(all(unix, not(target_arch = "wasm32")))] -pub(crate) fn printer_options_via(printer: &str, ppd_dir: &std::path::Path, mut lpoptions: std::process::Command) -> Vec { +pub(crate) fn printer_options_via_within( + printer: &str, + ppd_dir: &std::path::Path, + mut lpoptions: std::process::Command, + limit: std::time::Duration, + lang: &str, +) -> Vec { let (queue, instance) = printer.split_once('/').map_or((printer, None), |(q, i)| (q, Some(i))); // Retain the conservative name checks, applied to each component. Neither component is // a path: reject dot components, backslashes and any additional separator before I/O. @@ -43,8 +127,12 @@ pub(crate) fn printer_options_via(printer: &str, ppd_dir: &std::path::Path, mut let mut bytes = Vec::new(); f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes) }); - let mut options = ppd.map(|b| parse_ppd(&ppd_text(&b))).unwrap_or_default(); - let current = lpoptions.args(["-p", printer, "-l"]).output().map(|o| parse_lpoptions(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default(); + let mut options = ppd.as_deref().map(|b| parse_ppd(&ppd_text(b))).unwrap_or_default(); + if let Some(b) = &ppd { + localize(&mut options, b, lang); + } + let current = + output_within(lpoptions.args(["-p", printer, "-l"]), limit).map(|out| parse_lpoptions(&String::from_utf8_lossy(&out))).unwrap_or_default(); for (key, value) in current { if let Some(o) = options.iter_mut().find(|o| o.key == key) && o.choices.iter().any(|(c, _)| *c == value) @@ -68,3 +156,81 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> { craft_print::open_printer_preferences(printer).map_err(|e| crate::PrintError::Spool(e.to_string())) } + +/// The PPD language codes (`*cupsLanguages`, the `ll` of `*ll.Translation`) to try for a UI +/// language code, most specific first: `pt-br` → `pt_BR`, `pt`; `zh-hans` → `zh_CN`, `zh`. +// Read only by the CUPS path; the tests exercise it everywhere. +#[cfg_attr(not(all(unix, not(target_arch = "wasm32"))), allow(dead_code))] +fn ppd_languages(lang: &str) -> Vec { + let lang = lang.to_ascii_lowercase(); + let mut parts = lang.split(['-', '_']).filter(|p| !p.is_empty()); + let Some(primary) = parts.next() else { return Vec::new() }; + let region = match (primary, parts.next()) { + ("zh", Some("hans")) => Some("CN".to_string()), + ("zh", Some("hant")) => Some("TW".to_string()), + (_, Some(r)) if r.len() == 2 => Some(r.to_ascii_uppercase()), + _ => None, + }; + region.map(|r| format!("{primary}_{r}")).into_iter().chain(std::iter::once(primary.to_string())).collect() +} + +/// Give `options` the driver's own names in `lang` from the PPD's localization lines +/// (`*ll.Translation Key/Label: ""` for options and groups, `*ll.Key choice/Label: ""` for +/// choices). Each line is read as UTF-8 on its own: drivers write them so in files whose other +/// text is Latin-1. Names with no translation, or an empty one, stay as they are. +// Read only by the CUPS path; the tests exercise it everywhere. +#[cfg_attr(not(all(unix, not(target_arch = "wasm32"))), allow(dead_code))] +pub(crate) fn localize(options: &mut [PrinterOption], ppd: &[u8], lang: &str) { + use std::collections::HashMap; + let Some(code) = ppd_languages(lang).into_iter().find(|code| { + let prefix = format!("*{code}."); + ppd.split(|b| *b == b'\n').any(|l| l.starts_with(prefix.as_bytes())) + }) else { + return; + }; + if code == "en" { + return; + } + let prefix = format!("*{code}."); + let mut names: HashMap = HashMap::new(); + let mut choices: HashMap<(String, String), String> = HashMap::new(); + let mut groups: HashMap = HashMap::new(); + for line in ppd.split(|b| *b == b'\n') { + let Some(text) = std::str::from_utf8(line).ok() else { continue }; + let text = text.trim_end_matches('\r'); + if let Some(rest) = text.strip_prefix("*OpenGroup:") { + // Group label → keyword, since options carry their group's label. + let rest = rest.trim(); + if let Some((key, label)) = rest.split_once('/') { + groups.insert(label.trim().to_string(), key.trim().to_string()); + } + continue; + } + let Some(rest) = text.strip_prefix(prefix.as_str()) else { continue }; + let Some((head, _)) = rest.split_once(':') else { continue }; + let Some((main, spec)) = head.split_once(' ') else { continue }; + let Some((key, label)) = spec.split_once('/') else { continue }; + let label = label.trim(); + if label.is_empty() { + continue; + } + if main == "Translation" { + names.insert(key.trim().to_string(), label.to_string()); + } else { + choices.insert((main.to_string(), key.trim().to_string()), label.to_string()); + } + } + for o in options.iter_mut() { + if let Some(label) = names.get(&o.key) { + o.label = label.clone(); + } + if let Some(label) = groups.get(&o.group).and_then(|key| names.get(key)) { + o.group = label.clone(); + } + for (choice, label) in o.choices.iter_mut() { + if let Some(t) = choices.get(&(o.key.clone(), choice.clone())) { + *label = t.clone(); + } + } + } +} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 54cbbd4c7c..885756e8c6 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -71,8 +71,10 @@ fn page_selection() { assert_eq!(select_pages(1, None, &[], Subset::Even, false), Err(PrintError::NoPages)); // An explicit list (selected thumbnails): positions, never labels, in the order given. assert_eq!(select_listed(5, &[1, 3], Subset::All, false).unwrap(), [1, 3]); - assert_eq!(select_listed(5, &[0, 2, 4], Subset::Even, false).unwrap(), [2]); - assert_eq!(select_listed(5, &[0, 2, 4], Subset::Odd, true).unwrap(), [4, 0]); + // Picked pages keep their page numbers: pages 1, 3 and 5 are all odd. + assert_eq!(select_listed(5, &[0, 2, 4], Subset::Even, false), Err(PrintError::NoPages)); + assert_eq!(select_listed(5, &[0, 2, 4], Subset::Odd, true).unwrap(), [4, 2, 0]); + assert_eq!(select_listed(5, &[1, 2, 3], Subset::Even, false).unwrap(), [1, 3]); assert_eq!(select_listed(5, &[1, 5], Subset::All, false), Err(PrintError::OutOfRange(6, 5))); assert!(matches!(select_listed(0, &[usize::MAX], Subset::All, false), Err(PrintError::OutOfRange(_, 0)))); assert_eq!(select_listed(5, &[], Subset::All, false), Err(PrintError::NoPages)); @@ -192,6 +194,17 @@ fn custom_pages_per_sheet_never_overfill_the_grid() { assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3); } +#[test] +fn imposing_lists_the_pages_once() { + let n = 150; + let doc = fixture(n); + PAGES_LISTED.with(|c| c.set(0)); + let out = impose(&doc, &settings((0..n).collect(), Layout::multiple(2))).unwrap(); + let listed = PAGES_LISTED.with(|c| c.get()); + assert!(listed <= 2 * n, "{listed} pages listed to print {n}"); + assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), n / 2); +} + #[test] fn booklets_pair_pages_for_folding() { let sizes = vec![(200.0, 300.0); 6]; @@ -270,8 +283,8 @@ fn spooler_arguments_and_printer_list() { assert_eq!( parse_lpstat(out), [ - spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, - spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None } + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None, description: String::new() } ] ); assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty()); @@ -314,18 +327,21 @@ fn driverless_printers_join_the_list_without_a_queue() { assert_eq!( spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")), [ - spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, - spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None }, + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None, description: String::new() }, ], "queues first, then the driverless destination; the queue listed twice is not doubled" ); // An old spooler without `-e` (CUPS < 1.7) keeps its queues; only the driverless names are lost. - assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }]); + assert_eq!( + spool::printers_parsed(queues, None), + [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }] + ); // The default can itself be driverless (`lpoptions -d` names a destination no queue exists // for): it keeps its marker, so the dialog preselects the user's default, not the first queue. assert_eq!( spool::printers_parsed("system default destination: Basement_Color\n", Some("Basement_Color\n")), - [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None }], + [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None, description: String::new() }], "a default destination without a queue is still the default" ); assert!(spool::parse_lpstat_e("\n \n").is_empty(), "no destinations is empty, not [\"\"]"); @@ -401,17 +417,17 @@ fn windows_printers_carry_one_default_and_keep_their_names() { // Windows marks the system default with a `*` no queue name may contain; names hold spaces, // parentheses and non-ASCII letters, and the lines end with CRLF. let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n"); - assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None }); - assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None }); - assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None }); + assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None, description: String::new() }); + assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None, description: String::new() }); + assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None, description: String::new() }); // A spooler marking two defaults: the first still wins, so the dialog has one to preselect. - assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None }); + assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None, description: String::new() }); assert_eq!(parse_windows_printers("\r\n"), Vec::new()); // After a tab, whether the driver reports two-sided printing; a tab inside a name stays. let sided = parse_windows_printers("*Office PCL\t2\r\nOffice PS\t1\r\nTab\tName\t2\r\n"); - assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }); - assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }); - assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) }); + assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() }); + assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() }); + assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true), description: String::new() }); } /// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1 @@ -499,6 +515,8 @@ mod cups_options { const RECORD: &str = "PDFCRAFT_LPOPTIONS_TEST_RECORD"; const ANSWER: &str = "PDFCRAFT_LPOPTIONS_TEST_ANSWER"; + /// Generous: these tests are about what is read, not how long it takes. + const LIMIT: std::time::Duration = std::time::Duration::from_secs(60); /// The child process stands in for lpoptions, recording its real arguments. #[test] @@ -506,8 +524,31 @@ mod cups_options { let Some(record) = std::env::var_os(RECORD) else { return }; let args: Vec = std::env::args().skip_while(|a| a != "--").skip(1).collect(); std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap(); + let answer = std::env::var(ANSWER).unwrap(); + // A queue that never answers (CUPS stopped responding). + if answer == "HANG" { + std::thread::sleep(std::time::Duration::from_secs(60)); + } // Serial libtest prints its test label without a newline before this helper's stdout. - println!("\n{}", std::env::var(ANSWER).unwrap()); + println!("\n{answer}"); + } + + /// `lpoptions` that never answers is stopped at the deadline: the PPD's defaults stand and + /// whoever asked is not held (#1818). + #[test] + fn a_queue_that_never_answers_is_given_up_at_the_deadline() { + let dir = folder(); + std::fs::write( + dir.join("Office.ppd"), + "*OpenUI *InputSlot/Paper tray: PickOne\n*DefaultInputSlot: Auto\n*InputSlot Auto/Automatic: \"\"\n*InputSlot Tray2/Tray 2: \"\"\n*CloseUI: *InputSlot\n", + ) + .unwrap(); + let started = std::time::Instant::now(); + let options = spool::printer_options_via_within("Office", &dir, command(&dir, "HANG"), std::time::Duration::from_millis(500), "en"); + assert!(started.elapsed() < std::time::Duration::from_secs(20), "given up at the deadline, not when the command does"); + assert_eq!(options.len(), 1, "the PPD is still read"); + assert_eq!(options[0].default, "Auto", "the PPD default stands when the queue does not answer"); + std::fs::remove_dir_all(dir).unwrap(); } fn command(record: &std::path::Path, answer: &str) -> std::process::Command { @@ -557,7 +598,8 @@ mod cups_options { (boundary_queue.as_str(), "Tray2", "Tray2"), (boundary_instance.as_str(), "Tray2", "Tray2"), ] { - let options = spool::printer_options_via(destination, &dir, command(&dir, &format!("InputSlot/Paper tray: *{current}"))); + let options = + spool::printer_options_via_within(destination, &dir, command(&dir, &format!("InputSlot/Paper tray: *{current}")), LIMIT, "en"); assert_eq!(options.len(), 1, "{destination}: the queue's PPD must be read"); assert_eq!(options[0].default, expected, "{destination}: only valid instance choices override the PPD default"); assert_eq!(options[0].choices, [("Auto".into(), "Automatic".into()), ("Tray2".into(), "Tray 2".into())]); @@ -598,7 +640,10 @@ mod cups_options { long_queue_instance.as_str(), long_instance.as_str(), ] { - assert!(spool::printer_options_via(destination, &dir, command(&dir, "InputSlot: *Auto")).is_empty(), "{destination:?}"); + assert!( + spool::printer_options_via_within(destination, &dir, command(&dir, "InputSlot: *Auto"), LIMIT, "en").is_empty(), + "{destination:?}" + ); assert!(!dir.join("args").exists(), "{destination:?}: rejected before spawning"); } std::fs::remove_dir_all(dir).unwrap(); @@ -632,6 +677,8 @@ fn architectural_sheets_preserve_full_scale() { assert!(fit[0].placed[0].matrix.0[0] < 1.0, "Fit adds margins and must not masquerade as full scale"); } +/// A sheet matches a preset either way round and rounded to whole points, but not half a point off; +/// empty, negative, infinite, NaN and custom sizes match none. #[test] fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() { for (i, (_, (w, h))) in PAPERS.iter().enumerate() { @@ -645,6 +692,77 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() } } +/// #3752: 13 of the 21 sizes had no media name, so their jobs went out on the queue's default +/// paper (A4 on the report's queue) whatever the sheets were laid out on. +#[test] +fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { + let ppd_keywords = ["Letter", "Legal", "Tabloid", "A3", "A4", "A5", "w288h432", "Env10"]; + for (i, (name, (w, h))) in PAPERS.iter().enumerate() { + let media = cups_media(i).unwrap_or_else(|| panic!("{name} has no media name")); + assert_eq!(media_option(i), Some(("media".to_string(), media.to_string()))); + let args = lp_args(&Job { options: media_option(i).into_iter().collect(), ..Job::default() }); + assert!(args.contains(&format!("media={media}")), "{name}: lp is given it: {args:?}"); + if ppd_keywords.contains(&media) { + continue; + } + // The rest are PWG 5101.1 names, which end in their dimensions: CUPS feeds the driver's + // size of those dimensions, so they must be the paper's. + let dims = media.rsplit('_').next().unwrap_or_default(); + let (dims, points_per_unit) = match (dims.strip_suffix("mm"), dims.strip_suffix("in")) { + (Some(mm), _) => (mm, 72.0 / 25.4), + (_, Some(inches)) => (inches, 72.0), + _ => panic!("{name}: {media} is not a PWG name"), + }; + let (pw, ph) = dims.split_once('x').unwrap_or_else(|| panic!("{name}: {media} has no dimensions")); + let (pw, ph) = (pw.parse::().unwrap() * points_per_unit, ph.parse::().unwrap() * points_per_unit); + assert!((pw - w).abs() < 0.5 && (ph - h).abs() < 0.5, "{name}: {media} is {pw:.2} x {ph:.2} pt, not {w} x {h}"); + } + assert_eq!((cups_media(PAPERS.len()), media_option(PAPERS.len())), (None, None)); + // The names themselves, spelled as PWG 5101.1 has them: a driverless printer may take only the + // names it advertises, so one CUPS can merely parse (ARCH A was `arch_a_9x12in`) is not enough. + // ARCH E1 is 5101.1's `wide-format` size: `na_arch-e1_30x42in` was asked for once and never + // registered. + let names: Vec<(&str, &str)> = PAPERS.iter().enumerate().map(|(i, (name, _))| (*name, cups_media(i).unwrap_or_default())).collect(); + assert_eq!( + names, + [ + ("US Letter", "Letter"), + ("US Legal", "Legal"), + ("Tabloid", "Tabloid"), + ("A3", "A3"), + ("A4", "A4"), + ("A5", "A5"), + ("A6", "iso_a6_105x148mm"), + ("ARCH A (9 x 12 in)", "na_arch-a_9x12in"), + ("ARCH B (12 x 18 in)", "na_arch-b_12x18in"), + ("ARCH C (18 x 24 in)", "na_arch-c_18x24in"), + ("ARCH D (24 x 36 in)", "na_arch-d_24x36in"), + ("ARCH E1 (30 x 42 in)", "na_wide-format_30x42in"), + ("ARCH E (36 x 48 in)", "na_arch-e_36x48in"), + ("ANSI C (17 x 22 in)", "na_c_17x22in"), + ("ANSI D (22 x 34 in)", "na_d_22x34in"), + ("ANSI E (34 x 44 in)", "na_e_34x44in"), + ("A2", "iso_a2_420x594mm"), + ("A1", "iso_a1_594x841mm"), + ("A0", "iso_a0_841x1189mm"), + ("4 x 6 in label (101.6 x 152.4 mm)", "w288h432"), + ("US #10 Envelope (4.125 x 9.5 in)", "Env10"), + ] + ); +} + +/// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at +/// its own size. +#[test] +fn us_number_10_envelope_is_offered_and_fed_by_name() { + let index = matching_paper((684.0, 297.0)).unwrap(); + assert_eq!(PAPERS[index].0, "US #10 Envelope (4.125 x 9.5 in)"); + assert_eq!(PAPERS[index].1, (297.0, 684.0)); + assert_eq!(cups_media(index), Some("Env10")); + let s = Settings { pages: vec![0], paper: PAPERS[index].1, layout: Layout::Size(SizeMode::Actual), ..Settings::default() }; + assert_eq!(layout(&[(297.0, 684.0)], &s).unwrap()[0].size, (297.0, 684.0)); +} + #[test] fn label_is_cut_turned_upright_and_fitted_without_distortion() { let label = LABEL_PAPER; @@ -744,3 +862,285 @@ fn imposed_pages_preserve_large_user_unit_matrices() { } } } + +/// `output_within` (#1818): the bounded wait every platform's `lpoptions` call goes through. +#[cfg(not(target_arch = "wasm32"))] +mod bounded_commands { + const MODE: &str = "PDFCRAFT_COMMAND_TEST_MODE"; + + /// The child process stands in for a command: it answers, or it never does. + #[test] + fn stand_in_for_a_command() { + match std::env::var(MODE).as_deref() { + Ok("answer") => println!("hello from the command"), + Ok("hang") => std::thread::sleep(std::time::Duration::from_secs(60)), + // Holds the output pipe it inherited for a while, as a daemon a command starts can. + Ok("linger") => std::thread::sleep(LINGER), + // Starts a lingering process on its own output, then exits or hangs itself. + Ok(mode @ ("orphan-exit" | "orphan-hang")) => { + let _ = command("linger").spawn(); + println!("started"); + if mode == "orphan-hang" { + std::thread::sleep(std::time::Duration::from_secs(60)); + } + } + _ => {} + } + } + + const LINGER: std::time::Duration = std::time::Duration::from_secs(8); + + fn command(mode: &str) -> std::process::Command { + let mut c = std::process::Command::new(std::env::current_exe().unwrap()); + c.args(["--exact", "tests::bounded_commands::stand_in_for_a_command", "--nocapture", "--"]).env(MODE, mode); + c + } + + #[test] + fn a_command_that_answers_gives_its_output() { + let out = crate::spool::output_within(&mut command("answer"), std::time::Duration::from_secs(60)).unwrap(); + assert!(String::from_utf8_lossy(&out).contains("hello from the command")); + } + + #[test] + fn a_command_that_never_answers_is_stopped_at_the_deadline() { + let started = std::time::Instant::now(); + let err = crate::spool::output_within(&mut command("hang"), std::time::Duration::from_millis(500)).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); + assert!(started.elapsed() < std::time::Duration::from_secs(20), "stopped soon after the deadline, not when the command gives up"); + } + + /// A process the command started keeps the output pipe open after the command has exited, + /// or after it was killed at the deadline: the wait still ends at the deadline (#2108 + /// review), not when that process lets go of the pipe. + #[test] + fn a_process_left_holding_the_output_does_not_extend_the_wait() { + for mode in ["orphan-exit", "orphan-hang"] { + let started = std::time::Instant::now(); + let err = crate::spool::output_within(&mut command(mode), std::time::Duration::from_millis(1500)).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut, "{mode}"); + assert!(started.elapsed() < LINGER / 2, "{mode}: returned after {:?}, when the lingering process let go", started.elapsed()); + } + } +} + +#[test] +fn driver_options_take_the_ppds_own_names_in_the_ui_language() { + use crate::spool::{PrinterOption, localize, parse_ppd, ppd_text}; + // Latin-1 elsewhere (the é), UTF-8 in the localization lines, as drivers ship them. + let mut ppd = b"*cupsLanguages: \"bg pt_BR\"\n*OpenGroup: Finishing/Finishing Options\n*OpenUI *KMDuplex/Print Type: PickOne\n*DefaultKMDuplex: Single\n*KMDuplex Single/1-Sided: \"\"\n*KMDuplex Double/2-Sided: \"\"\n*CloseUI: *KMDuplex\n*OpenUI *Staple/Staple: PickOne\n*DefaultStaple: None\n*Staple None/None: \"\"\n*CloseUI: *Staple\n*CloseGroup: Finishing\n*fr.Translation KMDuplex/Type d'impression r".to_vec(); + ppd.push(0xE9); + ppd.extend_from_slice("el: \"\"\n*bg.Translation Finishing/Довършителни операции: \"\"\n*bg.Translation KMDuplex/Тип печат: \"\"\n*bg.KMDuplex Single/Едностранен: \"\"\n*bg.KMDuplex Double/: \"\"\n*pt_BR.Translation KMDuplex/Tipo de impressão: \"\"\n".as_bytes()); + let parsed = parse_ppd(&ppd_text(&ppd)); + let label = |o: &[PrinterOption], key: &str| o.iter().find(|o| o.key == key).map(|o| o.label.clone()).unwrap(); + let mut bg = parsed.clone(); + localize(&mut bg, &ppd, "bg"); + let duplex = bg.iter().find(|o| o.key == "KMDuplex").unwrap(); + assert_eq!((duplex.label.as_str(), duplex.group.as_str()), ("Тип печат", "Довършителни операции")); + assert_eq!( + duplex.choices, + [("Single".to_string(), "Едностранен".to_string()), ("Double".to_string(), "2-Sided".to_string())], + "an empty translation keeps the driver's name" + ); + assert_eq!(label(&bg, "Staple"), "Staple", "untranslated options keep theirs"); + // Region-specific codes, then the language alone; languages the PPD lacks change nothing. + let mut pt = parsed.clone(); + localize(&mut pt, &ppd, "pt-br"); + assert_eq!(label(&pt, "KMDuplex"), "Tipo de impressão"); + for lang in ["en", "de", "", "bg-"] { + let mut other = parsed.clone(); + localize(&mut other, &ppd, lang); + assert_eq!(other == parsed, lang != "bg-", "{lang:?}"); + } +} + +mod appearances; + +#[test] +fn repeated_content_references_share_one_decode_budget() { + let mut doc = Document::new_empty(); + let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])); + let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect(); + let data = decoded_contents(&doc, &page, &mut DecodeBudget::page()); + assert!(data.is_none_or(|d| d.len() <= pdfcraft_cos::MAX_PAGE_DECODED + 300)); +} + +/// 20 draws of a form that draws the next form 20 times, four levels down: 168,420 form +/// entries from a few hundred bytes. +fn form_fanout(doc: &mut Document) -> Dict { + let draws = "/X Do ".repeat(20); + let mut form = |content: &str, inner: Option| { + let mut d: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(); + if let Some(inner) = inner { + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + d.set(b"Resources".to_vec(), Object::Dict(res)); + } + doc.add(Stream::from_raw(d, content.as_bytes().to_vec())) + }; + let mut inner = form("BT 0 1 -1 0 0 0 Tm (a) Tj ET", None); + for _ in 0..3 { + inner = form(&draws, Some(inner)); + } + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect(); + [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect() +} + +#[test] +fn text_direction_stops_at_the_form_budget() { + let mut doc = Document::new_empty(); + let res = form_fanout(&mut doc); + let mut score = 0; + text_direction(&doc, "/X Do ".repeat(20).as_bytes(), &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::page()); + assert!(score > 0 && score <= pdfcraft_cos::MAX_PAGE_FORMS as i64, "score {score}"); +} + +/// A form refused by the spent budget is left out; the text drawn after it still counts. +#[test] +fn text_direction_reads_on_past_a_refused_form() { + let mut doc = Document::new_empty(); + let form = doc.add(Stream::from_raw([(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(), Vec::new())); + let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect(); + let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect(); + let mut score = 0; + let content = b"/X Do BT 0 1 -1 0 0 0 Tm (a) Tj ET"; + text_direction(&doc, content, &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::new(1 << 20, 0)); + assert_eq!(score, 1); +} + +/// #3984: odd and even are page numbers, as Acrobat counts them, not places in the range. +#[test] +fn odd_and_even_pages_are_page_numbers_not_places_in_the_range() { + let even = |range: &str| select_pages(12, Some(range), &[], Subset::Even, false).unwrap(); + let odd = |range: &str| select_pages(12, Some(range), &[], Subset::Odd, false).unwrap(); + // Adobe Acrobat DC Help (11/28/2019), Options in the Print dialog box, p. 515: "if you set the + // range to 2, 7–10 and choose Even Pages Only, only pages 2, 8, and 10 print". + assert_eq!(even("2, 7-10"), [1, 7, 9]); + // The report: 4–11 and 3–11 both print pages 4, 6, 8 and 10 as Even pages only. + assert_eq!(even("4-11"), [3, 5, 7, 9]); + assert_eq!(even("3-11"), [3, 5, 7, 9]); + assert_eq!(odd("4-11"), [4, 6, 8, 10]); + // Typed order is kept, and Reverse turns it round. + assert_eq!(even("10, 3, 4"), [9, 3]); + assert_eq!(select_pages(12, Some("10, 3, 4"), &[], Subset::Even, true).unwrap(), [3, 9]); + // A range with no page of the kind prints nothing. + assert_eq!(select_pages(12, Some("3, 5"), &[], Subset::Even, false), Err(PrintError::NoPages)); +} + +/// ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard does not print, +/// even with its Print flag; Invisible means nothing for a standard type. +#[test] +fn invisible_annotations_of_unknown_types_do_not_print() { + let doc = fixture(1); + let annot = |subtype: Option<&str>, flags: i64| { + let mut d = Dict::new(); + if let Some(s) = subtype { + d.set(b"Subtype".to_vec(), Object::name(s)); + } + d.set(b"F".to_vec(), Object::Int(flags)); + d + }; + let all = Content::DocumentAndMarkups; + assert!(prints(&doc, &annot(Some("MyCustomType"), 4), all), "an unknown type prints without Invisible"); + assert!(!prints(&doc, &annot(Some("MyCustomType"), 5), all), "Invisible + Print on an unknown type"); + assert!(!prints(&doc, &annot(None, 5), all), "no Subtype is no standard type"); + assert!(prints(&doc, &annot(Some("Square"), 5), all), "Invisible doesn't apply to a standard type"); + assert!(prints(&doc, &annot(Some("Widget"), 5), Content::FormFieldsOnly), "nor to a field"); + + // An indirect `/Subtype` (§7.3.10) is resolved: a standard type stays standard. + let mut doc = doc; + let square = doc.add(Object::name("Square")); + let mut indirect = annot(None, 5); + indirect.set(b"Subtype".to_vec(), Object::Ref(square)); + assert!(prints(&doc, &indirect, all), "an indirect standard Subtype"); +} + +/// #2707: a booklet's two-sided setting follows its subset. +#[test] +fn a_booklet_prints_both_sides_on_the_short_edge() { + assert_eq!(booklet_duplex(BookletSubset::BothSides), spool::Duplex::ShortEdge); + assert_eq!(booklet_duplex(BookletSubset::FrontOnly), spool::Duplex::Off); + assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off); +} + +/// #3502: `/Contents` array pieces are joined with a space, matching the renderer, so a `%` +/// comment at the end of a piece without a line break continues through the first line of the +/// next piece in both `impose` and `text_reads_up`. +#[test] +fn contents_pieces_join_with_a_space_across_a_trailing_comment() { + let build_doc = |first_piece: &[u8]| { + let mut doc = Document::new_empty(); + let font = doc.add( + [ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type1")), + (b"BaseFont".to_vec(), Object::name("Helvetica")), + ] + .into_iter() + .collect::(), + ); + let s1 = doc.add(Stream::from_raw(Dict::new(), first_piece.to_vec())); + // Line 1 of s2 draws sideways ("HIDDEN") and 4 characters reading up; line 2 draws upright ("SHOWN"). + let s2 = doc.add(Stream::from_raw( + Dict::new(), + b"BT /F1 18 Tf 0 1 -1 0 50 50 Tm (HIDDEN) Tj ET\nBT /F1 18 Tf 1 0 0 1 20 100 Tm (SHOWN) Tj ET".to_vec(), + )); + let fonts: Dict = [(b"F1".to_vec(), Object::Ref(font))].into_iter().collect(); + let res: Dict = [(b"Font".to_vec(), Object::Dict(fonts))].into_iter().collect(); + let pages_root = doc.add(Object::Null); + let page = doc.add( + [ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(pages_root)), + (b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 200.0, 300.0].into_iter().map(Object::Real).collect())), + (b"Resources".to_vec(), Object::Dict(res)), + (b"Contents".to_vec(), Object::Array(vec![Object::Ref(s1), Object::Ref(s2)])), + ] + .into_iter() + .collect::(), + ); + doc.set( + pages_root, + Object::Dict( + [ + (b"Type".to_vec(), Object::name("Pages")), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(), + ), + ); + let cat = doc.add([(b"Type".to_vec(), Object::name("Catalog")), (b"Pages".to_vec(), Object::Ref(pages_root))].into_iter().collect::()); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(cat)); + doc + }; + + let form_strings = |imposed: Vec| -> Vec> { + let out = Document::open(Arc::new(imposed)).unwrap(); + let page = &pdfcraft_model::pages(&out)[0]; + let res = out.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = out.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let form_obj = out.resolve(xo.get(b"P0").unwrap()); + let Object::Stream(form) = &*form_obj else { panic!("form stream") }; + let body = form.decoded().unwrap(); + pdfcraft_content::parse(&body) + .ops + .into_iter() + .filter(|op| op.op == b"Tj") + .filter_map(|op| op.operands.first().and_then(Object::as_string).map(|s| s.bytes.to_vec())) + .collect() + }; + + // Without a newline at the end of s1, `% comment` swallows line 1 of s2 (`(HIDDEN) Tj`). + let commented = build_doc(b"q Q % trailing comment"); + assert!(!text_reads_up(&commented, 0), "commented-out vertical text on piece 2's first line is not counted"); + let printed = impose(&commented, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); + assert_eq!(form_strings(printed), [b"SHOWN".to_vec()], "impose does not un-comment piece 2's first line"); + + // Control: when s1 terminates the comment with a newline, line 1 of s2 is drawn and counted. + let with_newline = build_doc(b"q Q % trailing comment\n"); + assert!(text_reads_up(&with_newline, 0), "6 vertical vs 5 horizontal chars when line 1 is not commented out"); + let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); + assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]); +} diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs new file mode 100644 index 0000000000..0d3107d1d7 --- /dev/null +++ b/crates/print/src/tests/appearances.rs @@ -0,0 +1,395 @@ +use super::*; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind}; + +fn numbers(values: &[f64]) -> Object { + Object::Array(values.iter().copied().map(Object::Real).collect()) +} + +// Contributor-original artwork uses a named color resource, without fonts or external assets. +fn widget_fixture(subtype: Option, matrix: Option<[f64; 6]>) -> (Document, ObjRef, ObjRef) { + let mut doc = fixture(1); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut colors = Dict::new(); + colors.set(b"Ink".to_vec(), Object::name("DeviceRGB")); + let mut resources = Dict::new(); + resources.set(b"ColorSpace".to_vec(), Object::Dict(colors)); + let resources = doc.add(Object::Dict(resources)); + let mut d = Dict::new(); + d.set(b"BBox".to_vec(), numbers(&[0.0, 0.0, 20.0, 10.0])); + d.set(b"Resources".to_vec(), Object::Ref(resources)); + d.set(b"PrivateKey".to_vec(), Object::name("PreserveMe")); + if let Some(subtype) = subtype { + d.set(b"Subtype".to_vec(), subtype); + } + if let Some(matrix) = matrix { + d.set(b"Matrix".to_vec(), numbers(&matrix)); + } + let mut stream = Stream::flate(d, b"/Ink cs 0 1 0 scn 0 0 20 10 re f"); + stream.dict.set(b"DecodeParms".to_vec(), Object::Dict(Dict::new())); + let ap = doc.add(Object::Stream(stream)); + let mut widget = Dict::new(); + widget.set(b"Subtype".to_vec(), Object::name("Widget")); + widget.set(b"FT".to_vec(), Object::name("Tx")); + widget.set(b"F".to_vec(), Object::Int(4)); + widget.set(b"Rect".to_vec(), numbers(&[10.0, 20.0, 50.0, 40.0])); + let mut appearance = Dict::new(); + appearance.set(b"N".to_vec(), Object::Ref(ap)); + widget.set(b"AP".to_vec(), Object::Dict(appearance)); + let widget = doc.add(Object::Dict(widget)); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 1 rg 0 0 8 8 re f"))); + doc.update_dict(page, |d| { + d.set(b"MediaBox".to_vec(), numbers(&[0.0, 0.0, 100.0, 100.0])); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget)])); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"Resources".to_vec(), Object::Dict(Dict::new())); + }) + .unwrap(); + (doc, ap, widget) +} + +fn print_settings(content: Content) -> Settings { + Settings { pages: vec![0], paper: (100.0, 100.0), layout: Layout::Size(SizeMode::Actual), content, ..Settings::default() } +} + +fn render(bytes: Vec) -> RenderedPage { + render_page(bytes, 0) +} + +fn render_page(bytes: Vec, index: usize) -> RenderedPage { + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: index, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert_eq!(page.error, None); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); + assert_eq!((page.width, page.height), (100, 100)); + page +} + +fn pixel(page: &RenderedPage, x: usize, y: usize) -> &[u8] { + let offset = (y * page.width as usize + x) * 4; + &page.rgba[offset..offset + 4] +} + +fn saved(doc: &Document) -> Vec { + write_full(doc, &SaveOptions::default()).unwrap() +} + +fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef { + let resources = doc.resolve(owner.get(b"Resources").unwrap()); + let objects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + objects.as_dict().unwrap().get(name).unwrap().as_ref().unwrap() +} + +/// Printing keeps occupied page resource names and distinct annotation appearances intact, +/// whether the page resources are direct or inherited from the page tree. +#[test] +fn annotation_appearances_do_not_replace_page_xobjects() { + for inherited in [false, true] { + let (mut doc, ap, widget) = widget_fixture(Some(Object::name("Form")), None); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut xobjects = Dict::new(); + for (name, color) in [("PCPrA0", "1 0 0"), ("PCPrA1", "0 0 1"), ("PCPrA3", "1 0 0")] { + let dict = Dict::from_iter([ + (b"Type".to_vec(), Object::name("XObject")), + (b"Subtype".to_vec(), Object::name("Form")), + (b"BBox".to_vec(), numbers(&[0.0, 0.0, 10.0, 10.0])), + ]); + let form = doc.add(Object::Stream(Stream::flate(dict, format!("{color} rg 0 0 10 10 re f").as_bytes()))); + xobjects.set(name.as_bytes().to_vec(), Object::Ref(form)); + } + let xobjects = doc.add(Object::Dict(xobjects)); + let resources = doc.add(Object::Dict(Dict::from_iter([(b"XObject".to_vec(), Object::Ref(xobjects))]))); + let contents = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"q 1 0 0 1 60 20 cm /PCPrA0 Do Q\nq 1 0 0 1 70 20 cm /PCPrA1 Do Q\nq 1 0 0 1 80 20 cm /PCPrA3 Do Q", + ))); + let second_ap = doc.add(Object::Stream(Stream::flate(doc.get(ap).as_dict().unwrap().clone(), b"/Ink cs 1 1 0 scn 0 0 20 10 re f"))); + let mut second_widget = doc.get(widget).as_dict().unwrap().clone(); + second_widget.set(b"Rect".to_vec(), numbers(&[10.0, 50.0, 50.0, 70.0])); + second_widget.set(b"AP".to_vec(), Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Ref(second_ap))]))); + let second_widget = doc.add(Object::Dict(second_widget)); + doc.update_dict(page, |d| { + d.set(b"Resources".to_vec(), Object::Ref(resources)); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget), Object::Ref(second_widget)])); + }) + .unwrap(); + if inherited { + let parent = doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"Resources".to_vec(), Object::Ref(resources))).unwrap(); + doc.update_dict(page, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + } + let before = saved(&doc); + let source = render(before.clone()); + for (x, color) in [(65, [255, 0, 0, 255]), (75, [0, 0, 255, 255]), (85, [255, 0, 0, 255])] { + assert_eq!(pixel(&source, x, 75), color, "source page artwork"); + } + for (y, color) in [(40, [255, 255, 0, 255]), (70, [0, 255, 0, 255])] { + assert_eq!(pixel(&source, 30, y), color, "source widget appearance"); + } + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + for (x, color) in [(65, [255, 0, 0, 255]), (75, [0, 0, 255, 255]), (85, [255, 0, 0, 255])] { + assert_eq!(pixel(&printed, x, 75), color, "printed page artwork, inherited={inherited}"); + } + for (y, color) in [(40, [255, 255, 0, 255]), (70, [0, 255, 0, 255])] { + assert_eq!(pixel(&printed, 30, y), color, "printed widget appearance, inherited={inherited}"); + } + assert_eq!(printed.rgba, source.rgba, "printing must retain page artwork and both appearances, inherited={inherited}"); + assert_eq!(saved(&doc), before, "printing must leave the source resources unchanged"); + } +} + +#[test] +fn missing_subtype_appearance_survives_vector_print() { + let (control, _, _) = widget_fixture(Some(Object::name("Form")), None); + let control = render(impose(&control, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&control, 30, 70), [0, 255, 0, 255], "valid Form control"); + let (doc, ap, _) = widget_fixture(None, None); + let before = saved(&doc); + let state = doc.read_state(); + let source_bytes = doc.bytes().clone(); + let graph: Vec<_> = doc.scan_objects().collect(); + let original = doc.get(ap); + let source = render(before.clone()); + assert_eq!(pixel(&source, 30, 70), [0, 255, 0, 255], "source display"); + let output = impose(&doc, &print_settings(Content::Document)).unwrap(); + let reopened = Document::open(Arc::new(output.clone())).unwrap(); + assert_eq!(pdfcraft_model::pages(&reopened).len(), 1); + let printed = render(output); + assert_eq!(pixel(&printed, 5, 5), [255, 255, 255, 255], "outside the appearance"); + assert_eq!(pixel(&printed, 30, 70), [0, 255, 0, 255], "imposed missing-Subtype appearance"); + assert_eq!(printed.rgba, control.rgba); + assert!(state.matches(&doc)); + assert_eq!(doc.bytes(), &source_bytes); + assert_eq!(doc.scan_objects().collect::>(), graph); + assert_eq!(doc.get(ap), original); + assert_eq!(saved(&doc), before); + let source = Document::open(Arc::new(before)).unwrap(); + let page = &pdfcraft_model::pages(&source)[0]; + let annots = source.resolve(page.dict.get(b"Annots").unwrap()); + let widget = source.resolve(&annots.as_array().unwrap()[0]); + let selected = appearance(&source, widget.as_dict().unwrap()).unwrap(); + let Object::Stream(stream) = &*source.get(selected) else { panic!("source AP stream") }; + assert!(!stream.dict.contains(b"Subtype") && !stream.dict.contains(b"Type")); + assert_eq!(stream.decoded().unwrap(), b"/Ink cs 0 1 0 scn 0 0 20 10 re f"); + assert_eq!(source.resolve(stream.dict.get(b"Resources").unwrap()), doc.resolve(original.as_dict().unwrap().get(b"Resources").unwrap())); + let sheet = &pdfcraft_model::pages(&reopened)[0]; + let form = reopened.get(xobject(&reopened, &sheet.dict, b"P0")); + let promoted = reopened.get(xobject(&reopened, form.as_dict().unwrap(), b"PCPrA0")); + let Object::Stream(promoted) = &*promoted else { panic!("promoted AP stream") }; + assert_eq!(promoted.dict.name(b"Subtype"), Some(b"Form".as_slice())); + let Object::Stream(original) = &*original else { panic!("original AP stream") }; + assert_eq!(promoted.raw, original.raw); + let mut expected = original.dict.clone(); + let mut actual = promoted.dict.clone(); + actual.remove(b"Subtype"); + actual.remove(b"Length"); + expected.remove(b"Length"); + let expected_resources = doc.resolve(expected.remove(b"Resources").as_ref().unwrap()); + let actual_resources = reopened.resolve(actual.remove(b"Resources").as_ref().unwrap()); + assert_eq!(actual_resources, expected_resources); + // The writer can spell integral Real values as integers. Compare their PDF values. + let mut actual_bytes = Vec::new(); + let mut expected_bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(actual), &mut actual_bytes); + pdfcraft_cos::serialize(&Object::Dict(expected), &mut expected_bytes); + assert_eq!(actual_bytes, expected_bytes); +} + +#[test] +fn promotion_preserves_streams_and_explicit_subtypes() { + for subtype in [None, Some(Object::name("Form")), Some(Object::name("Image")), Some(Object::Null), Some(Object::Int(7))] { + let (mut doc, ap, _) = widget_fixture(subtype.clone(), Some([2.0, 0.0, 0.0, 3.0, 5.0, 7.0])); + let original = doc.get(ap); + let pages = pdfcraft_model::pages(&doc); + let form = page_form(&mut doc, &pages, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap(); + assert_eq!(doc.get(ap), original, "original in the print clone"); + let promoted = xobject(&doc, doc.get(form).as_dict().unwrap(), b"PCPrA0"); + if subtype.is_some() { + assert_eq!(promoted, ap, "an explicit subtype keeps its object"); + let printed = Document::open(Arc::new(impose(&doc, &print_settings(Content::Document)).unwrap())).unwrap(); + let sheet = &pdfcraft_model::pages(&printed)[0]; + let page_form = printed.get(xobject(&printed, &sheet.dict, b"P0")); + let printed_ap = printed.get(xobject(&printed, page_form.as_dict().unwrap(), b"PCPrA0")); + assert_eq!(printed_ap.as_dict().unwrap().get(b"Subtype"), subtype.as_ref(), "explicit subtype survives serialization"); + } else { + assert_ne!(promoted, ap, "repair needs a separate object"); + let Object::Stream(mut expected) = (*original).clone() else { panic!("AP stream") }; + expected.dict.set(b"Subtype".to_vec(), Object::name("Form")); + assert_eq!(*doc.get(promoted), Object::Stream(expected), "only Subtype changes, including encoded data and resource refs"); + } + } +} + +#[test] +fn selected_shared_appearances_keep_placement_flags_and_content_modes() { + let mut default_images = Vec::new(); + for matrix in [None, Some([2.0, 0.0, 0.0, 3.0, 5.0, 7.0])] { + let (mut doc, ap, widget) = widget_fixture(None, matrix); + // Optional Type does not replace the required Subtype. + doc.update_dict(ap, |d| d.set(b"Type".to_vec(), Object::name("XObject"))).unwrap(); + let Object::Stream(mut off) = (*doc.get(ap)).clone() else { panic!("AP stream") }; + off.raw = Stream::flate(off.dict.clone(), b"/Ink cs 1 0 0 scn 0 0 20 10 re f").raw; + let off = doc.add(Object::Stream(off)); + let mut states = Dict::new(); + states.set(b"On".to_vec(), Object::Ref(ap)); + states.set(b"Off".to_vec(), Object::Ref(off)); + let mut appearances = Dict::new(); + appearances.set(b"N".to_vec(), Object::Dict(states)); + doc.update_dict(widget, |d| { + d.set(b"AP".to_vec(), Object::Dict(appearances)); + d.set(b"AS".to_vec(), Object::name("On")); + }) + .unwrap(); + let mut widgets = vec![Object::Ref(widget)]; + for (rect, flags) in [([60.0, 20.0, 90.0, 40.0], 4), ([10.0, 60.0, 50.0, 80.0], 0), ([60.0, 60.0, 90.0, 80.0], 6)] { + let mut copy = doc.get(widget).as_dict().unwrap().clone(); + copy.set(b"Rect".to_vec(), numbers(&rect)); + copy.set(b"F".to_vec(), Object::Int(flags)); + widgets.push(Object::Ref(doc.add(Object::Dict(copy)))); + } + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(widgets))).unwrap(); + let before = saved(&doc); + for (mode, content) in + [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly].into_iter().enumerate() + { + let output = impose(&doc, &print_settings(content)).unwrap(); + let reopened = Document::open(Arc::new(output.clone())).unwrap(); + assert_eq!(pdfcraft_model::pages(&reopened).len(), 1); + let sheet = &pdfcraft_model::pages(&reopened)[0]; + let form = reopened.get(xobject(&reopened, &sheet.dict, b"P0")); + assert_eq!( + xobject(&reopened, form.as_dict().unwrap(), b"PCPrA0"), + xobject(&reopened, form.as_dict().unwrap(), b"PCPrA1"), + "selected shared appearance keeps one promotion" + ); + let page = render(output); + for (x, y, expected) in + [(30, 70, [0, 255, 0, 255]), (75, 70, [0, 255, 0, 255]), (30, 30, [255, 255, 255, 255]), (75, 30, [255, 255, 255, 255])] + { + assert_eq!(pixel(&page, x, y), expected, "{matrix:?}, {content:?}, ({x},{y})"); + } + let expected = if content == Content::FormFieldsOnly { [255, 255, 255, 255] } else { [0, 0, 255, 255] }; + assert_eq!(pixel(&page, 4, 96), expected, "document artwork under {content:?}"); + if matrix.is_none() { + default_images.push(page.rgba); + } else { + assert_eq!(page.rgba, default_images[mode], "Matrix must preserve the complete appearance extent under {content:?}"); + } + } + assert_eq!(saved(&doc), before, "shared source graph stays unchanged"); + } +} + +#[test] +fn shared_promotions_span_pages_without_merging_distinct_sources() { + let mut doc = fixture(2); + // Contributor-original drawing and a bounded PDF comment expose repeated encoded payloads. + let mut payload = b"/Ink cs 0 1 0 scn 0 0 20 10 re f\n%".to_vec(); + payload.extend(std::iter::repeat_n(b'x', 32 * 1024)); + payload.push(b'\n'); + let mut appearances = Vec::new(); + for marker in ["Shared", "Distinct"] { + let mut colors = Dict::new(); + colors.set(b"Ink".to_vec(), Object::name("DeviceRGB")); + let mut resources = Dict::new(); + resources.set(b"ColorSpace".to_vec(), Object::Dict(colors)); + resources.set(b"PrivateMarker".to_vec(), Object::name(marker)); + let resources = doc.add(Object::Dict(resources)); + let mut dict = Dict::new(); + dict.set(b"BBox".to_vec(), numbers(&[0.0, 0.0, 20.0, 10.0])); + dict.set(b"Resources".to_vec(), Object::Ref(resources)); + appearances.push(doc.add(Object::Stream(Stream::from_raw(dict, payload.clone())))); + } + let pages = pdfcraft_model::pages(&doc); + for (index, page) in pages.iter().enumerate() { + let mut widgets = Vec::new(); + for (ap, rect) in [(appearances[0], [10.0, 20.0, 50.0, 40.0]), (appearances[0], [60.0, 20.0, 90.0, 40.0])] + .into_iter() + .chain((index == 1).then_some((appearances[1], [10.0, 60.0, 50.0, 80.0]))) + { + let mut widget = Dict::new(); + widget.set(b"Subtype".to_vec(), Object::name("Widget")); + widget.set(b"FT".to_vec(), Object::name("Tx")); + widget.set(b"F".to_vec(), Object::Int(4)); + widget.set(b"Rect".to_vec(), numbers(&rect)); + let mut appearance = Dict::new(); + appearance.set(b"N".to_vec(), Object::Ref(ap)); + widget.set(b"AP".to_vec(), Object::Dict(appearance)); + widgets.push(Object::Ref(doc.add(Object::Dict(widget)))); + } + doc.update_dict(page.obj, |dict| { + dict.set(b"MediaBox".to_vec(), numbers(&[0.0, 0.0, 100.0, 100.0])); + dict.set(b"Annots".to_vec(), Object::Array(widgets)); + dict.remove(b"Contents"); + dict.set(b"Resources".to_vec(), Object::Dict(Dict::new())); + }) + .unwrap(); + } + let before = saved(&doc); + let state = doc.read_state(); + let source_bytes = doc.bytes().clone(); + let graph: Vec<_> = doc.scan_objects().collect(); + let originals: Vec<_> = appearances.iter().map(|ap| doc.get(*ap)).collect(); + let settings = Settings { pages: vec![0, 1], ..print_settings(Content::Document) }; + for _ in 0..2 { + let output = impose(&doc, &settings).unwrap(); + let reopened = Document::open(Arc::new(output.clone())).unwrap(); + let sheets = pdfcraft_model::pages(&reopened); + assert_eq!(sheets.len(), 2); + let mut shared = Vec::new(); + let mut distinct = None; + for (index, sheet) in sheets.iter().enumerate() { + let form = reopened.get(xobject(&reopened, &sheet.dict, b"P0")); + let form = form.as_dict().unwrap(); + shared.push(xobject(&reopened, form, b"PCPrA0")); + shared.push(xobject(&reopened, form, b"PCPrA1")); + if index == 1 { + distinct = Some(xobject(&reopened, form, b"PCPrA2")); + } + let rendered = render_page(output.clone(), index); + assert_eq!(pixel(&rendered, 30, 70), [0, 255, 0, 255]); + assert_eq!(pixel(&rendered, 75, 70), [0, 255, 0, 255]); + assert_eq!(pixel(&rendered, 30, 30), if index == 1 { [0, 255, 0, 255] } else { [255, 255, 255, 255] }); + assert_eq!(pixel(&rendered, 75, 30), [255, 255, 255, 255]); + assert_eq!(pixel(&rendered, 5, 5), [255, 255, 255, 255]); + } + let copies = reopened + .scan_objects() + .filter(|(_, object)| matches!(&**object, Object::Stream(stream) if stream.raw.as_ref() == payload.as_slice())) + .count(); + eprintln!( + "shared promotion output: bytes={}, payload_bytes={}, copies={copies}, shared={shared:?}, distinct={distinct:?}", + output.len(), + payload.len() + ); + assert!(state.matches(&doc)); + assert_eq!(doc.bytes(), &source_bytes); + assert_eq!(doc.scan_objects().collect::>(), graph); + assert_eq!(saved(&doc), before); + for (ap, original) in appearances.iter().zip(&originals) { + assert_eq!(doc.get(*ap), *original); + } + assert_eq!(copies, 2, "one encoded payload per distinct source appearance"); + assert!(shared.iter().all(|ap| *ap == shared[0]), "shared uses span both page forms"); + let distinct = distinct.unwrap(); + assert_ne!(shared[0], distinct, "identical bytes do not merge source references"); + for (ap, marker) in [(shared[0], b"Shared".as_slice()), (distinct, b"Distinct".as_slice())] { + let object = reopened.get(ap); + let Object::Stream(stream) = &*object else { panic!("promoted appearance stream") }; + assert_eq!(stream.raw.as_ref(), payload.as_slice()); + assert_eq!(stream.dict.name(b"Subtype"), Some(b"Form".as_slice())); + let resources = reopened.resolve(stream.dict.get(b"Resources").unwrap()); + assert_eq!(resources.as_dict().unwrap().name(b"PrivateMarker"), Some(marker)); + } + } +} diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 49593855e2..8d1515d8a4 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -20,4 +20,5 @@ pdfcraft-xfa = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-filters = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/README.md b/crates/redact/README.md index 7cd5ded1e4..8383f0119f 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -18,9 +18,15 @@ clear_marks(&mut doc, None)?; // remove marks without app more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is inside; - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared - (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PdfCraft can't - re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an - area are removed. Images with separate `/SMask` or `/Mask` data are removed whole; + (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax as the last + filter, decoded through `Document::decoded_image_within`; `/DecodeParms` given by reference, + numbers inside included, count). Codecs redaction doesn't decode (DCT, JPX, JBIG2) are removed + whole (fail-closed), and so is Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's + renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other + rows than the decoder reads, so a mark over what is shown needn't cover what is cleared; inline images under an area are removed. Images with separate `/SMask` + or `/Mask` data, and images whose data decodes to more than three times their declared raster + and a byte per row, plus 4 KiB (ASCIIHex text broken into lines under Flate is a little over + twice; for a fax image's code, about a byte per pixel), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; @@ -31,8 +37,15 @@ clear_marks(&mut doc, None)?; // remove marks without app page as a `/PCMark /Redaction` stream. A verification pass re-reads each redacted page and fails the operation if any glyph or inline -image is still under an area. Content that can't be decoded makes the operation fail rather than -leave it unredacted. +image is still under an area. It checks every content stream the page had, whatever its +dictionary calls it; only the overlay streams this `apply` added (`Report::overlays`) are skipped. +Text is placed with the font and size set by `Tf` or by an ExtGState's `/Font` through `gs`, and a +form XObject starts from the text state of the content that draws it. Content that can't be +decoded makes the operation fail rather than leave it unredacted, and so does an inline image the +renderer's tokenizer reads no image for (`RedactError::Unreadable`): where the page's operators +resume after it would be a guess. A Redact annotation with neither a usable `/QuadPoints` array nor a usable +`/Rect` makes it fail with `RedactError::UnreadableMark` before anything changes, instead of +skipping that mark. The count covers the marks on the pages being applied. Before changing any page, `apply` prepares the redaction appearances with a shared 8 MiB budget for their uncompressed PDF content across the selected pages. It bounds repeated-text @@ -42,17 +55,25 @@ the document. Reduce the marked area, disable repeated text, or apply fewer page This limits appearance generation; it is not a limit on the input PDF or the whole process's memory. Structure cleanup resolves named MCIDs and indirect content references. Form content -references are updated to the cleaned copy; unchanged sibling descriptions remain. +references are updated to the cleaned copy; unchanged sibling descriptions remain. The structure +tree's object references (`/OBJR`) to the annotations and form fields `apply` removes are dropped, +as Sanitize drops them, so a full rewrite of a tagged PDF can't keep a removed comment or field value. +A tree where that can't be done (an element written in place, or one too large to walk) makes +`apply` fail with `RedactError::Tags` rather than leave the annotation reachable. -Replacement descriptions are removed only from marked-content spans changed by cleanup. -Unchanged spans retain their descriptions; unused property entries are retired during redaction +Replacement descriptions (`/Alt`, `/ActualText`, `/E`) are removed from marked-content spans +changed by cleanup, from every structure element above them (found through both `/P` and `/K`), +and from elements that lose an annotation reference, which also lose `/T`. An element written in +place above one whose descriptions go makes `apply` fail with `RedactError::Tags`. Unchanged +spans retain their descriptions; unused property entries are retired during redaction and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still used by surviving pages or forms are kept. A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's `/Resources` is then retired unless another page still inherits it, so it cannot keep a removed -XObject reachable. Pages that aren't redacted are left untouched. +XObject reachable. Surviving form XObjects lose the resource entries for removed XObjects their own +content doesn't draw, for the same reason. Pages that aren't redacted are left untouched. ## Remove hidden information / Sanitize @@ -65,10 +86,18 @@ sanitize::sanitize(&mut doc)?; // all of them Categories: metadata (`/Info`, XMP), file attachments, comments, form fields (flattened so the values stay visible), hidden text (render modes 3 and 7, or wholly off the page), hidden layers (content of off optional-content groups, then the groups), bookmarks, links/actions/JavaScript, -and `/PieceInfo` private data. Both make the next save a full rewrite. +and `/PieceInfo` private data. XMP metadata, private data, associated files (`/AF`) and +additional actions (`/AA`) are removed from every object reachable from the catalog that owns +them (images, fonts, Forms, annotations and fields as well as the catalog and pages), never from +name maps such as resource dictionaries. Content is read strictly: a page whose content can't be +read in full is refused, not cleaned in part. Both make the next save a full rewrite. `patterns` finds phone numbers, e-mail addresses, credit card numbers (Luhn-checked), US Social Security numbers and dates for Search & Redact. +A phone label such as `Tel.` or `Fax:` also enables Italian international geographic numbers with a slash, such as `Tel. +39 0426/123456` or `Fax: +390426/123456` (#3205 follow-up). +The country code and national number are matched together, with the significant initial `0` and 6 to 11 national digits described in the [AGCOM numbering plan published by ITU](https://www.itu.int/dms_pub/itu-t/oth/02/02/t020200006b0001pdfe.pdf). +This narrow case accepts at most one space after `+39` and one slash immediately after the area code; international slash numbers without a phone label, or with country codes other than +39, remain unsupported. +Patterns are heuristic: dates, protocol identifiers and longer number runs are retained, and users still need to review the marks before applying them. `codes` holds the redaction code sets (U.S. FOIA and U.S. Privacy Act exemptions): `CodeSet::from_id("foia")?.overlay(&["(b)(6)"])` gives the overlay text for the picked codes. diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 23ef2559a5..f662849c12 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,8 +1,10 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel -//! whose centre maps into a region is set to zero (no ink for image masks), and the result is +//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is //! written as a new Flate image; the original object is untouched (other pages may use it). -//! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the -//! caller removes the whole image instead; that is fail-closed. +//! CCITT fax images decode through `Document::decoded_image_within`, but for Group 3 on bytes, +//! which PdfCraft's renderer may draw as other rows. Images whose codec PdfCraft can't decode +//! (DCT, JPX, JBIG2), and Group 3 on bytes, return `None`, and the caller removes the whole image +//! instead; that is fail-closed. use pdfcraft_content::{Matrix, overlaps}; use pdfcraft_cos::{Document, Object, Stream}; @@ -33,7 +35,7 @@ fn components(doc: &Document, s: &Stream) -> Option { }) } -/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel centre is covered, +/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects, /// `Err(())` when the image can't be cleared (the caller removes it). #[allow(clippy::result_unit_err)] pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result, ()> { @@ -51,15 +53,29 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL" ) }; - let ok = match s.dict.get(b"Filter") { - None => true, - Some(Object::Name(n)) => supported(n), - Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some_and(supported)), - _ => false, + // A CCITT fax image (its last filter) decodes through `Document::decoded_image_within`, after the + // filters before it, so a scan stored as Group 4 is cleared like any other image. + let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF")); + let readable = |f: &Object| f.as_name().is_some_and(supported); + let (ok, is_fax) = match s.dict.get(b"Filter") { + None => (true, false), + Some(f @ Object::Name(_)) => (readable(f) || fax(f), fax(f)), + Some(Object::Array(a)) => match a.split_last() { + Some((last, before)) if fax(last) => (before.iter().all(readable), true), + _ => (a.iter().all(readable), false), + }, + _ => (false, false), }; if !ok { return None; } + // Group 3 on bytes is removed whole for now. The decoder reads it by its end-of-line codes + // when the data starts with one, but PdfCraft's renderer draws such rows (the fill ending + // each code on a byte) as other rows, so the pixels a mark covers on screen needn't be the + // ones cleared. + if is_fax && doc.fax_params(s).is_none_or(|p| p.k >= 0 && p.encoded_byte_align) { + return None; + } let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; @@ -77,32 +93,50 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let bytes = row.checked_mul(h)?; let row_bits = row.checked_mul(8)?; bytes.checked_mul(8)?; - let mut data = s.decoded().ok()?; + // Decode no further than the raster can need: a filter stage may legitimately produce up to + // three bytes for each of it and a PNG predictor's byte per row (ASCIIHex text under Flate, + // two digits a byte broken into lines or spaced), as the optimizer allows. A fax image's + // code, which the filters before its decoder produce, takes at most about a byte per pixel + // plus each row's end. Data past that can only be a decompression bomb; the image is then + // removed whole. + let allowance = if is_fax { + w.checked_mul(h)?.checked_add(h.checked_mul(4)?)?.checked_add(4096)? + } else { + bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)? + }; + // Through the document, which reads `/DecodeParms` given by reference (`Stream::decoded*` + // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels). + let mut data = doc.decoded_image_within(s, allowance).ok()?; if data.len() < bytes { return None; } data.truncate(bytes); // Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint. let clear_bit = if mask { - let inverted = s.dict.get(b"Decode").and_then(Object::as_array).and_then(|d| d.first()).and_then(Object::as_f64) == Some(1.0); + // The array, and its first number, given by reference too. + let decode = s.dict.get(b"Decode").map(|d| doc.resolve(d)); + let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| doc.resolve(v)); + let inverted = first.as_deref().and_then(Object::as_f64) == Some(1.0); !inverted } else { false }; let mut cleared = 0usize; for j in 0..h { - // Image space: (0,0) is the bottom-left of the unit square; row 0 is the top. - let v = 1.0 - (j as f64 + 0.5) / h as f64; - let (ax, ay) = ctm.apply(0.0, v); - let (bx, by) = ctm.apply(1.0, v); - let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)]; - if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) { + // Image space has its origin at the bottom left; row 0 is at the top. + let v0 = 1.0 - (j + 1) as f64 / h as f64; + let v1 = 1.0 - j as f64 / h as f64; + let row_box = ctm.bbox([0.0, v0, 1.0, v1]); + if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) { continue; } for i in 0..w { - let u = (i as f64 + 0.5) / w as f64; - let (x, y) = ctm.apply(u, v); - if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) { + let u0 = i as f64 / w as f64; + let u1 = (i + 1) as f64 / w as f64; + // The bounding box may cover extra pixels for a rotated or skewed image; that + // conservative over-clear prevents a touched sample surviving in the file. + let pixel_box = ctm.bbox([u0, v0, u1, v1]); + if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) { continue; } cleared += 1; diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 52ca6c276f..ac69c73acb 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,12 +21,31 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; -const MAX_DEPTH: usize = 12; +mod layers; +mod prune; + +use layers::strip_hidden_layers; +use prune::{clip_out, extgstate_font, prune_form_resources}; +pub(crate) use prune::{prune, prune_properties, resource_ops, unlink_doomed}; + +/// How deep this pass follows form XObjects. A form past it is removed rather than walked, which +/// is the fail-safe choice for a redaction -- marked content inside a form we cannot read would +/// otherwise survive -- but it also removes whatever else that form drew, marked or not. So the +/// limit has to reach as deep as anything the reader can see: the renderer follows forms to 50 +/// (`MAX_NESTED_INTERPRETATION_DEPTH`) and so does Edit text's reader (`text::MAX_FORM_DEPTH`). +/// At 12, a page with a 13-deep chain lost the text of its innermost form even with nothing in it +/// marked (#3645); past 50 nothing is drawn by any of the three, so removing it costs nothing +/// visible. +pub(crate) const MAX_DEPTH: usize = 50; + +// A form a reader still shows must be walkable here, or a redaction removes content that is +// visible; the build says so rather than a test, so the two limits cannot drift apart. +const _: () = assert!(MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Mode { @@ -74,6 +93,10 @@ pub(crate) struct Output { /// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was /// in force: the operation must fail closed rather than leave that text under the mark. pub vertical: bool, + /// The content holds an inline image the renderer reads no image for + /// ([`pdfcraft_content::Parsed::ambiguous_images`]): what this scope placed after it may not + /// be what the page shows, so a redaction must not trust it. + pub ambiguous: bool, pub tag_changes: Vec<(ObjRef, ObjRef, HashSet, HashSet)>, pub error: Option, } @@ -84,58 +107,55 @@ pub(crate) struct Scope<'a> { pub report: &'a mut Report, /// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box). pub hidden_text: Option<[f64; 4]>, - /// Sanitize: optional content groups (and membership dictionaries) that are off; content - /// marked with them is removed. - pub hidden_layers: Vec, + /// Sanitize: optional content groups that are off; content marked with them is removed. + pub hidden_layers: HashSet, + /// Whether this pass is evaluating optional-content rules. An empty off set is meaningful: + /// policies such as `/AllOff` can still hide content when every group is on. + pub layers_enabled: bool, + /// Off groups referenced by optional content that remains visible and therefore still needs + /// its original default state after sanitization. + pub retained_off_layers: HashSet, + /// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions. + ocmd_cache: HashMap, + /// Direct `/OC` dictionaries on XObjects have no object reference of their own. Cache them + /// by the containing XObject so repeated `Do` calls share the same bounded evaluation. + xobject_oc_cache: HashMap, /// Content removed from hidden layers (blocks and XObjects). pub layer_blocks: usize, fonts: HashMap, Rc>, used_names: Vec>, depth: usize, + /// Form entries and decoded bytes left for this page, its content streams already charged: + /// shared forms drawn from other shared forms would otherwise multiply the work with every + /// level. + budget: DecodeBudget, } impl<'a> Scope<'a> { - pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self { + /// A scope whose form walk spends `budget`, what is left of the page's allowance after its + /// content streams were decoded. + pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self { Scope { rects, mode, report, hidden_text: None, - hidden_layers: Vec::new(), + hidden_layers: HashSet::new(), + layers_enabled: false, + retained_off_layers: HashSet::new(), + ocmd_cache: HashMap::new(), + xobject_oc_cache: HashMap::new(), layer_blocks: 0, fonts: HashMap::new(), used_names: Vec::new(), depth: 0, + budget, } } /// Sanitizing visits every form XObject (not only those under a region). fn everywhere(&self) -> bool { - self.hidden_text.is_some() || !self.hidden_layers.is_empty() - } - - /// Is this optional-content reference (an OCG or OCMD) hidden? - fn layer_hidden(&self, doc: &Document, o: &Object) -> bool { - if self.hidden_layers.is_empty() { - return false; - } - if let Some(r) = o.as_ref() - && self.hidden_layers.contains(&r) - { - return true; - } - // A membership dictionary (default policy AnyOn): hidden when all its groups are. - let d = doc.resolve(o); - let Some(d) = d.as_dict() else { return false }; - if d.name(b"Type") != Some(b"OCMD") { - return false; - } - let groups: Vec = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { - Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), - Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(), - None => Vec::new(), - }; - !groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g)) + self.hidden_text.is_some() || self.layers_enabled } fn hits(&self, b: [f64; 4]) -> bool { @@ -194,301 +214,6 @@ fn fresh_name(scope: &mut Scope<'_>, xobjects: &Dict) -> Vec { } } -/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, -/// so a full-rewrite save stops reaching the original image or form it named. A name that some -/// surviving `Do` still draws (the same image shown again outside a mark) is kept. -pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { - if replaced.is_empty() { - return; - } - let joined = Pieces::join(content); - let ops = joined.parse().ops; - let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.name(0) == Some(n)); - for n in replaced { - if !still_drawn(n) { - xo.remove(n); - } - } -} - -/// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or -/// through a form that has no /Resources of its own (such a form draws through its caller's). -/// `None` when that cannot be told (a form that will not decode, or nesting past the limit). -fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { - let joined = Pieces::join(content); - let ops = joined.parse().ops; - for op in ops.iter().filter(|op| op.is("Do")) { - let Some(n) = op.name(0) else { continue }; - if n == name { - return Some(true); - } - let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; - let obj = doc.get(r); - let Object::Stream(s) = &*obj else { continue }; - if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { - continue; - } - if depth >= MAX_DEPTH { - return None; - } - let Ok(data) = s.decoded() else { return None }; - match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { - Some(false) => {} - other => return other, - } - } - Some(false) -} - -/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource -/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the -/// originals stay reachable, and so stay in a full-rewrite save, when: -/// - the page inherited its /Resources from a /Pages node, which still lists them; -/// - several pages share one resource dictionary, and another page still refers to it; -/// - a form without /Resources drew them through the page's dictionary. -/// -/// An entry goes unless a page that draws through that dictionary still draws the name (the same -/// image shown on another page, or again outside a mark). A dictionary that loses an entry is -/// replaced by a private copy on its node, so a dictionary object shared with other nodes is -/// left as it is for them. Retired properties are likewise unlinked only when none of the -/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. -pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { - if doomed.is_empty() && retired_properties.is_empty() { - return Ok(()); - } - let doomed: HashSet = doomed.iter().copied().collect(); - let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; - // Every page-tree node with a /Resources of its own, and the pages that draw through it - // (the nearest such node above a page, or the page itself). Cycles are cut as in - // `pdfcraft_model::pages`. - let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); - let mut seen = HashSet::new(); - let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; - while let Some((node, mut holder)) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - if d.contains(b"Resources") { - holders.push((node, Vec::new())); - holder = Some(holders.len() - 1); - } - let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); - if is_pages { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); - } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { - h.1.push(node); - } - } - let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); - let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); - for (node, users) in holders { - let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { - continue; - }; - let mut xo = res_dict(doc, &res, b"XObject"); - let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); - let properties = res_dict(doc, &res, b"Properties"); - let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); - if names.is_empty() && property_names.is_empty() { - continue; - } - let mut contents = Vec::with_capacity(users.len()); - for user in &users { - let page = doc.get(*user).as_dict().cloned().unwrap_or_default(); - // A page whose content cannot be read may still draw the name: fail closed. - contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); - } - let mut pruned = false; - for n in names { - let mut drawn = Some(false); - for content in &contents { - drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); - if drawn != Some(false) { - break; - } - } - match drawn { - Some(true) => {} - Some(false) => { - xo.remove(&n); - pruned = true; - } - None => return Err(RedactError::Residue(1)), - } - } - if pruned { - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; - if pruned { - doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; - } - } - // A surviving form can own the same old resource dictionary as a rewritten page. Its - // unused descriptions remain reachable too, even after every page holder is cleaned. - unlink_form_properties(doc, &holder_nodes, retired_properties)?; - Ok(()) -} - -/// Retire only properties removed from a rewritten scope, preserving names still used by any -/// content sharing this holder. Different pages' streams must never be concatenated together. -fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], contents: &[Vec>]) -> Result { - if names.is_empty() { - return Ok(false); - } - let mut used = HashSet::new(); - for content in contents { - for op in resource_ops(doc, res, content)? { - if matches!(op.op.as_slice(), b"BDC" | b"DP") - && let Some(name) = op.name(1) - { - used.insert(name.to_vec()); - } - } - } - let mut properties = res_dict(doc, res, b"Properties"); - let mut pruned = false; - for name in names { - if !used.contains(name) { - pruned |= properties.remove(name).is_some(); - } - } - if pruned { - if properties.is_empty() { - res.remove(b"Properties"); - } else { - res.set(b"Properties".to_vec(), Object::Dict(properties)); - } - } - Ok(pruned) -} - -fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> { - if retired.is_empty() { - return Ok(()); - } - let mut pending: Vec = holders - .iter() - .filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())) - .collect(); - let mut seen = HashSet::new(); - while let Some(resources) = pending.pop() { - for (_, value) in res_dict(doc, &resources, b"XObject").iter() { - let Some(reference) = value.as_ref() else { continue }; - if !seen.insert(reference) { - continue; - } - if seen.len() > 1_000_000 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }); - } - let object = doc.get(reference); - let Object::Stream(form) = &*object else { continue }; - if form.dict.name(b"Subtype") != Some(b"Form") { - continue; - } - let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; - let names: Vec> = - res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); - if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? { - doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; - } - pending.push(res); - } - } - Ok(()) -} - -/// The clip that hides everything inside each region: one even-odd clip per region (a big -/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM -/// can't be inverted (nothing is painted then anyway). -fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option> { - let inv = ctm.invert()?; - let mut ops = Vec::new(); - let big = [around[0] - 10.0, around[1] - 10.0, around[2] + 10.0, around[3] + 10.0]; - for r in rects.iter().filter(|r| overlaps(**r, around, 0.0)) { - let outer = [(big[0], big[1]), (big[2], big[1]), (big[2], big[3]), (big[0], big[3])]; - let inner = [(r[0], r[1]), (r[0], r[3]), (r[2], r[3]), (r[2], r[1])]; - for poly in [outer, inner] { - for (k, (x, y)) in poly.iter().enumerate() { - let (u, v) = inv.apply(*x, *y); - ops.push(Op::new(if k == 0 { "m" } else { "l" }, vec![num(u), num(v)])); - } - ops.push(Op::new("h", vec![])); - } - ops.push(Op::new("W*", vec![])); - ops.push(Op::new("n", vec![])); - } - Some(ops) -} - -fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { - let parsed = Pieces::join(content).parse(); - if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); - } - let mut ops = parsed.ops; - let xobjects = res_dict(doc, res, b"XObject"); - let mut seen = HashSet::new(); - let mut cursor = 0; - while cursor < ops.len() { - let op = &ops[cursor]; - if op.is("Do") - && let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) - && seen.insert(r) - && let Object::Stream(s) = &*doc.get(r) - && s.dict.name(b"Subtype") == Some(b"Form") - && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) - { - let parsed = pdfcraft_content::parse(&s.decoded()?); - if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { - return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); - } - ops.extend(parsed.ops); - } - cursor += 1; - } - Ok(ops) -} - -pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { - let mut properties = res_dict(doc, res, b"Properties"); - if properties.is_empty() { - return Ok(Vec::new()); - } - let mut retired = Vec::new(); - let ops = resource_ops(doc, res, content)?; - let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); - for name in names { - if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) - && let Some(value) = properties.remove(&name) - { - retired.push(value); - } - } - if properties.is_empty() { - res.remove(b"Properties"); - } else { - res.set(b"Properties".to_vec(), Object::Dict(properties)); - } - Ok(retired) -} - -fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { - let ops = resource_ops(doc, res, content)?; - let mut xo = res_dict(doc, res, b"XObject"); - let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); - for name in names { - if !ops.iter().any(|op| op.is("Do") && op.name(0) == Some(&name)) { - xo.remove(&name); - } - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - Ok(()) -} - /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, @@ -499,11 +224,9 @@ struct Glyph { /// Process a scope's content streams (state flows from one stream to the next). pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, ctm: Matrix) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut gs = Gs { + let gs = Gs { ctm, - font: fallback.clone(), + font: Rc::new(Metrics::fallback()), vertical: false, size: 0.0, char_spacing: 0.0, @@ -513,28 +236,28 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< leading: 0.0, rise: 0.0, }; - let mut stack: Vec = Vec::new(); - let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); - let fonts_res = res_dict(doc, resources, b"Font"); - let mut xobjects = res_dict(doc, resources, b"XObject"); - // The current path: its operators, its bounding box in user space, and whether it clips. - let mut path: Vec = Vec::new(); - let mut path_box: Option<[f64; 4]> = None; - let mut clip = false; - let mut forced_change = false; + process_from(doc, scope, streams, resources, gs) +} +/// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state +/// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed +/// with the caller's font, size and spacing, as the renderer places it. +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, gs: Gs) -> Output { + let mut walk = Walk::new(doc, resources, gs); // The streams are one content stream in pieces, which may be split between any two tokens // (ISO 32000-2 §7.8.2): an operator's operands can end one piece and the operator start the // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); + walk.out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); // Layer removal also changes enclosing replacement spans, so remember the operators before // any cleanup, not just the survivors of the layer pass. let original = parsed.ops.clone(); - if !scope.hidden_layers.is_empty() { + let mut forced_change = false; + if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); parsed.ops = kept; @@ -543,12 +266,32 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< forced_change = true; } } - let mut pieces: Vec> = streams.iter().map(|_| Vec::new()).collect(); - let mut rewrite: Vec = vec![forced_change; streams.len()]; - for op in parsed.ops { + let (pieces, rewrite) = split_into_pieces(&joined, parsed.ops, streams.len(), forced_change); + let mut processed = Vec::new(); + for (piece, changed) in pieces.into_iter().zip(rewrite) { + let mut done = Piece { ops: Vec::with_capacity(piece.len()), changed }; + for op in piece { + walk.step(doc, scope, resources, op, &mut done); + } + if !walk.path.is_empty() { + done.ops.append(&mut walk.path); + } + processed.push((done.ops, done.changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + walk.out.streams = processed.into_iter().zip(comment_edges).map(|((ops, changed), edges)| changed.then(|| piece_bytes(&ops, edges))).collect(); + walk.out +} + +/// Gives each operator to the piece its keyword is in. An operator split across pieces is written +/// whole into its keyword's piece, so every piece it spans is rewritten. +fn split_into_pieces(joined: &Pieces, ops: Vec, count: usize, forced_change: bool) -> (Vec>, Vec) { + let mut pieces: Vec> = (0..count).map(|_| Vec::new()).collect(); + let mut rewrite: Vec = vec![forced_change; count]; + for op in ops { let (first, last) = joined.pieces_of(&op); - // An operator split across pieces is written whole into its keyword's piece, so every - // piece it spans is rewritten. if first < last { for r in rewrite.iter_mut().take(last + 1).skip(first) { *r = true; @@ -558,243 +301,300 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< p.push(op); } } + (pieces, rewrite) +} - let mut processed = Vec::new(); - for (piece, mut changed) in pieces.into_iter().zip(rewrite) { - let mut ops: Vec = Vec::with_capacity(piece.len()); - for op in piece { - let o = op.op.as_slice(); - // Path construction. - if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { - let pts: Vec<(f64, f64)> = match o { - b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), - b"h" | b"W" | b"W*" => Vec::new(), - _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), - }; - for (x, y) in pts { - let (u, v) = gs.ctm.apply(x, y); - path_box = Some(match path_box { - None => [u, v, u, v], - Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], - }); - } - clip |= matches!(o, b"W" | b"W*"); - path.push(op); - continue; - } - if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { - let body = std::mem::take(&mut path); - let bbox = path_box.take(); - let clips = std::mem::replace(&mut clip, false); - let hit = scope.mode == Mode::Apply && o != b"n" && bbox.is_some_and(|b| scope.hits(b)); - if !hit { - ops.extend(body); - ops.push(op); - continue; - } - let b = bbox.unwrap_or_default(); - changed = true; - if scope.covered(b) { - scope.report.paths_removed += 1; - if clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); - } - } else if clips { - // A clip-and-paint path can't be wrapped in q/Q without losing its clip: - // paint it clipped, then set its clip with a no-op path. - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - let plain: Vec = body.iter().filter(|p| !p.is("W") && !p.is("W*")).cloned().collect(); - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(plain); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } - ops.extend(body); - ops.push(Op::new("n", vec![])); - } else { - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(body); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } - } - continue; - } - if !path.is_empty() { - // A path not ended by a painting operator: pass it through. - ops.append(&mut path); - path_box = None; - clip = false; - } - match o { - b"q" => stack.push(gs.clone()), - b"Q" => { - if let Some(g) = stack.pop() { - gs = g; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - gs.ctm = Matrix(m).then(&gs.ctm); - } - } - b"BT" => { - tm = Matrix::IDENTITY; - tlm = Matrix::IDENTITY; - } - b"Tf" => { - gs.size = op.num(1).unwrap_or(gs.size); - if let Some(name) = op.name(0) { - let font_dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); - gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); - gs.font = match scope.fonts.get(name) { - Some(f) => f.clone(), - None => { - let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); - scope.fonts.insert(name.to_vec(), m.clone()); - m - } - }; - } - } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, - b"Td" | b"TD" => { - if let Some([x, y]) = op.nums::<2>() { - if o == b"TD" { - gs.leading = -y; - } - tlm = Matrix::translate(x, y).then(&tlm); - tm = tlm; - } - } - b"Tm" => { - if let Some(m) = op.nums::<6>() { - tlm = Matrix(m); - tm = tlm; - } - } - b"T*" => { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - b"Tj" | b"TJ" | b"'" | b"\"" => { - if o == b"\"" { - gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); - gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); - } - if matches!(o, b"'" | b"\"") { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - let items: Vec = match o { - b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Vertical-writing text is laid out horizontally by `show`, so its glyphs are - // never matched against a region. Rather than ship a document whose vertical - // text was silently left under a mark, record it so apply can fail closed. - // (Full vertical-text removal is a future enhancement.) - if gs.vertical - && scope.mode == Mode::Apply - && scope.hidden_text.is_none() - && !scope.rects.is_empty() - && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) - { - out.vertical = true; - } - let (new_items, removed) = show(scope, &gs, &mut tm, &items); - if removed == 0 { - ops.push(op); - continue; - } - if scope.mode == Mode::Verify { - out.residue += removed; - ops.push(op); - continue; - } - changed = true; - scope.report.glyphs += removed; - match o { - b"\"" => { - ops.push(Op::new("Tw", vec![num(gs.word_spacing)])); - ops.push(Op::new("Tc", vec![num(gs.char_spacing)])); - ops.push(Op::new("T*", vec![])); - } - b"'" => ops.push(Op::new("T*", vec![])), - _ => {} - } - ops.push(Op::new("TJ", vec![Object::Array(new_items)])); - continue; - } - b"BI" => { - let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); - if scope.hits(b) { - if scope.mode == Mode::Verify { - out.residue += 1; - } else { - scope.report.images_removed += 1; - changed = true; - continue; - } - } - } - b"sh" if scope.mode == Mode::Apply => { - // A shading fills the current clip: clip the regions out. - if let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) { - changed = true; - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.push(op); - ops.push(Op::new("Q", vec![])); - continue; - } - } - b"Do" => { - if let Some(new) = xobject(doc, scope, &op, &gs, resources, &mut xobjects, &mut out) { - changed = true; - if let Some(n) = new { - ops.push(n); - } - continue; - } - } - _ => {} - } - ops.push(op); +/// A rewritten piece's bytes. `(comment_in, comment_out)` say whether the piece before ended +/// inside a comment, and whether this one did. +fn piece_bytes(ops: &[Op], (comment_in, comment_out): (bool, bool)) -> Vec { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes +} + +/// The operators written for one piece of a scope's content, and whether they differ from the source. +struct Piece { + ops: Vec, + changed: bool, +} + +/// The interpreter state carried across the operators of a scope. +struct Walk { + gs: Gs, + /// With each saved state, the text and text line matrices: inside a text object `q`/`Q` + /// save and restore them too (ISO 32000-2 §9.4.1 as corrected by the PDF Association's + /// errata, issue 368), as the renderer does. + stack: Vec<(Gs, Matrix, Matrix)>, + tm: Matrix, + tlm: Matrix, + fonts_res: Dict, + ext_gstates: Dict, + xobjects: Dict, + fallback: Rc, + /// The current path: its operators, its bounding box in user space, and whether it clips. + path: Vec, + path_box: Option<[f64; 4]>, + clip: bool, + out: Output, +} + +impl Walk { + fn new(doc: &Document, resources: &Dict, gs: Gs) -> Self { + Self { + gs, + stack: Vec::new(), + tm: Matrix::IDENTITY, + tlm: Matrix::IDENTITY, + fonts_res: res_dict(doc, resources, b"Font"), + ext_gstates: res_dict(doc, resources, b"ExtGState"), + xobjects: res_dict(doc, resources, b"XObject"), + fallback: Rc::new(Metrics::fallback()), + path: Vec::new(), + path_box: None, + clip: false, + out: Output::default(), } - if !path.is_empty() { - ops.append(&mut path); + } + + /// Interprets one operator, writing what replaces it to `piece`. + fn step(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) { + let o = op.op.as_slice(); + if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { + self.construct_path(op); + return; } - processed.push((ops, changed)); - } - if scope.mode == Mode::Apply { - clean_replacements(doc, resources, &original, &mut processed, scope.report); - } - for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { - out.streams.push(changed.then(|| { - // The piece before still ends inside a comment: end it before the first operator. - let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; - bytes.extend(serialize_ops(&ops)); - // The piece ended inside a comment that hid the start of the next one: keep it open. - if comment_out { - bytes.push(b'%'); + if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + self.paint_path(scope, op, piece); + return; + } + if !self.path.is_empty() { + // A path not ended by a painting operator: pass it through. + piece.ops.append(&mut self.path); + self.path_box = None; + self.clip = false; + } + let kept = match o { + b"q" | b"Q" | b"cm" | b"Tf" | b"gs" => { + self.graphics_state(doc, scope, &op); + Some(op) } - bytes - })); + b"BT" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Ts" | b"Tr" | b"Td" | b"TD" | b"Tm" | b"T*" => { + self.text_state(&op); + Some(op) + } + b"Tj" | b"TJ" | b"'" | b"\"" => self.show_text(scope, op, piece), + b"BI" => Self::inline_image(&self.gs, scope, &mut self.out, op, piece), + b"sh" if scope.mode == Mode::Apply => Self::shading(&self.gs, scope, op, piece), + b"Do" => self.draw_xobject(doc, scope, resources, op, piece), + _ => Some(op), + }; + piece.ops.extend(kept); + } + + /// Path construction: collects the operator and grows the path's bounding box. + fn construct_path(&mut self, op: Op) { + let o = op.op.as_slice(); + let pts: Vec<(f64, f64)> = match o { + b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), + b"h" | b"W" | b"W*" => Vec::new(), + _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), + }; + for (x, y) in pts { + let (u, v) = self.gs.ctm.apply(x, y); + self.path_box = Some(match self.path_box { + None => [u, v, u, v], + Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], + }); + } + self.clip |= matches!(o, b"W" | b"W*"); + self.path.push(op); + } + + /// A painting operator ends the path; one under a region is removed (or kept as a clip). + fn paint_path(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) { + let body = std::mem::take(&mut self.path); + let bbox = self.path_box.take(); + let clips = std::mem::replace(&mut self.clip, false); + let hit = scope.mode == Mode::Apply && op.op.as_slice() != b"n" && bbox.is_some_and(|b| scope.hits(b)); + if !hit { + piece.ops.extend(body); + piece.ops.push(op); + return; + } + let b = bbox.unwrap_or_default(); + piece.changed = true; + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. + piece.ops.extend(body); + piece.ops.push(Op::new("n", vec![])); + } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. + } + + fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + match op.op.as_slice() { + b"q" => self.stack.push((self.gs.clone(), self.tm, self.tlm)), + b"Q" => { + if let Some((g, m, lm)) = self.stack.pop() { + (self.gs, self.tm, self.tlm) = (g, m, lm); + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + self.gs.ctm = Matrix(m).then(&self.gs.ctm); + } + } + b"Tf" => self.select_font(doc, scope, op), + _ => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { + (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); + } + } + } + } + + fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + self.gs.size = op.num(1).unwrap_or(self.gs.size); + let Some(name) = op.name(0) else { return }; + let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); + self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + self.gs.font = match scope.fonts.get(name) { + Some(f) => f.clone(), + None => { + let m = font_dict.as_ref().map_or_else(|| self.fallback.clone(), |d| Rc::new(Metrics::from_dict(doc, d))); + scope.fonts.insert(name.to_vec(), m.clone()); + m + } + }; + } + + /// Text state and text-positioning operators. + fn text_state(&mut self, op: &Op) { + let gs = &mut self.gs; + match op.op.as_slice() { + b"BT" => { + self.tm = Matrix::IDENTITY; + self.tlm = Matrix::IDENTITY; + } + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), + b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.is("TD") { + gs.leading = -y; + } + self.tlm = Matrix::translate(x, y).then(&self.tlm); + self.tm = self.tlm; + } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + self.tlm = Matrix(m); + self.tm = self.tlm; + } + } + _ => { + self.tlm = Matrix::translate(0.0, -gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + } + } + + /// `Tj`, `TJ`, `'` and `"`: glyphs under a region are cut out (or counted, when verifying). + fn show_text(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let o = op.op.as_slice(); + if o == b"\"" { + self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + } + if matches!(o, b"'" | b"\"") { + self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + let items: Vec = match o { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569), so a redaction search matches what is drawn. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Vertical-writing text is laid out horizontally by `show`, so its glyphs are + // never matched against a region. Rather than ship a document whose vertical + // text was silently left under a mark, record it so apply can fail closed. + // (Full vertical-text removal is a future enhancement.) + if self.gs.vertical + && scope.mode == Mode::Apply + && scope.hidden_text.is_none() + && !scope.rects.is_empty() + && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) + { + self.out.vertical = true; + } + let (new_items, removed) = show(scope, &self.gs, &mut self.tm, &items); + if removed == 0 { + return Some(op); + } + if scope.mode == Mode::Verify { + self.out.residue += removed; + return Some(op); + } + piece.changed = true; + scope.report.glyphs += removed; + match op.op.as_slice() { + b"\"" => { + piece.ops.push(Op::new("Tw", vec![num(self.gs.word_spacing)])); + piece.ops.push(Op::new("Tc", vec![num(self.gs.char_spacing)])); + piece.ops.push(Op::new("T*", vec![])); + } + b"'" => piece.ops.push(Op::new("T*", vec![])), + _ => {} + } + piece.ops.push(Op::new("TJ", vec![Object::Array(new_items)])); + None + } + + /// An inline image under a region is removed (or counted, when verifying). + fn inline_image(gs: &Gs, scope: &mut Scope<'_>, out: &mut Output, op: Op, piece: &mut Piece) -> Option { + if !scope.hits(gs.ctm.bbox([0.0, 0.0, 1.0, 1.0])) { + return Some(op); + } + if scope.mode == Mode::Verify { + out.residue += 1; + return Some(op); + } + scope.report.images_removed += 1; + piece.changed = true; + None + } + + /// A shading fills the current clip: the regions are clipped out. + fn shading(gs: &Gs, scope: &Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) else { return Some(op) }; + piece.changed = true; + piece.ops.push(Op::new("q", vec![])); + piece.ops.extend(c); + piece.ops.push(op); + piece.ops.push(Op::new("Q", vec![])); + None + } + + fn draw_xobject(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) -> Option { + let Some(new) = xobject(doc, scope, &op, &self.gs, resources, &mut self.xobjects, &mut self.out) else { return Some(op) }; + piece.changed = true; + piece.ops.extend(new); + None } - out } fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { @@ -923,16 +723,18 @@ fn xobject( xobjects: &mut Dict, out: &mut Output, ) -> Option> { - let name = op.name(0)?.to_vec(); + let name = op.last_name()?.to_vec(); let r = xobjects.get(&name)?.as_ref()?; let obj = doc.get(r); let Object::Stream(s) = &*obj else { return None }; match s.dict.name(b"Subtype") { - _ if s.dict.get(b"OC").is_some_and(|oc| scope.layer_hidden(doc, oc)) => { + _ if s.dict.get(b"OC").is_some_and(|oc| scope.xobject_layer_hidden(doc, r, oc)) => { + // Verify mode must count hidden XObjects too: scan uses that count to decide + // whether the HiddenLayers pass is necessary. + scope.layer_blocks += 1; if scope.mode == Mode::Verify { return None; } - scope.layer_blocks += 1; out.replaced.push(name); out.doomed.push(r); Some(None) @@ -981,8 +783,16 @@ fn xobject( { return None; } + // Too deep: this form cannot be read, so it goes (Apply) or the page is left alone + // (Verify), as a form that will not decode does. The page's decode budget below is what + // keeps a fan-out or a cycle from costing 2^50 walks. if scope.depth >= MAX_DEPTH { if scope.mode == Mode::Verify { + // Sanitization removes a Form it cannot enter, so the verification scan + // must count that conservative removal or it may decide to skip the pass. + if scope.everywhere() { + scope.layer_blocks += 1; + } return None; } scope.report.forms_removed += 1; @@ -990,25 +800,51 @@ fn xobject( out.doomed.push(r); return Some(None); } - let data = match s.decoded() { + // Strict, like page content: a Form read only in part counts as undecodable. + let data = if scope.budget.enter_form() { + scope.budget.decode_strict(s) + } else { + Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects"))) + }; + let data = match data { Ok(d) => d, + // Past the page's budget nothing more is read: apply refuses the page, and + // verification counts the form as content that may remain. + Err(e) if scope.budget.exhausted() => { + if scope.mode == Mode::Apply { + out.error = Some(e); + } else if scope.everywhere() { + scope.layer_blocks += 1; + } else { + out.residue += 1; + } + return None; + } Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; out.replaced.push(name); out.doomed.push(r); return Some(None); } - Err(_) => return None, + Err(_) => { + // Apply drops an undecodable Form while sanitizing. Count it during Verify + // so a zero-count preflight cannot leave its potentially hidden bytes behind. + if scope.everywhere() { + scope.layer_blocks += 1; + } + return None; + } }; let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()); let res = own.clone().unwrap_or_else(|| resources.clone()); let saved_fonts = std::mem::take(&mut scope.fonts); scope.depth += 1; - let inner = process(doc, scope, std::slice::from_ref(&data), &res, fctm); + let inner = process_from(doc, scope, std::slice::from_ref(&data), &res, Gs { ctm: fctm, ..gs.clone() }); scope.depth -= 1; scope.fonts = saved_fonts; out.residue += inner.residue; out.vertical |= inner.vertical; + out.ambiguous |= inner.ambiguous; out.doomed.extend_from_slice(&inner.doomed); out.retired_properties.extend(inner.retired_properties); out.tag_changes.extend(inner.tag_changes); @@ -1048,6 +884,10 @@ fn xobject( out.error = Some(error); return None; } + if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) { + out.error = Some(error); + return None; + } } if cleaned_res != res { dict.set(b"Resources".to_vec(), Object::Dict(cleaned_res)); @@ -1070,45 +910,3 @@ fn xobject( _ => None, } } - -/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks -/// included). Returns the remaining operators and how many blocks went. -fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { - let mut out = Vec::with_capacity(ops.len()); - // For each open marked-content block: does it hide its contents? - let mut stack: Vec = Vec::new(); - let mut removed = 0; - for op in ops { - let hidden_now = stack.last().copied().unwrap_or(false); - match op.op.as_slice() { - b"BDC" | b"BMC" => { - let this = op.is("BDC") - && op.name(0) == Some(b"OC") - && match op.operands.get(1) { - Some(Object::Name(n)) => props.get(n).is_some_and(|o| scope.layer_hidden(doc, o)), - Some(o) => scope.layer_hidden(doc, o), - None => false, - }; - if this && !hidden_now { - removed += 1; - } - stack.push(hidden_now || this); - if !(hidden_now || this) { - out.push(op); - } - } - b"EMC" => { - let was = stack.pop().unwrap_or(false); - if !was { - out.push(op); - } - } - _ => { - if !hidden_now { - out.push(op); - } - } - } - } - (out, removed) -} diff --git a/crates/redact/src/interp/layers.rs b/crates/redact/src/interp/layers.rs new file mode 100644 index 0000000000..88d1c4e677 --- /dev/null +++ b/crates/redact/src/interp/layers.rs @@ -0,0 +1,256 @@ +use super::{Dict, Document, HashSet, ObjRef, Object, Op, Scope}; + +impl<'a> Scope<'a> { + /// Is this optional-content reference (an OCG or OCMD) hidden? + fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool { + if !self.layers_enabled { + return false; + } + let reference = o.as_ref(); + if let Some(r) = reference { + if let Some(hidden) = self.ocmd_cache.get(&r) { + return *hidden; + } + if self.hidden_layers.contains(&r) { + self.ocmd_cache.insert(r, true); + return true; + } + } + // Evaluate the policy against the same default configuration used to select hidden + // groups. A membership dictionary's default policy is AnyOn. + let d = doc.resolve(o); + let Some(d) = d.as_dict() else { return false }; + if d.name(b"Type") != Some(b"OCMD") { + return false; + } + let groups: Vec = match d.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { + Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), + Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(), + None => Vec::new(), + }; + let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g)); + let hidden = if let Some(expression) = d.get(b"VE") { + let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096); + match visible { + Some(visible) => !visible, + None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096), + } + } else if groups.is_empty() { + false + } else { + let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g)); + let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g)); + let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec())); + match (d.get(b"P"), policy.as_deref()) { + (None, _) | (Some(_), Some(b"AnyOn")) => !any_on, + (Some(_), Some(b"AllOn")) => !all_on, + (Some(_), Some(b"AnyOff")) => all_on, + (Some(_), Some(b"AllOff")) => any_on, + // An invalid policy must not make a candidate hidden block survive sanitization. + _ => fail_closed(), + } + }; + if !hidden { + self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied()); + if let Some(expression) = d.get(b"VE") { + let mut referenced = HashSet::new(); + if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) { + self.retained_off_layers.extend(referenced); + } else { + // If an expression cannot be fully walked, preserve every off group so that + // the content we kept does not change visibility after configuration cleanup. + self.retained_off_layers.extend(self.hidden_layers.iter().copied()); + } + } + } + if let Some(r) = reference { + self.ocmd_cache.insert(r, hidden); + } + hidden + } + + pub(super) fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool { + if oc.as_ref().is_some() { + return self.layer_hidden(doc, oc); + } + if let Some(hidden) = self.xobject_oc_cache.get(&xobject) { + return *hidden; + } + let hidden = self.layer_hidden(doc, oc); + self.xobject_oc_cache.insert(xobject, hidden); + hidden + } + + fn collect_hidden_expression_groups( + doc: &Document, + expression: &Object, + hidden_layers: &HashSet, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + found: &mut HashSet, + ) -> bool { + if depth > 64 || *budget == 0 { + return false; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return false; + } + let resolved = doc.resolve(expression); + let complete = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => { + if let Some(r) = reference + && hidden_layers.contains(&r) + { + found.insert(r); + } + reference.is_some() + } + Object::Array(items) => { + let Some(Object::Name(operator)) = items.first() else { return false }; + let operands = match operator.as_slice() { + b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..), + b"Not" if items.len() == 2 => items.get(1..), + _ => None, + }; + operands.is_some_and(|operands| { + operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found)) + }) + } + _ => false, + }; + if let Some(r) = reference { + path.remove(&r); + } + complete + } + + /// Evaluate an optional-content visibility expression (`/VE`) against the default config. + /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed. + fn visibility_expression( + &self, + doc: &Document, + expression: &Object, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + ) -> Option { + if depth > 64 || *budget == 0 { + return None; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return None; + } + let resolved = doc.resolve(expression); + let result = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)), + Object::Array(items) if (2..=4097).contains(&items.len()) => { + let operator = match items.first() { + Some(Object::Name(n)) => n.as_slice(), + _ => return None, + }; + match operator { + b"And" if items.len() >= 2 => { + let mut visible = true; + for item in items.iter().skip(1) { + visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Or" if items.len() >= 2 => { + let mut visible = false; + for item in items.iter().skip(1) { + visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?), + _ => None, + } + } + _ => None, + }; + if let Some(r) = reference { + path.remove(&r); + } + result + } + + /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown + /// syntax, cycles, and exhausted budgets count as a possible hidden reference. + fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool { + if depth > 64 || *budget == 0 { + return true; + } + *budget -= 1; + if let Some(r) = expression.as_ref() { + if self.hidden_layers.contains(&r) { + return true; + } + if !path.insert(r) { + return true; + } + } + let resolved = doc.resolve(expression); + let found = match &*resolved { + Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)), + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)), + Object::Dict(_) => true, + _ => false, + }; + if let Some(r) = expression.as_ref() { + path.remove(&r); + } + found + } +} + +/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks +/// included). Returns the remaining operators and how many blocks went. +pub(super) fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { + let mut out = Vec::with_capacity(ops.len()); + // For each open marked-content block: does it hide its contents? + let mut stack: Vec = Vec::new(); + let mut removed = 0; + for op in ops { + let hidden_now = stack.last().copied().unwrap_or(false); + match op.op.as_slice() { + b"BDC" | b"BMC" => { + let this = op.is("BDC") + && op.name(0) == Some(b"OC") + && match op.operands.get(1) { + Some(Object::Name(n)) => props.get(n).is_some_and(|o| scope.layer_hidden(doc, o)), + Some(o) => scope.layer_hidden(doc, o), + None => false, + }; + if this && !hidden_now { + removed += 1; + } + stack.push(hidden_now || this); + if !(hidden_now || this) { + out.push(op); + } + } + b"EMC" => { + let was = stack.pop().unwrap_or(false); + if !was { + out.push(op); + } + } + _ => { + if !hidden_now { + out.push(op); + } + } + } + } + (out, removed) +} diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs new file mode 100644 index 0000000000..6ca5ea2803 --- /dev/null +++ b/crates/redact/src/interp/prune.rs @@ -0,0 +1,357 @@ +use super::{ + CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num, + overlaps, page_streams, res_dict, +}; + +/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, +/// so a full-rewrite save stops reaching the original image or form it named. A name that some +/// surviving `Do` still draws (the same image shown again outside a mark) is kept. +pub(crate) fn prune(xo: &mut Dict, replaced: &[Vec], content: &[Vec]) { + if replaced.is_empty() { + return; + } + let joined = Pieces::join(content); + let ops = joined.parse().ops; + let still_drawn = |n: &[u8]| ops.iter().any(|op| op.is("Do") && op.last_name() == Some(n)); + for n in replaced { + if !still_drawn(n) { + xo.remove(n); + } + } +} + +/// Whether `content` draws the XObject `name` of the resource dictionary `xo`: directly, or +/// through a form that has no /Resources of its own (such a form draws through its caller's). +/// `None` when that cannot be told (a form that will not decode, or nesting past the limit). +fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], depth: usize, seen: &mut HashSet) -> Option { + let joined = Pieces::join(content); + let ops = joined.parse().ops; + for op in ops.iter().filter(|op| op.is("Do")) { + let Some(n) = op.last_name() else { continue }; + if n == name { + return Some(true); + } + let Some(r) = xo.get(n).and_then(Object::as_ref) else { continue }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.name(b"Subtype") != Some(b"Form") || s.dict.contains(b"Resources") || !seen.insert(r) { + continue; + } + if depth >= MAX_DEPTH { + return None; + } + let Ok(data) = s.decoded() else { return None }; + match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { + Some(false) => {} + other => return other, + } + } + Some(false) +} + +/// Unlink the XObjects a rewrite dropped or replaced (`doomed`) from every page-tree resource +/// dictionary that still names them. The rewritten page has its own pruned /Resources, but the +/// originals stay reachable, and so stay in a full-rewrite save, when: +/// - the page inherited its /Resources from a /Pages node, which still lists them; +/// - several pages share one resource dictionary, and another page still refers to it; +/// - a form without /Resources drew them through the page's dictionary. +/// +/// An entry goes unless a page that draws through that dictionary still draws the name (the same +/// image shown on another page, or again outside a mark). A dictionary that loses an entry is +/// replaced by a private copy on its node, so a dictionary object shared with other nodes is +/// left as it is for them. Retired properties are likewise unlinked only when none of the +/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. +pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { + crate::type3::unlink_type3(doc)?; + if doomed.is_empty() && retired_properties.is_empty() { + return Ok(()); + } + let doomed: HashSet = doomed.iter().copied().collect(); + let Some(top) = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|d| d.reference(b"Pages"))) else { return Ok(()) }; + // Every page-tree node with a /Resources of its own, and the pages that draw through it + // (the nearest such node above a page, or the page itself). Cycles are cut as in + // `pdfcraft_model::pages`. + let mut holders: Vec<(ObjRef, Vec)> = Vec::new(); + let mut seen = HashSet::new(); + let mut stack: Vec<(ObjRef, Option)> = vec![(top, None)]; + while let Some((node, mut holder)) = stack.pop() { + if !seen.insert(node) || seen.len() > 1_000_000 { + continue; + } + let obj = doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + if d.contains(b"Resources") { + holders.push((node, Vec::new())); + holder = Some(holders.len() - 1); + } + let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); + if is_pages { + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + stack.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, holder))); + } else if let Some(h) = holder.and_then(|i| holders.get_mut(i)) { + h.1.push(node); + } + } + let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); + for (node, users) in holders { + let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { + continue; + }; + let mut xo = res_dict(doc, &res, b"XObject"); + let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + let properties = res_dict(doc, &res, b"Properties"); + let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && property_names.is_empty() { + continue; + } + let mut contents = Vec::with_capacity(users.len()); + for user in &users { + let page = doc.get(*user).as_dict().cloned().unwrap_or_default(); + // A page whose content cannot be read may still draw the name: fail closed. + contents.push(page_streams(doc, &page, index.get(user).copied().unwrap_or(0))?.1); + } + let mut pruned = false; + for n in names { + let mut drawn = Some(false); + for content in &contents { + drawn = content_draws(doc, content, &xo, &n, 0, &mut HashSet::new()); + if drawn != Some(false) { + break; + } + } + match drawn { + Some(true) => {} + Some(false) => { + xo.remove(&n); + pruned = true; + } + None => return Err(RedactError::Residue(1)), + } + } + if pruned { + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; + if pruned { + doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + } + // A surviving form can own the same old resource dictionary as a rewritten page. Its + // unused descriptions and doomed XObjects remain reachable too, even after every page + // holder is cleaned. + unlink_from_forms(doc, &holder_nodes, &doomed, retired_properties)?; + Ok(()) +} + +/// Retire only properties removed from a rewritten scope, preserving names still used by any +/// content sharing this holder. Different pages' streams must never be concatenated together. +fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], contents: &[Vec>]) -> Result { + if names.is_empty() { + return Ok(false); + } + let mut used = HashSet::new(); + for content in contents { + for op in resource_ops(doc, res, content)? { + if matches!(op.op.as_slice(), b"BDC" | b"DP") + && let Some(name) = op.name(1) + { + used.insert(name.to_vec()); + } + } + } + let mut properties = res_dict(doc, res, b"Properties"); + let mut pruned = false; + for name in names { + if !used.contains(name) { + pruned |= properties.remove(name).is_some(); + } + } + if pruned { + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + } + Ok(pruned) +} + +/// Unlink retired properties and `doomed` XObjects from the resources of the forms reachable from +/// the page-tree `holders`, each unless the form's own content (or a form inheriting its +/// resources) still uses the name. Fails closed when that cannot be determined. +fn unlink_from_forms(doc: &mut Document, holders: &[ObjRef], doomed: &HashSet, retired: &[Object]) -> Result<(), CosError> { + if retired.is_empty() && doomed.is_empty() { + return Ok(()); + } + let mut pending: Vec = holders + .iter() + .filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())) + .collect(); + let mut seen = HashSet::new(); + while let Some(resources) = pending.pop() { + for (_, value) in res_dict(doc, &resources, b"XObject").iter() { + let Some(reference) = value.as_ref() else { continue }; + if !seen.insert(reference) { + continue; + } + if seen.len() > 1_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }); + } + let object = doc.get(reference); + let Object::Stream(form) = &*object else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; + let names: Vec> = + res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); + let mut xo = res_dict(doc, &res, b"XObject"); + let doomed_names: Vec> = + xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && doomed_names.is_empty() { + pending.push(res); + continue; + } + let data = form.decoded()?; + let mut pruned = prune_doomed_xobjects(doc, std::slice::from_ref(&data), &mut xo, &doomed_names)?; + if pruned { + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &names, &[vec![data]])?; + if pruned { + doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; + } + pending.push(res); + } + } + Ok(()) +} + +fn prune_doomed_xobjects(doc: &Document, content: &[Vec], xo: &mut Dict, doomed_names: &[Vec]) -> Result { + let mut pruned = false; + for name in doomed_names { + match content_draws(doc, content, xo, name, 0, &mut HashSet::new()) { + Some(true) => {} + Some(false) => pruned |= xo.remove(name).is_some(), + None => return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }), + } + } + Ok(pruned) +} + +/// The clip that hides everything inside each region: one even-odd clip per region (a big +/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM +/// can't be inverted (nothing is painted then anyway). +pub(super) fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option> { + let inv = ctm.invert()?; + let mut ops = Vec::new(); + let big = [around[0] - 10.0, around[1] - 10.0, around[2] + 10.0, around[3] + 10.0]; + for r in rects.iter().filter(|r| overlaps(**r, around, 0.0)) { + let outer = [(big[0], big[1]), (big[2], big[1]), (big[2], big[3]), (big[0], big[3])]; + let inner = [(r[0], r[1]), (r[0], r[3]), (r[2], r[3]), (r[2], r[1])]; + for poly in [outer, inner] { + for (k, (x, y)) in poly.iter().enumerate() { + let (u, v) = inv.apply(*x, *y); + ops.push(Op::new(if k == 0 { "m" } else { "l" }, vec![num(u), num(v)])); + } + ops.push(Op::new("h", vec![])); + } + ops.push(Op::new("W*", vec![])); + ops.push(Op::new("n", vec![])); + } + Some(ops) +} + +pub(crate) fn resource_ops(doc: &Document, res: &Dict, content: &[Vec]) -> Result, CosError> { + let parsed = Pieces::join(content).parse(); + if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); + } + let mut ops = parsed.ops; + let xobjects = res_dict(doc, res, b"XObject"); + let mut seen = HashSet::new(); + let mut cursor = 0; + while cursor < ops.len() { + let op = &ops[cursor]; + if op.is("Do") + && let Some(r) = op.last_name().and_then(|n| xobjects.get(n)).and_then(Object::as_ref) + && seen.insert(r) + && let Object::Stream(s) = &*doc.get(r) + && s.dict.name(b"Subtype") == Some(b"Form") + && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) + { + let parsed = pdfcraft_content::parse(&s.decoded()?); + if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); + } + ops.extend(parsed.ops); + } + cursor += 1; + } + Ok(ops) +} + +pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { + let mut properties = res_dict(doc, res, b"Properties"); + if properties.is_empty() { + return Ok(Vec::new()); + } + let mut retired = Vec::new(); + let ops = resource_ops(doc, res, content)?; + let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) + && let Some(value) = properties.remove(&name) + { + retired.push(value); + } + } + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + Ok(retired) +} + +pub(super) fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let ops = resource_ops(doc, res, content)?; + let mut xo = res_dict(doc, res, b"XObject"); + let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| op.is("Do") && op.last_name() == Some(&name)) { + xo.remove(&name); + } + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + Ok(()) +} + +/// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` +/// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the +/// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). +pub(super) fn extgstate_font( + doc: &Document, + scope: &mut Scope<'_>, + ext_gstates: &Dict, + name: &[u8], + fallback: &Rc, +) -> Option<(Rc, bool, f64)> { + let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; + let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; + let [font, size] = entry.as_slice() else { return None }; + let size = doc.resolve(size).as_f64()?; + let font_dict = doc.resolve(font).as_dict().cloned(); + let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + let key = [b"\0".as_slice(), name].concat(); + let metrics = match scope.fonts.get(&key) { + Some(m) => m.clone(), + None => { + let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); + scope.fonts.insert(key, m.clone()); + m + } + }; + Some((metrics, vertical, size)) +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index a8dbf64d9b..de9dafe7db 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -27,6 +27,8 @@ pub mod sanitize; mod tags; #[cfg(test)] mod tests; +pub(crate) mod type3; +pub(crate) mod type3_tables; use interp::{Mode, Scope, process}; @@ -38,6 +40,12 @@ pub enum RedactError { NothingToApply, #[error("page {0} has content that can't be read, so it can't be redacted safely")] Unreadable(usize), + /// Redact annotations with no readable area (neither usable `/QuadPoints` nor a usable `/Rect`). + /// Skipping them would leave the text under them in the file while the others report success. + #[error( + "{0} redaction mark(s) have no readable area (neither usable QuadPoints nor a usable Rect), so the text under them can't be known; nothing was redacted. Remove those marks or mark the text again, then apply again" + )] + UnreadableMark(usize), #[error("redaction could not be verified: {0} item(s) were still found under the marks, so nothing was changed")] Residue(usize), /// A mark's overlay text has a character Helvetica can't draw; nothing was redacted. @@ -51,6 +59,17 @@ pub enum RedactError { "redaction appearance has invalid geometry or exceeds the 8 MiB budget; reduce the marked area, turn off repeated text or apply fewer pages at once (nothing was redacted)" )] OverlayLimit, + /// The structure tree couldn't be cleaned completely (#3302): an element written in place that + /// would need changing, an element whose inherited page can't be found, or a tree too large to + /// walk. Tags that kept the removed text or annotation would undo the redaction. + #[error( + "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" + )] + Tags, + /// Objects written in place nest deeper than hidden information is followed, so its removal + /// could not be shown complete. + #[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")] + TooDeep, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -120,11 +139,16 @@ pub struct Report { pub annotations: usize, pub fields: usize, pub replacement_text: usize, - /// Structure elements that lost alternate/actual text or emptied marked content. + /// Structure elements that lost alternate/actual text or emptied marked content, and + /// structure references to removed annotations. pub tags: usize, /// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field /// values, and from which a relayout could rebuild them) were removed. pub xfa_removed: bool, + /// The content streams this apply added to draw the marks' boxes and labels. They are the + /// only streams a check for text left under the marks may skip: an input stream labelled + /// like an overlay (`/PCMark /Redaction`) is checked like any other. + pub overlays: Vec, } fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { @@ -146,12 +170,31 @@ pub(crate) fn annots_of(doc: &Document, page: &Dict) -> Vec { page.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() } +/// Every annotation the pages list. Compared before and after a removal, what is no longer listed +/// leaves the structure tree too ([`tags::drop_object_refs`]). +pub(crate) fn listed_annotations(doc: &Document) -> HashSet { + pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect() +} + /// Every redaction mark in the document, page by page. pub fn marks(doc: &Document) -> Vec { - let mut out = Vec::new(); + marks_checked(doc).0 +} + +/// [`marks`], and the 0-based page of each Redact annotation that has no readable area, which +/// [`apply`] refuses rather than skipping. +pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { + let (mut out, mut unreadable) = (Vec::new(), Vec::new()); for (pi, p) in pdfcraft_model::pages(doc).iter().enumerate() { for a in annots_of(doc, &p.dict) { - let Some(r) = a.as_ref() else { continue }; + let Some(r) = a.as_ref() else { + // A mark written in place has no object to find, apply or remove: refuse it + // rather than apply the others as if it were not there. + if doc.resolve(&a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Redact")) { + unreadable.push(pi); + } + continue; + }; let obj = doc.get(r); let Some(d) = obj.as_dict() else { continue }; if d.name(b"Subtype") != Some(b"Redact") { @@ -183,17 +226,30 @@ pub fn marks(doc: &Document) -> Vec { rects.push(r); } if rects.is_empty() { + unreadable.push(pi); continue; } let overlay = d.get(b"OverlayText").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); out.push(Mark { page: pi, obj: r, rects, fill: color(doc, d, b"IC"), overlay, look: overlay_look(doc, d) }); } } - out + (out, unreadable) } -/// A page's content streams: (the references or inline objects as listed, their decoded data). +/// A page's content streams: (the references or inline objects as listed, their decoded data), +/// decoded within one page's [`pdfcraft_cos::DecodeBudget`]. pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { + page_streams_within(doc, page, index, &mut pdfcraft_cos::DecodeBudget::page()) +} + +/// [`page_streams`], decoded against `budget`, which the walk of the page's forms then goes on +/// spending. +pub(crate) fn page_streams_within( + doc: &Document, + page: &Dict, + index: usize, + budget: &mut pdfcraft_cos::DecodeBudget, +) -> Result<(Vec, Vec>), RedactError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -204,7 +260,9 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< let mut data = Vec::new(); for o in &list { match &*doc.resolve(o) { - Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?), + // Strict: a tolerant decode keeps what precedes corruption and drops the rest, which + // still draws in viewers that read further; anything not read can't be cleaned. + Object::Stream(s) => data.push(budget.decode_strict(s).map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } @@ -305,8 +363,27 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc /// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved /// file; callers keep the previous document for undo. +/// +/// # Errors +/// +/// On any error `doc` is left as it was: the redaction runs on a copy (cheap: unchanged objects +/// are shared) that replaces `doc` only on success. pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result { - let all_marks = marks(doc); + let mut work = doc.clone(); + let report = apply_to(&mut work, pages)?; + *doc = work; + Ok(report) +} + +/// [`apply`] without the copy: an error can leave `doc` part redacted. +fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result { + let (all_marks, unreadable) = marks_checked(doc); + // A mark with no readable area is not applied, and applying the others would look complete: + // refuse before anything changes. Only the pages being applied count. + let unreadable = unreadable.iter().filter(|page| pages.is_none_or(|p| p.contains(page))).count(); + if unreadable > 0 { + return Err(RedactError::UnreadableMark(unreadable)); + } let chosen: Vec<&Mark> = all_marks.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).collect(); if chosen.is_empty() { return Err(RedactError::NothingToApply); @@ -332,184 +409,85 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result, _>>()?; report.pages = by_page.len(); localize_resources(doc, &by_page)?; - let mut doomed_fields: Vec = Vec::new(); - // The original image and form XObjects the rewrite drops or replaces, on any page. - let mut doomed_xobjects: Vec = Vec::new(); - let mut retired_properties = Vec::new(); - let widget_owner: Vec<(ObjRef, String)> = - pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); - + let widget_owner: Vec<(ObjRef, ObjRef)> = + pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::>()).collect(); + let listed_before = listed_annotations(doc); + let mut applying = Applying { + report, + doomed_fields: Vec::new(), + doomed_xobjects: Vec::new(), + retired_properties: Vec::new(), + widget_owner, + removed_everywhere: HashSet::new(), + }; for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); + redact_page_content(doc, &mut applying, pi, &page, &rects)?; + remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?; + } + let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, .. } = applying; - // 1. Content. - let (list, data) = page_streams(doc, &page.dict, pi)?; - let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) - }; - // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot - // tell whether those glyphs lie under a mark. Rather than report success and possibly - // leave the text in place, refuse the whole operation (the caller keeps the previous - // document). The error does not claim the text was under the mark. Full vertical-text - // removal is a future enhancement. - if let Some(error) = out.error { - return Err(error.into()); - } - if out.vertical { - return Err(RedactError::VerticalText(pi + 1)); - } - doomed_xobjects.extend_from_slice(&out.doomed); - retired_properties.extend(out.retired_properties); - let mut new_list = list.clone(); - let mut changed = false; - for (i, new) in out.streams.into_iter().enumerate() { - let Some(bytes) = new else { continue }; - let mut dict = match &*doc.resolve(&list[i]) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs - // are redacted, keeping it would leave the redacted text in the file. The item stays - // as plain drawn content but is no longer editable. - dict.remove(b"PCAdded"); - // A new object: the original may be shared with other pages. - new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); - changed = true; - } - if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut res = resources.clone(); - if !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - // A full save keeps whatever is still referenced: unlink the originals this page no - // longer draws so the removed image or form bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); - for (n, r) in &out.xobjects { - xo.set(n.clone(), Object::Ref(*r)); - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - let (_, cleaned_data) = page_streams( - doc, - &{ - let mut cleaned_page = page.dict.clone(); - cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); - cleaned_page - }, - pi, - )?; - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - doc.update_dict(page.obj, |d| { - d.set(b"Contents".to_vec(), Object::Array(new_list)); - d.set(b"Resources".to_vec(), Object::Dict(res)); - })?; - // Tags must not keep what the page no longer shows. - let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); - let (_, new_data) = page_streams(doc, &after, pi)?; - let (changed_ids, empty_ids) = - tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); - report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; - } - - for (old, new, changed, empty) in out.tag_changes { - report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; - } - - // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). - let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); - let mut removed: Vec = Vec::new(); - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let subtype = d.name(b"Subtype").unwrap_or(b""); - if subtype == b"Redact" { - if page_marks.iter().any(|m| m.obj == r) { - removed.push(r); - } - continue; - } - if subtype == b"Popup" { - continue; - } - if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { - removed.push(r); - if subtype == b"Widget" { - if let Some((_, name)) = widget_owner.iter().find(|(w, _)| *w == r) - && !doomed_fields.contains(name) - { - doomed_fields.push(name.clone()); + // 2b. Replies and pop-ups of a removed annotation can be listed on another page than their + // parent: their `/IRT` or `/Parent` would keep the removed comment reachable. + loop { + let mut grew = false; + for p in pdfcraft_model::pages(doc) { + let annots = annots_of(doc, &p.dict); + let mut kept = Vec::with_capacity(annots.len()); + for a in &annots { + let Some(r) = a.as_ref() else { + kept.push(a.clone()); + continue; + }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { + kept.push(a.clone()); + continue; + }; + let is_reply = + d.get(b"IRT").and_then(Object::as_ref).is_some_and(|x| removed_everywhere.contains(&x)) && d.name(b"Subtype") != Some(b"Widget"); + let is_popup = d.get(b"Parent").and_then(Object::as_ref).is_some_and(|x| removed_everywhere.contains(&x)); + if removed_everywhere.contains(&r) || is_reply || is_popup { + if removed_everywhere.insert(r) { + grew = true; + if is_reply { + report.annotations += 1; + } } } else { - report.annotations += 1; + kept.push(a.clone()); } } + if kept.len() != annots.len() { + doc.update_dict(p.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?; + } } - // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to - // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints - // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at - // least one more annotation of this page or stops, so it ends. - let replies: Vec<(ObjRef, ObjRef)> = annots - .iter() - .filter_map(Object::as_ref) - .filter_map(|r| { - let obj = doc.get(r); - let d = obj.as_dict()?; - (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; - Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) - }) - .collect(); - loop { - let before = removed.len(); - for (r, parent) in &replies { - if !removed.contains(r) && removed.contains(parent) { - removed.push(*r); - report.annotations += 1; - } - } - if removed.len() == before { - break; - } + if !grew { + break; } - let kept: Vec = annots - .into_iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - if removed.contains(&r) { - return false; - } - // Pop-ups of removed annotations go too. - let obj = doc.get(r); - !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) - }) - .collect(); - doc.update_dict(page.obj, |d| { - if kept.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - })?; } // 3. Form fields with a widget under a mark (all their widgets go). - for name in &doomed_fields { - if pdfcraft_forms::fields(doc).iter().any(|f| &f.name == name) { - pdfcraft_forms::delete_field(doc, name)?; + for field in &doomed_fields { + if pdfcraft_forms::fields(doc).iter().any(|f| f.obj == *field) { + pdfcraft_forms::delete_field_object(doc, *field)?; report.fields += 1; } } + // 3a. A tagged PDF's structure tree still points at the removed comments and widgets + // (`/OBJR`, #2874): drop those references, or a full rewrite keeps them reachable, the + // comment text and the field value included. Widgets of a removed field on other pages count. + let gone: HashSet = listed_before.difference(&listed_annotations(doc)).copied().collect(); + report.tags += tags::drop_object_refs(doc, &gone)?; + // 3b. An XFA form mirrors its field values in the /XFA datasets packet, and a relayout // rebuilds fields and pages from that packet, so deleting the AcroForm field alone leaves the // redacted value in the file and lets a later relayout put it back. Once redactions are @@ -524,7 +502,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - let (list, data) = page_streams(doc, &page.dict, pi)?; - // The overlay stream (last) draws text of its own: the overlay label is allowed. - let original: Vec> = list - .iter() - .zip(data) - .filter(|(o, _)| doc.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")) - .map(|(_, d)| d) - .collect(); + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; + // The overlay stream draws text of its own: the overlay label is allowed. + let original: Vec> = + list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect(); let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut scratch = Report::default(); - let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch); + let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget); residue += process(doc, &mut scope, &original, &resources, pdfcraft_content::Matrix::IDENTITY).residue; } if residue > 0 { @@ -558,27 +533,231 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result { - let all = marks(doc); - let doomed: Vec = all.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); - if doomed.is_empty() { - return Err(RedactError::NothingToApply); +/// What applying the marks collects across pages. +struct Applying { + report: Report, + doomed_fields: Vec, + /// The original image and form XObjects the rewrite drops or replaces, on any page. + doomed_xobjects: Vec, + retired_properties: Vec, + widget_owner: Vec<(ObjRef, ObjRef)>, + removed_everywhere: HashSet, +} + +/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. +fn redact_page_content( + doc: &mut Document, + applying: &mut Applying, + pi: usize, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], +) -> Result<(), RedactError> { + let Applying { report, doomed_xobjects, retired_properties, .. } = applying; + // 1. Content. + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let out = { + let mut scope = Scope::new(rects, Mode::Apply, report, budget); + process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + }; + // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot + // tell whether those glyphs lie under a mark. Rather than report success and possibly + // leave the text in place, refuse the whole operation (the caller keeps the previous + // document). The error does not claim the text was under the mark. Full vertical-text + // removal is a future enhancement. + if let Some(error) = out.error { + return Err(error.into()); } - for p in pdfcraft_model::pages(doc) { - let annots = annots_of(doc, &p.dict); - let kept: Vec = annots - .iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - let parent = doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref); - !doomed.contains(&r) && !parent.is_some_and(|p| doomed.contains(&p)) - }) - .cloned() - .collect(); - if kept.len() != annots.len() { - doc.update_dict(p.obj, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?; + if out.vertical { + return Err(RedactError::VerticalText(pi + 1)); + } + // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it + // can't read): where the page's operators resume after it is a guess, so neither removal + // nor the verify pass can be trusted on this page. + if out.ambiguous { + return Err(RedactError::Unreadable(pi + 1)); + } + doomed_xobjects.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); + let mut new_list = list.clone(); + let mut changed = false; + for (i, new) in out.streams.into_iter().enumerate() { + let Some(bytes) = new else { continue }; + let mut dict = match &*doc.resolve(&list[i]) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs + // are redacted, keeping it would leave the redacted text in the file. The item stays + // as plain drawn content but is no longer editable. + dict.remove(b"PCAdded"); + // A new object: the original may be shared with other pages. + new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); + changed = true; + } + if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut res = resources.clone(); + if !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // A full save keeps whatever is still referenced: unlink the originals this page no + // longer draws so the removed image or form bytes cannot survive in the saved file. + let page_content: Vec> = new_list + .iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }) + .collect(); + interp::prune(&mut xo, &out.replaced, &page_content); + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + let (_, cleaned_data) = page_streams( + doc, + &{ + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + cleaned_page + }, + pi, + )?; + retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(new_list)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + // Tags must not keep what the page no longer shows. + let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); + let (_, new_data) = page_streams(doc, &after, pi)?; + let (changed_ids, empty_ids) = + tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); + report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + + for (old, new, changed, empty) in out.tag_changes { + report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; + } + Ok(()) +} + +/// Step 2 for one page: the marks, and whatever lies under them (with their pop-ups). +fn remove_page_annotations( + doc: &mut Document, + applying: &mut Applying, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], + page_marks: &[&Mark], +) -> Result<(), RedactError> { + let Applying { report, doomed_fields, widget_owner, removed_everywhere, .. } = applying; + // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). + let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); + let mut removed: Vec = Vec::new(); + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let subtype = d.name(b"Subtype").unwrap_or(b""); + if subtype == b"Redact" { + if page_marks.iter().any(|m| m.obj == r) { + removed.push(r); + } + continue; + } + if subtype == b"Popup" { + continue; + } + if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { + removed.push(r); + if subtype == b"Widget" { + if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) + && !doomed_fields.contains(field) + { + doomed_fields.push(*field); + } + } else { + report.annotations += 1; + } } } - Ok(doomed.len()) + // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to + // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints + // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at + // least one more annotation of this page or stops, so it ends. + let replies: Vec<(ObjRef, ObjRef)> = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } + removed_everywhere.extend(removed.iter().copied()); + // Pop-ups dropped with their parent below count too: a reply on another page may answer one. + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { + removed_everywhere.insert(r); + } + } + let kept: Vec = annots + .into_iter() + .filter(|a| { + let Some(r) = a.as_ref() else { return true }; + if removed.contains(&r) { + return false; + } + // Pop-ups of removed annotations go too. + let obj = doc.get(r); + !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) + }) + .collect(); + doc.update_dict(page.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?; + Ok(()) +} + +/// Remove redaction marks without applying them (`None` = all), with their replies and pop-ups as +/// deleting a comment does: a reply left behind would keep its mark in the file through `/IRT`. +/// Returns how many marks went. +pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result { + let targets: HashSet = marks(doc).iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); + if targets.is_empty() { + return Err(RedactError::NothingToApply); + } + let listed: Vec = pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).collect(); + let doomed = pdfcraft_annot::with_replies_and_popups(doc, &listed, targets.iter().copied()); + for p in pdfcraft_model::pages(doc) { + let annots = annots_of(doc, &p.dict); + let kept: Vec = annots.iter().filter(|a| !pdfcraft_annot::goes_with(a, &doomed)).cloned().collect(); + // As deleting a comment does: a page whose marks were all it had loses `/Annots` again, + // and an indirect array stays the one object the page points to. + if kept.len() != annots.len() { + pdfcraft_annot::set_annots(doc, p.obj, kept)?; + } + } + Ok(targets.len()) } diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 4da8236c22..59e623a7e7 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -56,7 +56,12 @@ fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize, let c = s[i]; if c.is_ascii_digit() { n += 1; - } else if !(seps.contains(&c) && (digit(s.get(i + 1)) || s.get(i + 1) == Some(&'(')) || (c == '(' || c == ')') && seps.contains(&c)) { + } else if !(seps.contains(&c) + && (digit(s.get(i + 1)) || s.get(i + 1) == Some(&'(')) + // A slash only ever joins two digits ("0426/123456"). + && (c != '/' || digit(s.get(i + 1)) && digit(i.checked_sub(1).and_then(|p| s.get(p)))) + || (c == '(' || c == ')') && seps.contains(&c)) + { break; } i += 1; @@ -84,8 +89,56 @@ fn run_continues(s: &[char], k: usize, step: isize, seps: &[char]) -> bool { } } +/// Words that label the number after them as a phone number, lowercased ("Tel.", "cell.", "Fax:"). +const PHONE_LABELS: [&str; 24] = [ + "tel", + "tél", + "tele", + "telefon", + "telefono", + "teléfono", + "telephone", + "téléphone", + "phone", + "ph", + "cell", + "cel", + "cellulare", + "cellphone", + "mobile", + "mob", + "mobil", + "móvil", + "movil", + "handy", + "fax", + "tlf", + "telf", + "gsm", +]; + +/// Whether the text before `i` is a phone label, with one to four spaces, colons or dots between. +fn labelled(s: &[char], i: usize) -> bool { + let before = |k: usize| k.checked_sub(1).and_then(|p| s.get(p)).copied(); + let mut j = i; + while i - j < 4 && before(j).is_some_and(|c| matches!(c, ' ' | ':' | '.' | '\u{a0}')) { + j -= 1; + } + let end = j; + while end - j < 12 && before(j).is_some_and(char::is_alphabetic) { + j -= 1; + } + if end == i || before(j).is_some_and(char::is_alphanumeric) { + return false; + } + let label: String = s.get(j..end).unwrap_or_default().iter().flat_map(|c| c.to_lowercase()).collect(); + PHONE_LABELS.contains(&label.as_str()) +} + fn phone(s: &[char], i: usize) -> Option { const SEPS: [char; 5] = [' ', '-', '.', '(', ')']; + // A slash joins the area code to the number in Italian and German landlines ("0426/123456"). + const RUN_SEPS: [char; 6] = [' ', '-', '.', '(', ')', '/']; let start_ok = s[i] == '+' && digit(s.get(i + 1)) || s[i] == '(' && digit(s.get(i + 1)) || s[i].is_ascii_digit(); if !start_ok || word(i.checked_sub(1).and_then(|p| s.get(p))) || s.get(i.wrapping_sub(1)) == Some(&'+') { return None; @@ -97,12 +150,33 @@ fn phone(s: &[char], i: usize) -> Option { } let plus = s[i] == '+'; let first = if plus { i + 1 } else { i }; - let (end, n) = digit_run(s, first, &SEPS, 28); + let (end, n) = digit_run(s, first, &RUN_SEPS, 28); let run = s.get(first..end)?; let seps = run.iter().filter(|c| !c.is_ascii_digit()).count(); - // Always grouped by at least one separator, so plain numbers never match; nor does the - // head of a run too long to be a phone number. - if seps == 0 || digit(s.get(end)) || run_continues(s, end - 1, 1, &SEPS) { + // Grouped by at least one separator, or labelled ("cell. 3451234567", #3205), so plain + // numbers never match; nor does the head of a run too long to be a phone number. + if seps == 0 && !labelled(s, i) || digit(s.get(end)) || run_continues(s, end.checked_sub(1)?, 1, &SEPS) { + return None; + } + let slash = run.contains(&'/'); + // Italian geographic numbers retain their initial 0 after +39. For a labelled number, + // apply the national slash rule after that country code and an optional separating space. + // AGCOM's E.164 plan gives geographic numbers 6 to 11 national digits (#3205). + let slash_run = if plus && slash { + if !labelled(s, i) || !(8..=13).contains(&n) { + return None; + } + let national = run.strip_prefix(&['3', '9'])?; + national.strip_prefix(&[' ']).unwrap_or(national) + } else { + run + }; + // Only an area code beginning with 0 takes a slash, so "2024/123456" is no phone number; + // and only one, right after it (not "0426/12/3456" or "0426 12/3456"). + if run.iter().filter(|c| **c == '/').count() > 1 + || slash + && (slash_run.first() != Some(&'0') || !digit(slash_run.get(1)) || !slash_run.iter().take_while(|c| **c != '/').all(char::is_ascii_digit)) + { return None; } let ok = if plus { @@ -110,7 +184,7 @@ fn phone(s: &[char], i: usize) -> Option { (8..=15).contains(&n) } else { // North American: 10 digits, 11 with a country code ("(555) 123-4567"). - let nanp = (10..=11).contains(&n); + let nanp = (10..=11).contains(&n) && !slash; // National with a trunk 0 and 6–14 more digits ("0211 123456", "01 23 45 67 89"). // The first group needs two digits so decimals ("0.1234567") don't match, and // day-first dates ("01.10.2026") are left to the date pattern. @@ -287,7 +361,7 @@ fn date(s: &[char], i: usize) -> Option { // Numeric: m/d/yy(yy), m-d-yyyy, d.m.yyyy, yyyy-mm-dd. if let Some(a) = number(s, i, 1, 4) { let sep = s.get(i + a).copied(); - if matches!(sep, Some('/' | '-' | '.')) + if matches!(sep, Some('-'..='/')) && let Some(b) = number(s, i + a + 1, 1, 2) && s.get(i + a + 1 + b).copied() == sep && let Some(c) = number(s, i + a + b + 2, if a == 4 { 1 } else { 2 }, if a == 4 { 2 } else { 4 }) @@ -404,6 +478,81 @@ mod tests { ); } + /// #3205: Italian numbers written with a slash, and, after a phone label, without separators. + #[test] + fn slashed_and_labelled_unseparated_phone_numbers() { + let lines = [ + "A tel. 0426 123456", + "B tel. +39 0426 123456", + "C cell. 345 1234567", + "D cell. 3451234567", + "E tel. 0426/123456", + "F cell. 345.123.4567", + "G tel. 0426.123456", + "H cell. +393451234567", + "Fax: 0211123456", + "Telefono 0426/123456", + ]; + assert_eq!( + found(Pattern::Phone, &lines.join("\n")), + [ + "0426 123456", + "+39 0426 123456", + "345 1234567", + "3451234567", + "0426/123456", + "345.123.4567", + "0426.123456", + "+393451234567", + "0211123456", + "0426/123456" + ] + ); + // Without a label, or after a word that only ends like one, unseparated numbers stay + // unmarked; a slash needs a trunk 0 and digits on both sides, and dates stay dates. + assert_eq!( + found( + Pattern::Phone, + "id 0211123456, +4921511234567, hotel 3451234567, prot. 2024/123456, on 01/10/2026, \ + tel. 12345, tel 0426/ 123456, +39 0426/123456, tel 0426/12/3456, tel 0426 12/3456" + ), + Vec::::new() + ); + } + + #[test] + /// Labelled Italian geographic numbers match completely while dates and other digit runs stay unmarked. + fn labelled_italian_international_slash_numbers_match_whole() { + for (text, number) in [ + ("tel. +39 0426/123456", "+39 0426/123456"), + ("Fax: +390426/123456", "+390426/123456"), + ("Telefono +39 02/12345678", "+39 02/12345678"), + ("tel. +39 02/1234", "+39 02/1234"), + ("tel. +39 0426/1234567", "+39 0426/1234567"), + ] { + assert_eq!(found(Pattern::Phone, text), [number], "{text}"); + } + for text in [ + "+39 0426/123456", + "tel. +39 2024/123456", + "tel. +39 0/123456789", + "tel. +39 0426/12/3456", + "tel. +39 0426 12/3456", + "tel. +39 0426/ 123456", + "tel. +39 02/123", + "tel. +39 0426/12345678", + "tel. +39 0426/1234567890", + "tel. +39 0426/123456", + "tel. +39-0426/123456", + "tel. +39 -0426/123456", + "tel. +40 0426/123456", + "protocol 2024/123456", + "on 01/10/2026", + ] { + assert!(found(Pattern::Phone, text).is_empty(), "{text}"); + } + } + #[test] fn the_tail_of_a_longer_number_is_not_a_phone_number() { // #1884: the last groups of a French social security number, an IBAN and a SIRET used diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 0c9228ac02..98703dfbf2 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -8,14 +8,14 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::interp::{Mode, Scope, process}; -use crate::{RedactError, Report, annots_of, page_streams}; +use crate::{RedactError, Report, annots_of, listed_annotations, page_streams, page_streams_within}; /// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Hidden { /// Document information (`/Info`) and XMP metadata streams. Metadata, - /// Embedded files and file attachment annotations. + /// Embedded files, associated files (`/AF`) and file attachment annotations. Attachments, /// Comments and markup annotations (with their pop-ups). Comments, @@ -87,32 +87,237 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Option { d.get(key).and_then(|o| doc.resolve(o).as_dict().cloned()) } -/// Entries of a name tree (`/Names` leaves, `/Kids` recursion), bounded against cycles. -fn name_tree_len(doc: &Document, node: &Dict, depth: usize) -> usize { - if depth > 32 { +/// The most name-tree nodes one count reads. +const NAME_TREE_NODE_BUDGET: usize = 100_000; + +/// Entries of a name tree (`/Names` leaves, `/Kids` recursion). Each indirect node is read once, +/// however many `/Kids` arrays list it, so shared or cyclic kids cost one step per node rather +/// than one per path. Every `/Kids` entry the walk does not descend into (a repeated reference or +/// a non-dictionary) costs a step as well, so a long `/Kids` array of repeats cannot outrun the +/// budget. Depth and a total step budget bound the rest. +fn name_tree_len(doc: &Document, node: &Dict) -> usize { + let (mut seen, mut visited) = (std::collections::HashSet::new(), 0); + name_tree_len_seen(doc, node, 0, &mut seen, &mut visited) +} + +fn name_tree_len_seen(doc: &Document, node: &Dict, depth: usize, seen: &mut std::collections::HashSet, visited: &mut usize) -> usize { + *visited = visited.saturating_add(1); + if depth > 32 || *visited > NAME_TREE_NODE_BUDGET { return 0; } let leaves = node.get(b"Names").and_then(|n| doc.resolve(n).as_array().map(|a| a.len() / 2)).unwrap_or(0); - let kids = node.get(b"Kids").and_then(|k| doc.resolve(k).as_array().cloned()).unwrap_or_default(); - leaves + kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).map(|k| name_tree_len(doc, &k, depth + 1)).sum::() + let mut n = leaves; + let kids = node.get(b"Kids").map(|k| doc.resolve(k)); + for k in kids.as_deref().and_then(Object::as_array).map_or(&[][..], Vec::as_slice) { + if *visited > NAME_TREE_NODE_BUDGET { + break; + } + let held; + let kid = match k { + Object::Ref(r) if !seen.insert(*r) => None, + Object::Ref(r) => { + held = doc.get(*r); + held.as_dict() + } + other => other.as_dict(), + }; + match kid { + Some(kid) => n = n.saturating_add(name_tree_len_seen(doc, kid, depth + 1, seen, visited)), + None => *visited = visited.saturating_add(1), + } + } + n } +/// The most distinct outline items one count reads, over all levels together. +const OUTLINE_ITEM_BUDGET: usize = 100_000; + fn outline_len(doc: &Document, first: Option<&Object>, depth: usize) -> usize { + let (mut seen, mut counted) = (std::collections::HashSet::new(), 0); + outline_len_seen(doc, first, depth, &mut seen, &mut counted) +} + +/// Counts an outline level and everything below it, each item once. `seen` holds the indirect items +/// already counted, and `counted` every item counted so far, inline ones included, which the total +/// budget applies to. An item that two others name (as `/First` or `/Next`) is counted where the walk +/// first reaches it, and a level stops at an item already seen, because the walk that reached that +/// item counts the rest of its level. A hostile outline therefore costs one step per item, not 2^n +/// steps for n levels of shared children. The depth limit of 64 only matters for outlines nested that +/// deep: an item first reached through a deeper path is not visited again from a shallower one, so +/// items nested beyond the limit may stay uncounted. +fn outline_len_seen( + doc: &Document, + first: Option<&Object>, + depth: usize, + seen: &mut std::collections::HashSet, + counted: &mut usize, +) -> usize { let mut n = 0; let mut cur = first.cloned(); let mut guard = 0; while let Some(o) = cur { guard += 1; - if guard > 100_000 || depth > 64 { + if guard > 100_000 || depth > 64 || *counted >= OUTLINE_ITEM_BUDGET { + break; + } + if let Object::Ref(r) = &o + && !seen.insert(*r) + { break; } let Some(d) = doc.resolve(&o).as_dict().cloned() else { break }; - n += 1 + outline_len(doc, d.get(b"First"), depth + 1); + *counted += 1; + n += 1 + outline_len_seen(doc, d.get(b"First"), depth + 1, seen, counted); cur = d.get(b"Next").cloned(); } n } +/// Entries that hold a category's data wherever they appear, not only on the catalog and pages: +/// XMP on images, fonts and Forms, private data on Forms, associated files on pages and +/// annotations, additional actions on fields. +const OWNED: [(Hidden, &[u8]); 4] = + [(Hidden::Metadata, b"Metadata"), (Hidden::PrivateData, b"PieceInfo"), (Hidden::Attachments, b"AF"), (Hidden::LinksActionsScripts, b"AA")]; + +/// Keys whose dictionary values map names a producer chooses to objects (resources, glyph +/// procedures, named destinations, role and class maps, private data by application). A name +/// there may be spelt like an owned entry (a font called `/AF`), so these maps are walked but +/// never stripped. +const NAME_MAPS: [&[u8]; 14] = [ + b"Font", + b"XObject", + b"ExtGState", + b"ColorSpace", + b"Pattern", + b"Shading", + b"Properties", + b"CharProcs", + b"Dests", + b"RoleMap", + b"ClassMap", + b"Colorants", + b"Schema", + b"PieceInfo", +]; + +/// How deep the owner sweep follows dictionaries and arrays written in place. +const MAX_DIRECT_DEPTH: usize = 64; + +/// What a dictionary is to the owner sweep. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Held { + /// An object that may own an entry of [`OWNED`]. + Owner, + /// A [`NAME_MAPS`] map: its keys are names, its values owners. + Map, + /// An appearance dictionary (`/AP`): its dictionary values map appearance states. + Appearances, +} + +impl Held { + fn child(self, key: &[u8]) -> Held { + match self { + Held::Owner if NAME_MAPS.contains(&key) => Held::Map, + Held::Owner if key == b"AP" => Held::Appearances, + Held::Appearances => Held::Map, + _ => Held::Owner, + } + } +} + +/// Indirect objects that hold an owned entry, each with the role it was reached in. +type Holders = Vec<(ObjRef, Held)>; + +/// The objects reachable from the catalog that hold one of `keys` outside a name map, and how +/// many dictionaries hold each key. Each object is read at most once per [`Held`] role, so a +/// shared or cyclic graph costs one step per object. +fn owners(doc: &Document, keys: &[&[u8]]) -> Result<(Vec, Holders), RedactError> { + let mut held = vec![0; keys.len()]; + let mut holders = Vec::new(); + let Some(root) = doc.root() else { return Ok((held, holders)) }; + let mut next = vec![(root, Held::Owner)]; + let mut seen = std::collections::HashSet::new(); + while let Some((r, role)) = next.pop() { + if !seen.insert((r, role)) { + continue; + } + if find_owned(&doc.get(r), role, keys, &mut held, &mut next, 0)? { + holders.push((r, role)); + } + } + Ok((held, holders)) +} + +/// Counts the dictionaries in `o` (and what it holds in place) that hold one of `keys`, and +/// queues the indirect objects it names. Returns whether any key was found. +fn find_owned(o: &Object, role: Held, keys: &[&[u8]], held: &mut [usize], next: &mut Vec<(ObjRef, Held)>, depth: usize) -> Result { + if depth > MAX_DIRECT_DEPTH { + return Err(RedactError::TooDeep); + } + let (d, role) = match o { + Object::Ref(r) => { + next.push((*r, role)); + return Ok(false); + } + Object::Array(a) => { + let mut found = false; + for v in a { + found |= find_owned(v, Held::Owner, keys, held, next, depth + 1)?; + } + return Ok(found); + } + Object::Dict(d) => (d, role), + // A stream's dictionary is never a name map. + Object::Stream(s) => (&s.dict, Held::Owner), + _ => return Ok(false), + }; + let mut found = false; + if role != Held::Map { + for (i, k) in keys.iter().enumerate() { + if d.contains(k) + && let Some(n) = held.get_mut(i) + { + *n = n.saturating_add(1); + found = true; + } + } + } + for (k, v) in d.iter() { + found |= find_owned(v, role.child(k), keys, held, next, depth + 1)?; + } + Ok(found) +} + +/// Removes `keys` from `o` and from what it holds in place, outside name maps. [`owners`] has +/// already refused anything nested past the depth limit. +fn strip_owned(o: &mut Object, role: Held, keys: &[&[u8]], depth: usize) { + if depth > MAX_DIRECT_DEPTH { + return; + } + let (d, role) = match o { + Object::Array(a) => { + for v in a { + strip_owned(v, Held::Owner, keys, depth + 1); + } + return; + } + Object::Dict(d) => (d, role), + Object::Stream(s) => (&mut s.dict, Held::Owner), + _ => return, + }; + if role != Held::Map { + for k in keys { + d.remove(k); + } + } + let names: Vec> = d.iter().filter(|(_, v)| matches!(v, Object::Array(_) | Object::Dict(_))).map(|(k, _)| k.clone()).collect(); + for k in names { + if let Some(v) = d.get_mut(&k) { + strip_owned(v, role.child(&k), keys, depth + 1); + } + } +} + /// A comment as the comments list sees it, less file attachments (removed with Attachments) and /// redaction marks. fn is_comment(subtype: &[u8]) -> bool { @@ -138,39 +343,49 @@ fn hidden_layers(doc: &Document) -> Vec { } /// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them). -fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result { +fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet), RedactError> { let off = if layers { hidden_layers(doc) } else { Vec::new() }; - if !text && off.is_empty() { - return Ok(0); + if !text && !layers { + return Ok((0, std::collections::HashSet::new())); } let mut total = 0; + let mut retained_off_layers = std::collections::HashSet::new(); // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed = Vec::new(); let mut retired_properties = Vec::new(); let n = pdfcraft_model::pages(doc).len(); for pi in 0..n { let page = pdfcraft_model::pages(doc).swap_remove(pi); - let Ok((list, data)) = page_streams(doc, &page.dict, pi) else { + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else { if write { return Err(RedactError::Unreadable(pi + 1)); } + // Content that can't be read may hide anything: count it so the removal runs and + // refuses the page, instead of a zero count skipping it. + total += 1; continue; }; let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut report = Report::default(); let mode = if write { Mode::Apply } else { Mode::Verify }; - let mut scope = Scope::new(&[], mode, &mut report); + let mut scope = Scope::new(&[], mode, &mut report, budget); if text { let c = page.crop(doc); scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); } - scope.hidden_layers = off.clone(); + scope.hidden_layers = off.iter().copied().collect(); + scope.layers_enabled = layers; let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY); if let Some(error) = out.error { return Err(error.into()); } let blocks = scope.layer_blocks; - total += out.residue + blocks + report.glyphs; + let glyphs = scope.report.glyphs; + let retained_for_page = std::mem::take(&mut scope.retained_off_layers); + drop(scope); + total += out.residue + blocks + glyphs; + retained_off_layers.extend(retained_for_page); if !write { continue; } @@ -185,6 +400,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re _ => Dict::new(), }; dict.remove(b"Length"); + // The source text of an added item (Edit ▸ Add content) would keep what was removed. + dict.remove(b"PCAdded"); new_list[i] = Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::flate(dict, &bytes)))); changed = true; } @@ -195,7 +412,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let mut cleaned_page = page.dict.clone(); cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); let (_, page_content) = page_streams(doc, &cleaned_page, pi)?; - let mut res = resources; + let mut res = resources.clone(); if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no @@ -207,16 +424,32 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re res.set(b"XObject".to_vec(), Object::Dict(xo)); } retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); + crate::type3::prune_type3_from_resources(doc, &mut res, &page_content)?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + // Tags must not describe or point at what the page no longer shows. + let (changed_ids, empty_ids) = crate::tags::touched( + doc, + &resources, + pdfcraft_content::Pieces::join(&data).data(), + pdfcraft_content::Pieces::join(&page_content).data(), + ); + crate::tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + for (old, new, changed, empty) in out.tag_changes { + crate::tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; } } // What a page no longer draws must not stay reachable through an inherited or shared // /Resources either. - crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; - Ok(total) + // Only a removal rewrites the document; a count on a clone has nothing to unlink, and a + // cleanup error there would be mistaken for "nothing to remove". + if write { + crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; + } + Ok((total, retained_off_layers)) } /// How many items each category would remove (categories with nothing are included as 0). @@ -226,6 +459,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let annots: Vec = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect(); let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); let names = sub(doc, &cat, b"Names").unwrap_or_default(); + let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); + // A graph nested too deeply to walk counts as holding everything, so the removal runs and + // refuses it. + let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0); + let owned = |h: Hidden| OWNED.iter().position(|o| o.0 == h).and_then(|i| held.get(i)).copied().unwrap_or(0); let mut doc2 = doc.clone(); HIDDEN .into_iter() @@ -233,27 +471,38 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let n = match h { Hidden::Metadata => { let info = doc.trailer().get(b"Info").and_then(|i| doc.resolve(i).as_dict().map(|d| d.len())).unwrap_or(0); - let xmp = usize::from(cat.contains(b"Metadata")) + pages.iter().filter(|p| p.dict.contains(b"Metadata")).count(); - info + xmp + info + owned(Hidden::Metadata) + } + Hidden::Attachments => { + sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + + count_sub(b"FileAttachment") + + owned(Hidden::Attachments) } - Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), - Hidden::FormFields => pdfcraft_forms::fields(doc).len(), - Hidden::HiddenText => content_pass(&mut doc2, true, false, false).unwrap_or(0), + Hidden::FormFields => { + let n = pdfcraft_forms::fields(doc).len(); + if n > 0 { + n + } else if cat.contains(b"AcroForm") { + 1 + } else { + 0 + } + } + Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), Hidden::HiddenLayers => { let off = hidden_layers(doc).len(); - if off == 0 { 0 } else { off + content_pass(&mut doc2, false, true, false).unwrap_or(0) } + off + content_pass(&mut doc2, false, true, false).map(|(n, _)| n).unwrap_or(0) } Hidden::Bookmarks => sub(doc, &cat, b"Outlines").map_or(0, |o| outline_len(doc, o.get(b"First"), 0)), Hidden::LinksActionsScripts => { count_sub(b"Link") + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) - + usize::from(cat.contains(b"AA")) - + pages.iter().filter(|p| p.dict.contains(b"AA")).count() - + annots.iter().filter(|a| a.contains(b"AA") || (a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A"))).count() - + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t, 0)) + + owned(Hidden::LinksActionsScripts) + + annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) } - Hidden::PrivateData => usize::from(cat.contains(b"PieceInfo")) + pages.iter().filter(|p| p.dict.contains(b"PieceInfo")).count(), + Hidden::PrivateData => owned(Hidden::PrivateData), }; (h, n) }) @@ -261,23 +510,23 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { } /// Remove the chosen categories. Returns what was removed per category. +/// +/// # Errors +/// +/// This operation is not transactional: an error may leave `doc` partially modified, with +/// sensitive content still present. Call this on a working clone and replace the original only +/// on success. On any error, discard the working clone; do not save or reuse it. pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result, RedactError> { let counts = scan(doc); let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; let mut done = Vec::new(); - // Content first (it needs the layers and the form still in place). - let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers)); - if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) { - content_pass(doc, text, layers, true)?; - } // Every annotation the pages list now: whatever is no longer listed at the end (flattened // widgets included) leaves the structure tree too. - let listed = |doc: &Document| -> std::collections::HashSet { - pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect() - }; - let listed_before = listed(doc); - if which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0 { + let listed_before = listed_annotations(doc); + let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); + let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); + if fields { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { @@ -290,10 +539,20 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result