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/assets/badges/rust.svg b/.github/assets/badges/rust.svg index 11aeaaf4bd..fc33e3e188 100644 --- a/.github/assets/badges/rust.svg +++ b/.github/assets/badges/rust.svg @@ -1 +1 @@ -rust: 1.90+rust1.90+ +rust: 1.95+rust1.95+ diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000..93b6522991 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,12 @@ +# Tests hold known passwords and crypto vectors on purpose. Vendored crates are patched copies of +# third-party code, whose PDF encryption code flags the formats' own constants. Excluding them keeps +# real findings in the app's code visible, but CodeQL no longer looks for bugs in these folders. +paths-ignore: + - "vendor/**" + - "xtask/**" + - "**/tests/**" + - "**/tests.rs" + - "**/*_tests.rs" + - "**/benches/**" + - "**/examples/**" + - "**/fuzz/**" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..1910ea4bd3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directories: + - "/" + - "/crates/filters/fuzz" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..50ff3e96c4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,76 @@ + + +## What this changes + + + +## How you verified it + + + +- [ ] `cargo fmt --check` +- [ ] clippy with `-D warnings` on the crates I changed +- [ ] tests pass on the crates I changed +- [ ] `cargo xtask layers` and `cargo xtask assets` (if you touched crate boundaries or any asset) + +**Tests.** New behaviour needs a test at the right scale — a property test, a unit test, a +crate integration test or an end-to-end one. For a bug fix, **please check that the test +fails before your fix** by reverting the fix and watching it go red. A test nobody has seen +fail is not evidence that it pins anything. + +## Never-crash + +Every PDF, script, MCP call, settings file and keystroke is untrusted input (`AGENTS.md` §4). + +- [ ] No new `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` outside tests, unless it is + provably infallible with a comment saying why +- [ ] No indexing or slicing with input-derived positions (`get`, and strings only at char + boundaries); checked or saturating arithmetic; allocations sized by input are capped; + recursion is bounded +- [ ] No `unsafe` +- [ ] Any crash this fixes has a small synthetic regression test + +## Provenance + +This is the part that cannot be undone after a merge, and the part a new contributor is +least likely to know about. Please read `AGENTS.md` §1 before adding any asset. + +- [ ] No asset from an Adobe product, and no Adobe icon recreated, traced or imitated +- [ ] No font files committed here (they live in + [storytold/craft-fonts](https://github.com/storytold/craft-fonts)) +- [ ] No GPL/AGPL code copied in — MuPDF, Ghostscript, Poppler, veraPDF and DSS may only be + run as external oracle processes +- [ ] Behaviour comes from the specification or black-box observation, **not** from reading + another implementation's source +- [ ] Every new asset has an `ATTRIBUTION.toml` entry and `cargo xtask assets` passes + +## Security + +- [ ] `--root` confinement still holds on every path, including ones reached by nested + tool dispatch +- [ ] MCP and the UI control channel stay opt-in, loopback-only and token-authenticated +- [ ] No new outbound network access (that decision is the owner's) + +## Parity and reach + +- [ ] `parity/acrobat-features.toml` updated in this PR, with `notes` saying what is *still* + missing — `shipped` means "exists and tested", not "as good as Acrobat" +- [ ] Reachable headlessly through an `automation` tool where that applies (`AGENTS.md` §3), + so agents can drive it and not just the UI +- [ ] Docs updated if this changes a documented behaviour (`docs/`, crate README) + +## Anything a reviewer should look at first + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0c0596447..087f26bceb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,133 +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 }} - 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] - steps: - - uses: actions/checkout@v4 - - # 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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - - name: Install Linux system dependencies (egui/winit/rfd) - 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 - - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - - name: Require Windows executable resources - if: runner.os == 'Windows' - run: Add-Content -Path $env:GITHUB_ENV -Value 'PDFCRAFT_REQUIRE_WINRES=1' - shell: pwsh - - - uses: Swatinem/rust-cache@v2 - - - 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' - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: cargo test --workspace - - - name: Layering - 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@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - with: - key: wasm32 - - name: Check library crates for wasm32 - run: cargo xtask wasm - - deny: - name: dependency licences and advisories + # 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@v4 - - uses: EmbarkStudios/cargo-deny-action@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 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@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - 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@v4 - with: - name: fuzz-findings - path: fuzz-out/findings - if-no-files-found: ignore + 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 + + gate: + if: github.event.action != 'labeled' || github.event.label.name == 'full-ci' + # Pinned: a mutable reference to another repository can change what CI runs. Move it deliberately. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@2b3fb9dc05036f73c77d18f753a3aa9da55fa386 # main, 31 October 2026 + 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/codeql.yml b/.github/workflows/codeql.yml index 9348c2cdf6..4f30a827af 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -39,6 +39,7 @@ jobs: with: languages: ${{ matrix.language }} build-mode: none + config-file: ./.github/codeql/codeql-config.yml - name: Perform CodeQL analysis uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4 diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index e409d1e99e..53e3046d30 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -23,30 +23,7 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@v4 - - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked - # The release packaging, end to end (a quicker release profile than the real release). - CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 packaging/freebsd/package.sh - tar -tzf dist/release/pdfcraft-*-freebsd-*.tar.gz | grep -E '/bin/pdfcraft$|/bin/pdfcraft-cli$|applications/|/share/pdfcraft/models/.+\.onnx$' + # Pinned: a mutable reference to another repository can change what CI runs. Move it deliberately. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@2b3fb9dc05036f73c77d18f753a3aa9da55fa386 # main, 31 October 2026 + with: + 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 new file mode 100644 index 0000000000..86e12c9ef2 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,23 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + nix: + # Pinned: a mutable reference to another repository can change what CI runs. Move it deliberately. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@2b3fb9dc05036f73c77d18f753a3aa9da55fa386 # main, 31 October 2026 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index aed79a90ed..bc0248fd20 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,48 +19,29 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 + # Pinned: a mutable reference to another repository can change what CI runs. Move it deliberately. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@2b3fb9dc05036f73c77d18f753a3aa9da55fa386 # main, 31 October 2026 + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest steps: - - uses: actions/checkout@v4 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain shell: pwsh run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" + $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: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/*.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon (#57). - if grep -oE '"$RUNNER_TEMP/ai.storyteller.pdfcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.pdfcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.pdfcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.pdfcraft" and m["command"] == "pdfcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF + - 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 da4a9e02b6..573b9f69f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "PdfCraft v". See docs/releasing.md. -# -# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release` -# environment (restricted to the `release` branch), which holds the signing secrets. Every secret -# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# other jobs use no secrets and so no environment: a dispatch on another branch builds and checks -# them (a dry run), while the signing jobs are refused there and the draft-release job, which -# needs them, is skipped. name: Release on: @@ -20,494 +11,33 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - PDFCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@v4 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### PdfCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$PDFCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM (#61): cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo xtask version set "$PDFCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - name: Install build and packaging tools - 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 \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$PDFCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz, CLI tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/pdfcraft$|metainfo|applications' - # The OCR models (#103), where the installed app looks: /../share/pdfcraft/models. - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/share/pdfcraft/models/.+\.onnx$' - # Named exactly: dist/release also holds the CLI-only tar.gz, and `tar -tzf a b` reads b as a member. - tar -tzf "dist/release/pdfcraft-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | grep -E '/share/pdfcraft/models/.+\.onnx$' - ldd target/release/pdfcraft - # The CLI-only archive: the binary and its licences, and it runs. - cli="dist/release/pdfcraft-cli-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - tar -tzf "$cli" | grep -E '/pdfcraft-cli$' - tar -tzf "$cli" | grep -E '/LICENSE-APACHE$' - mkdir -p "$RUNNER_TEMP/cli" && tar -xzf "$cli" -C "$RUNNER_TEMP/cli" - "$RUNNER_TEMP/cli/pdfcraft-cli-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}/pdfcraft-cli" --version - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.pdfcraft.yml')); assert m['id'] == 'ai.storyteller.pdfcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux-riscv64: - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only (the - # app tree and the CLI-only archive); the CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$PDFCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - # The same -dev packages as the linux job, for riscv64; the C toolchain also builds - # aws-lc-sys and ring (signing, TLS). - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu g++-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross \ - libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - - name: Build and package (tar.gz, CLI tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - CC_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-gcc - CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++ - AR_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-ar - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats "tar cli" - - name: Check the packages - run: | - tar -tzf "dist/release/pdfcraft-$PDFCRAFT_VERSION-linux-riscv64.tar.gz" | grep -E '/bin/pdfcraft$' - # The OCR models (#103), where the installed app looks: /../share/pdfcraft/models. - tar -tzf "dist/release/pdfcraft-$PDFCRAFT_VERSION-linux-riscv64.tar.gz" | grep -E '/share/pdfcraft/models/.+\.onnx$' - cli="dist/release/pdfcraft-cli-$PDFCRAFT_VERSION-linux-riscv64.tar.gz" - tar -tzf "$cli" | grep -E '/pdfcraft-cli$' - tar -tzf "$cli" | grep -E '/LICENSE-APACHE$' - file target/riscv64gc-unknown-linux-gnu/release/pdfcraft | grep -F 'RISC-V' - - uses: actions/upload-artifact@v4 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/pdfcraft-$PDFCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD VM (see .github/workflows/freebsd.yml). - runs-on: ubuntu-latest - timeout-minutes: 180 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - VERSION_OVERRIDE: ${{ inputs.version }} - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - name: Build and package in a FreeBSD VM (tar.gz) - # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - envs: "PDFCRAFT_VERSION PDFCRAFT_BUILD_DATE VERSION_OVERRIDE" - # The same packages as the FreeBSD CI workflow, plus bash for the packaging script. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - set -e - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - if [ -n "$VERSION_OVERRIDE" ]; then cargo xtask version set "$PDFCRAFT_VERSION"; fi - packaging/freebsd/package.sh - # Only dist/ comes back out of the VM: copying the target dir back fails on large builds. - rm -rf target - - uses: actions/upload-artifact@v4 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - PDFCRAFT_VERSION: ${{ needs.version.outputs.version }} - PDFCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - steps: - - uses: actions/checkout@v4 - # The craft-fonts build input (Japanese fonts): every release embeds it. Bump deliberately. - - name: Check out craft-fonts - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - with: - key: release-web - - name: Install trunk - run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$PDFCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - release: - name: Draft GitHub Release - # Needs the signing jobs, which only run on `release`: a branch dry run never drafts a release. - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@v4 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="PdfCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Pinned: a mutable reference to another repository can change what CI runs. Move it deliberately. + uses: storytold/craft-common-actions/.github/workflows/release.yml@2b3fb9dc05036f73c77d18f753a3aa9da55fa386 # main, 31 October 2026 + with: + app-name: pdfcraft + app-title: PlutarchPages + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000000..6f99597f63 --- /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: + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@2b3fb9dc05036f73c77d18f753a3aa9da55fa386 # main, 31 October 2026 + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 07d2861aa2..438231da2d 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -25,134 +25,12 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@v2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 - with: - name: pdfcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@v4 - with: - name: pdfcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it, reinstall without the desktop shortcut - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - # A command-line override must not put files in a user profile while registering - # their handlers machine-wide (#305). This runs on a disposable CI runner. - $p = Start-Process msiexec.exe -WindowStyle Hidden -ArgumentList '/i', "`"$msi`"", '/qn', 'ALLUSERS=2', 'MSIINSTALLPERUSER=1', '/l*v', 'per-user.log' -Wait -PassThru - if ($p.ExitCode -ne 1603) { Get-Content per-user.log -Tail 60; throw "per-user install should fail with 1603, got $($p.ExitCode)" } - if (-not (Select-String -Path per-user.log -SimpleMatch 'PlutarchPages must be installed for all users.')) { - Get-Content per-user.log -Tail 60; throw 'per-user install did not fail on the scope guard' - } - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $shortcuts = @( - (Join-Path ([Environment]::GetFolderPath('CommonPrograms')) 'PlutarchPages.lnk'), - (Join-Path ([Environment]::GetFolderPath('CommonDesktopDirectory')) 'PlutarchPages.lnk') - ) - # Plain shortcuts to the program (#143), not advertised ones, so TargetPath is the exe. - $exePath = Join-Path $env:ProgramFiles 'PlutarchPages\pdfcraft.exe' - $wsh = New-Object -ComObject WScript.Shell - foreach ($shortcut in $shortcuts) { - if (-not (Test-Path -LiteralPath $shortcut)) { throw "installer did not create $shortcut" } - $target = $wsh.CreateShortcut($shortcut).TargetPath - if ($target -ne $exePath) { throw "$shortcut points to '$target', not $exePath" } - Write-Output "ok $shortcut -> $target" - } - $dir = Join-Path $env:ProgramFiles 'PlutarchPages' - foreach ($exe in 'pdfcraft.exe', 'pdfcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { Get-ChildItem -Recurse $env:ProgramFiles -Filter $exe | Select-Object -First 3; throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively: open, render and edit a small PDF through the engine. - $cli = Join-Path $dir 'pdfcraft-cli.exe' - & $cli tools | Out-Null - if ($LASTEXITCODE -ne 0) { throw "pdfcraft-cli tools exited $LASTEXITCODE" } - $body = 'BT /F1 24 Tf 20 150 Td (Hello ARM64) Tj ET' - $objs = @('<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', - '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>', - "<< /Length $($body.Length) >>`nstream`n$body`nendstream", '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>') - $sb = [System.Text.StringBuilder]::new("%PDF-1.7`n"); $offsets = @() - for ($i = 0; $i -lt $objs.Count; $i++) { $offsets += $sb.Length; [void]$sb.Append("$($i + 1) 0 obj`n$($objs[$i])`nendobj`n") } - $xref = $sb.Length - [void]$sb.Append("xref`n0 $($objs.Count + 1)`n0000000000 65535 f `n") - foreach ($o in $offsets) { [void]$sb.Append(('{0:D10} 00000 n `n' -f $o)) } - [void]$sb.Append("trailer`n<< /Size $($objs.Count + 1) /Root 1 0 R >>`nstartxref`n$xref`n%%EOF`n") - New-Item -ItemType Directory -Force smoke | Out-Null - [System.IO.File]::WriteAllText("$PWD\smoke\hello.pdf", $sb.ToString(), [System.Text.Encoding]::ASCII) - $steps = '[{"tool":"doc_open","args":{"path":"hello.pdf"}},{"tool":"text_paragraphs","args":{"doc":1,"page":1}},{"tool":"text_edit","args":{"doc":1,"page":1,"paragraph":1,"text":"Edited on ARM64"}},{"tool":"page_render","args":{"doc":1,"page":1,"dpi":72},"out":"page.png"},{"tool":"doc_save","args":{"doc":1,"path":"out.pdf"}}]' - [System.IO.File]::WriteAllText("$PWD\smoke\steps.json", $steps) - $out = & $cli run --script smoke\steps.json --root smoke 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "pdfcraft-cli run exited $LASTEXITCODE" } - if ($out -notmatch 'Hello ARM64') { Write-Output $out; throw 'text_paragraphs did not read the page' } - foreach ($f in 'smoke\page.png', 'smoke\out.pdf') { if (-not (Test-Path $f) -or (Get-Item $f).Length -eq 0) { Write-Output $out; throw "$f was not written" } } - Write-Output 'ok pdfcraft-cli: opened, read, edited, rendered and saved a PDF on ARM64' - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'pdfcraft.exe')) { throw 'uninstall left pdfcraft.exe behind' } - foreach ($shortcut in $shortcuts) { - if (Test-Path -LiteralPath $shortcut) { throw "uninstall left $shortcut behind" } - } - Write-Output 'ok uninstalled' - # Unattended installs can opt out of the desktop shortcut (#143); the Start Menu one stays. - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', 'INSTALLDESKTOPSHORTCUT=0', '/l*v', 'install-nodesktop.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install-nodesktop.log -Tail 60; throw "msiexec /i INSTALLDESKTOPSHORTCUT=0 exited $($p.ExitCode)" } - if (-not (Test-Path -LiteralPath $shortcuts[0])) { throw "INSTALLDESKTOPSHORTCUT=0 also skipped $($shortcuts[0])" } - if (Test-Path -LiteralPath $shortcuts[1]) { throw "INSTALLDESKTOPSHORTCUT=0 still created $($shortcuts[1])" } - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path -LiteralPath $shortcuts[0]) { throw "uninstall left $($shortcuts[0]) behind" } - Write-Output 'ok INSTALLDESKTOPSHORTCUT=0: Start Menu shortcut only' - - test: - name: test (native ARM64) - runs-on: windows-11-arm - timeout-minutes: 120 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: windows-arm64-test - - name: Test - run: cargo test --workspace --locked + windows-arm64: + # Pinned: a mutable reference to another repository can change what CI runs. Move it deliberately. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@2b3fb9dc05036f73c77d18f753a3aa9da55fa386 # main, 31 October 2026 + with: + app-name: pdfcraft + app-dir: PlutarchPages + install-relative-path: Programs/PlutarchPages + install-scope: perUser diff --git a/AGENTS.md b/AGENTS.md index 671d7a6ca3..fc3a75a673 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,12 +20,12 @@ 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, and are embedded as code in hayro. + - 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. Adding anything to this list needs the user's explicit approval. @@ -54,16 +54,17 @@ 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 @@ -137,3 +138,8 @@ Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to r 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 44435b23af..b8281a9c8a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -2,28 +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 (262) +## In this repository (288) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| -| `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/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/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 | @@ -199,7 +211,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | +| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths | | `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | | `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono Regular | The JetBrains Mono Project Authors | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono | UI monospace font; demo PDF code | @@ -218,7 +230,8 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `vendor/hayro-interpret/assets/FoxitSerifItalic.pfb` | FoxitSerifItalic (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/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) | -| `vendor/hayro-interpret/assets/LAB.icc` | Lab abstract ICC profile | The Hayro Authors (generated with LittleCMS 2) | MIT OR Apache-2.0 | https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/) | Lab colour space 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 | | `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) | @@ -242,18 +255,6 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `.github/assets/badges/fork-of.svg` | PlutarchPages README badge: fork of PdfCraft | shields.io contributors (static badge template); badge text by EquityBot | Apache-2.0 | https://img.shields.io/badge/fork%20of-PdfCraft-66023C?labelColor=000000&style=flat&color=66023C (badges/shields) | README badge row; committed static SVG, text names the font family only, no embedded fonts | | `.github/assets/badges/licence.svg` | PlutarchPages README badge: licence | shields.io contributors (static badge template); badge text by EquityBot | Apache-2.0 | https://img.shields.io/badge/licence-MIT%20OR%20Apache--2.0-66023C?labelColor=000000&style=flat&color=66023C (badges/shields) | README badge row; committed static SVG, text names the font family only, no embedded fonts | | `.github/assets/badges/rust.svg` | PlutarchPages README badge: Rust version | shields.io contributors (static badge template); badge text by EquityBot | Apache-2.0 | https://img.shields.io/badge/rust-1.90%2B-66023C?labelColor=000000&style=flat&color=66023C (badges/shields) | README badge row; committed static SVG, text names the font family only, no embedded fonts | -| `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 | -| `docs/images/pdfcraft-forms.png` | PdfCraft screenshot: Interactive form | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: forms. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | -| `docs/images/pdfcraft-layers.png` | PdfCraft screenshot: Layers | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layers. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | -| `docs/images/pdfcraft-organize.png` | PdfCraft screenshot: Organize pages | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: organize. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | -| `docs/images/pdfcraft-palette.png` | PdfCraft screenshot: Command palette | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tools. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | -| `docs/images/pdfcraft-properties.png` | PdfCraft screenshot: Document properties | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: metadata. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | -| `docs/images/pdfcraft-scripts.png` | PdfCraft screenshot: Scripts of the world | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: rendering. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | -| `docs/images/pdfcraft-split.png` | PdfCraft screenshot: Split dialog | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: split. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | -| `docs/images/pdfcraft-twoup.png` | PdfCraft screenshot: Two-up reading | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layouts. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | -| `docs/images/pdfcraft-viewer.png` | PdfCraft screenshot: Viewer with comments | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README hero. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | | `assets/icons/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | @@ -261,25 +262,50 @@ 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); in this fork its title reads PlutarchPages, outlined from Inter SemiBold (assets/fonts/Inter-SemiBold.ttf) with usvg 0.48.1 | 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; 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 | +| `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 | +| `docs/images/pdfcraft-forms.png` | PdfCraft screenshot: Interactive form | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: forms. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | +| `docs/images/pdfcraft-layers.png` | PdfCraft screenshot: Layers | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layers. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | +| `docs/images/pdfcraft-organize.png` | PdfCraft screenshot: Organize pages | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: organize. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | +| `docs/images/pdfcraft-palette.png` | PdfCraft screenshot: Command palette | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tools. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | +| `docs/images/pdfcraft-properties.png` | PdfCraft screenshot: Document properties | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: metadata. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | +| `docs/images/pdfcraft-scripts.png` | PdfCraft screenshot: Scripts of the world | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: rendering. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | +| `docs/images/pdfcraft-split.png` | PdfCraft screenshot: Split dialog | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: split. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | +| `docs/images/pdfcraft-tools.png` | PdfCraft screenshot: All tools | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tool catalogue. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | +| `docs/images/pdfcraft-twoup.png` | PdfCraft screenshot: Two-up reading | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layouts. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | +| `docs/images/pdfcraft-viewer.png` | PdfCraft screenshot: Viewer with comments | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README hero. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts | ## 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) @@ -308,8 +334,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.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-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.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-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) @@ -317,4 +343,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 2ed16dbe7b..a735efea83 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -1,7 +1,19 @@ +[[asset]] +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; 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.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); @@ -14,177 +26,270 @@ 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 = "68e8ca7bdb19a321f6f70843abfb1bc08c2f2ea353177e51f0d724d323111768" +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 = "8da3d11470b9c4304c18fc56dd7c9fc88a97fd06cf82253d65cf722422290f8f" +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 = "8df881d04ba4016693b920d08dd6d8dd9ff53cf1e90252b1d1d1ea0f1abccfee" +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 = "2f423a95c84a9be90b00ecc0d75952d7b2ca373b01469f8bf9363a6a577ce0ac" +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 = "52b51c06916e208a455b55a244e19e04e9462e6096baa2d6017d593ed6608c60" +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 = "b2feef88102b920102075be873a741b8958890cd92701c61719eace1d310c04d" +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 = "90992a1f62beecfb952c677c9f9b15edf4d4444f3fa5b53df096f4e053f74013" +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 = "3fdcd98f48029f1c56cae4515dc13cb8f65675f0d366e79e1b13471b0602fa65" +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 = "0a103ed5869ac5882acfd3ea8b19e58ebed7b6f7913859373be93ce757e8f04b" +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 = "a19bdd068d817575006aed9b51879abca63fa0852227f4cedafa3b5c08ab5766" +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 = "51de90f7bb066acc9500f4eff5c7f2277d2ed81bd0d314d058241b2080f00292" +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 = "f8087a0cdb58f16efaff2ffadfe3d49c49d90eecfd0da98352df46dad7037891" +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.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; 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 = "c43faa6092a8ca97ff12b246420d2b849f0b3642b3f45bce17bae9d334aa1c8f" +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 = "4ebc0823d4573e428cfb0fe819351e0fc3b487f06bc4deb5268bc1f385be028d" +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 = "b35c5e81fe2cd114142f16413d7a9be00dea8b1c67253fb7ba7f67f379d66253" +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 = "6d2bd38132f411d439eb9ae1f54c3e512c4a6a67c322b243fab316043e3ac8bf" +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; 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.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; 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.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" @@ -2119,7 +2224,7 @@ source = "https://github.com/rsms/inter" licence = "OFL-1.1" licence_file = "assets/fonts/OFL-Inter.txt" kind = "font" -usage = "UI font, embedded in the app; demo PDF labels" +usage = "UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths" sha256 = "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82" [[asset]] @@ -2321,19 +2426,20 @@ usage = "Default CMYK colour conversion (vendored hayro-interpret)" sha256 = "73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0" [[asset]] -path = "vendor/hayro-interpret/assets/LAB.icc" -title = "Lab abstract ICC profile" -author = "The Hayro Authors (generated with LittleCMS 2)" -source = "https://github.com/LaurenzV/hayro (hayro-interpret 0.7.0 assets/)" +path = "crates/fonts/src/std14_metrics.rs" +title = "Standard-14 font advance widths and encodings (as Rust tables)" +author = "Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors" +source = "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." licence = "MIT OR Apache-2.0" -licence_file = "vendor/hayro-interpret/LICENSE-MIT" +licence_file = "LICENSE-MIT" kind = "data" -usage = "Lab colour space conversion (vendored hayro-interpret)" -sha256 = "a6efc5c30ece826eba2a83ddf5aa7026289b0c5ffa90acb129bc59ab8d7f5c59" +adobe_data = true +usage = "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." +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" @@ -2345,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)" @@ -2357,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" @@ -2674,7 +2791,7 @@ 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 = "ba79fea56c0e4b76f063c86594d6f447275b507c722fd56583db591515385c06" [[asset]] path = "assets/app-icon/pdfcraft-small.svg" @@ -2685,7 +2802,7 @@ 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 = "ba4c15c0e34e597083cf89769b08e0db6fee53994f512f227c1c154a4a6ecef9" [[asset]] path = "assets/app-icon/pdfcraft-1024.png" @@ -2696,7 +2813,7 @@ 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 = "15fc405f45d1e5958da48630ae1230d797555fbc8d47ea728ce087b522183c62" [[asset]] path = "assets/app-icon/pdfcraft.icns" @@ -2707,7 +2824,7 @@ licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "macOS app bundle icon" -sha256 = "2425148222873de241d267aba667a8b234ecdd0149f2ca8bb6e79443b743b1e8" +sha256 = "9aaf5f2cf012cd376867102579445f92a70a3a41ffa83ad285609962d7cba0d2" [[asset]] path = "assets/app-icon/pdfcraft.ico" @@ -2718,7 +2835,7 @@ 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 = "4038fbd7c4a1a8fb16a82dbe4a93fafd4342cedcc353850e03c186c574fa0785" [[asset]] path = "assets/app-icon/pdfcraft-document.svg" @@ -2729,7 +2846,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 = "dc99b273428705256129319d526f474e107bcdd2325c923d43d9e7c6717a137f" [[asset]] path = "assets/app-icon/pdfcraft-document.ico" @@ -2740,7 +2857,7 @@ 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 = "614a86c0cedbc663c15150c2e14ce1d4d5ee0b37952ebbbe5c8b8dbb1b6781a5" [[asset]] path = "assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png" @@ -2751,7 +2868,7 @@ 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 = "1b4b38909b2deac20bb37e86f9df35e843e3e56fdacb8d7663f37ef6aa47cf1d" [[asset]] path = "assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png" @@ -2762,7 +2879,7 @@ 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 = "4ed2a977e7643769ec7c144fe5146d4656ef4461f915c62c6292ca6ae48552d8" [[asset]] path = "assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png" @@ -2773,7 +2890,7 @@ 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 = "cbcec71077b4541c2cb263a4d8baf6046fe81e9f29a8a36d3539d0bc3f264bb7" [[asset]] path = "assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png" @@ -2784,7 +2901,7 @@ 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 = "a17bfe4958a5bfc25ca7d76c8f054ce1b1ff0c3088ece97537a87105ed5b3b4a" [[asset]] path = "assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" @@ -2795,7 +2912,7 @@ 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 = "50b11c39ccdba334dfd4962eb77b403a48abfd30b6acbf9b58ce3ec2fb67e57e" [[asset]] path = "assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png" @@ -2806,7 +2923,7 @@ 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 = "980dff6bbd2312a3adb8f5e0bd466ba6ef734bd9401f68040bfb39bbcdd41e47" [[asset]] path = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" @@ -2817,7 +2934,7 @@ 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 = "cdc0db0b1cbba5353f2b0743bff4086b7d059ee3c8719a8618a8c42a3146af0e" [[asset]] path = "assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png" @@ -2828,7 +2945,7 @@ 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 = "5f227f3f36ec43d9b58aaf2b90af211ebfa734ce976ab4d93307fe597b39e116" [[asset]] path = "assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg" @@ -2839,7 +2956,7 @@ 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 = "ba79fea56c0e4b76f063c86594d6f447275b507c722fd56583db591515385c06" [[asset]] path = ".github/assets/banner-light.svg" @@ -2916,139 +3033,7 @@ licence = "Apache-2.0" licence_file = "LICENSE-APACHE" kind = "image" usage = "README badge row; committed static SVG, text names the font family only, no embedded fonts" -sha256 = "4ad8c783ef128abc2398825c4248b840938e04f86503f83b4de4452673d0bd57" - -[[asset]] -path = "docs/images/pdfcraft-bookmarks.png" -title = "PdfCraft screenshot: Bookmarks" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: navigation. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "6daf2dceda8737da0c5f9e7895e5c4cc24cc845938ffca478713c2230573240d" - -[[asset]] -path = "docs/images/pdfcraft-dark.png" -title = "PdfCraft screenshot: Dark theme" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: themes. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "18fbd4931b2a8d01a88aef0d5d0837e3cdc12aa35f8529bedbb4a930565d47e5" - -[[asset]] -path = "docs/images/pdfcraft-find.png" -title = "PdfCraft screenshot: Find text" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: search. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "b52771502be4c03e2dabf107c77d5183fad7f39655146d59761dda855c2d4b52" - -[[asset]] -path = "docs/images/pdfcraft-forms.png" -title = "PdfCraft screenshot: Interactive form" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: forms. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "f8eb7705056d32e9c8e4d3b8b197c3e500faae08672e4622d6327d2799b536e7" - -[[asset]] -path = "docs/images/pdfcraft-layers.png" -title = "PdfCraft screenshot: Layers" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: layers. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "20ff9e1c6ecc74d000a7514b82fe9e39f82c8ef5e7c1a52e9060c5ca349020d6" - -[[asset]] -path = "docs/images/pdfcraft-organize.png" -title = "PdfCraft screenshot: Organize pages" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: organize. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "0a309ba73bcfccbe0106824b55fc347cc0b32293d1db68149d5eccec9c087ba1" - -[[asset]] -path = "docs/images/pdfcraft-palette.png" -title = "PdfCraft screenshot: Command palette" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: tools. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "74867793e506c03255459e838eef0b08e485c13b7ea8a6b8de3ec16870067eda" - -[[asset]] -path = "docs/images/pdfcraft-properties.png" -title = "PdfCraft screenshot: Document properties" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: metadata. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "86f293543b299fa1665db999a68978138f8d61a4082d7c5b5025498e188da459" - -[[asset]] -path = "docs/images/pdfcraft-scripts.png" -title = "PdfCraft screenshot: Scripts of the world" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: rendering. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "0dde8ed5a14dee7396379963adff517bd249c7dd0fe494c389b612e643584b15" - -[[asset]] -path = "docs/images/pdfcraft-split.png" -title = "PdfCraft screenshot: Split dialog" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: split. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "bf31e13b563ead564af968c5fae8d1c07b91ee38867d0cf1aae9e0582efc2fe5" - -[[asset]] -path = "docs/images/pdfcraft-twoup.png" -title = "PdfCraft screenshot: Two-up reading" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README: layouts. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "a72ed1b84c827e3aa1dd3282d857b4742f02d8ff23fc39b62115c840ebad14b4" - -[[asset]] -path = "docs/images/pdfcraft-viewer.png" -title = "PdfCraft screenshot: Viewer with comments" -author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" -source = "this repository" -licence = "MIT OR Apache-2.0" -licence_file = "LICENSE-MIT" -kind = "image" -usage = "README hero. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" -sha256 = "ecd261028d8837f7f7c013dd796d81ca7e89643bcfcbad3ab9fbfbe87ded04e3" +sha256 = "d60e88a8c56df37dda916263fa5cea446da6d3f8ab886bcb877252c9e2555115" [[asset]] path = "assets/icons/pin.svg" @@ -3135,7 +3120,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]] @@ -3168,7 +3153,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]] @@ -3202,7 +3187,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 = "f208091c702350d39096be33ab2ac1ed521ce7cbc93d44789911c5ddd7a5b799" +sha256 = "769e2a49eb96283ae48d1109bdb0ee159b1b17b9f407f440e092c6818eaad904" [[asset]] @@ -3214,14 +3199,14 @@ 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 = "e6ab168dad3e125196590375a41da1eb6638c74ebe68004508c44d72374eb916" +sha256 = "5b8c3b1395805311d3c43fde2682e0a73dbb900d52faee60845f8655fab38b48" [[fetched]] 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" @@ -3234,7 +3219,7 @@ 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" @@ -3245,13 +3230,13 @@ 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" @@ -3261,5 +3246,275 @@ 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]] +path = "assets/icons/monitor.svg" +title = "Lucide icon \"monitor\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.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 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" + +[[asset]] +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; 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.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; 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.rs" + +[[asset]] + +path = "vendor/vello_common/assets/probe.png" +title = "vello_common probe reference image (rendered from probe.rgba)" +author = "Linebender vello contributors" +source = "vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary" +licence = "MIT OR Apache-2.0" +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" + +[[asset]] +path = "docs/images/pdfcraft-bookmarks.png" +title = "PdfCraft screenshot: Bookmarks" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: navigation. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "4bae80dfa57ab0edbef73de503a84f54b8ec2cd9ef5e0b358b93f99263045b14" + +[[asset]] +path = "docs/images/pdfcraft-dark.png" +title = "PdfCraft screenshot: Dark theme" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: themes. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "014303098d27cd866010bd75976ddae147301eb5bd5c535a264f0f0a715e96e0" + +[[asset]] +path = "docs/images/pdfcraft-find.png" +title = "PdfCraft screenshot: Find text" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: search. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "8e5683fef9c6443f72cb7c11443786dd7979dbe294614d00322d8bede4c401e1" + +[[asset]] +path = "docs/images/pdfcraft-forms.png" +title = "PdfCraft screenshot: Interactive form" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: forms. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "aec8c79bb95ac97bbd63bdd65c1b4b26046fb43897cae76af40540dead0afa0d" + +[[asset]] +path = "docs/images/pdfcraft-layers.png" +title = "PdfCraft screenshot: Layers" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: layers. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "06c8558117f4fc437cb5a6ec0041f640331174e222e320697e4597219dd6405b" + +[[asset]] +path = "docs/images/pdfcraft-organize.png" +title = "PdfCraft screenshot: Organize pages" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: organize. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "78e6efd83b8b4916cfcbf6e99a48b0288026c383b7c005a00feaf56e6c63880e" + +[[asset]] +path = "docs/images/pdfcraft-palette.png" +title = "PdfCraft screenshot: Command palette" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: tools. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "4a56444416b7140f1ac696732d19760ecf7cefa74291a1513443dd0b2af9f189" + +[[asset]] +path = "docs/images/pdfcraft-properties.png" +title = "PdfCraft screenshot: Document properties" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: metadata. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "ef7466e501b0da941dbf21e4829bc4aa1f78551af2ffafea9fb3dab04039b33b" + +[[asset]] +path = "docs/images/pdfcraft-scripts.png" +title = "PdfCraft screenshot: Scripts of the world" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: rendering. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "24375f19e0cde9396ff4bda222d9252e8cfb1643be93de7e53b9ae5d5e15ac84" + +[[asset]] +path = "docs/images/pdfcraft-split.png" +title = "PdfCraft screenshot: Split dialog" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: split. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "831ae570c16ab6700573a6a6af0db03d66be0e7fd4fca2df6f4109772da6a934" + +[[asset]] +path = "docs/images/pdfcraft-tools.png" +title = "PdfCraft screenshot: All tools" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: tool catalogue. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "0d8604f1d80df90f0fc9613ec292af5b7bc9f96038beb9eb70cc220926f20903" + +[[asset]] +path = "docs/images/pdfcraft-twoup.png" +title = "PdfCraft screenshot: Two-up reading" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README: layouts. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "6264fe79427a83b481325c621b820e1e023335e9f236f18c964f5a3969118721" + +[[asset]] +path = "docs/images/pdfcraft-viewer.png" +title = "PdfCraft screenshot: Viewer with comments" +author = "PdfCraft contributors (generated by `cargo xtask screenshots`)" +source = "this repository" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "image" +usage = "README hero. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts" +sha256 = "c7db562f8a6a78e6ba452e5c7ff45c505c52fa7fab769819904c5f712a652025" diff --git a/CLAUDE.md b/CLAUDE.md index 760c917345..cf90b9f2e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,8 @@ 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. - **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. @@ -31,8 +33,18 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - 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`). - **Rust only** in the product and build (`xtask`). No handwritten JS/TS. -- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace -- -D warnings`, `cargo test --workspace`, and the wasm check once `xtask ci` exists (M0). -- **Commits:** one task id per commit (e.g. `M1.4: xref stream reader`). Commit only green states. End messages with the attribution line required by the environment. +- **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. ## Running and looking at the app - `cargo run -p pdfcraft -- ` opens the desktop app. @@ -45,3 +57,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 e625f36903..8ede03596b 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.36.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.38.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" 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", @@ -131,7 +126,7 @@ checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -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,19 +443,20 @@ 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", "zeroize", ] [[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", @@ -494,33 +491,37 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bit-set" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ - "bit-vec 0.8.0", + "bit-vec 0.9.1", ] [[package]] name = "bit-set" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" dependencies = [ - "bit-vec 0.9.1", + "bit-vec 0.10.1", ] [[package]] name = "bit-vec" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bit-vec" -version = "0.9.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] [[package]] name = "bitflags" @@ -537,6 +538,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -570,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]] @@ -639,7 +649,7 @@ dependencies = [ "oneshot", "pastey", "portable-atomic", - "rand 0.10.3", + "rand", "regress", "rustc-hash 2.1.3", "ryu-js", @@ -730,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" @@ -913,7 +947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -924,7 +958,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", "windows-link", ] @@ -934,7 +971,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common", + "crypto-common 0.2.2", "inout", ] @@ -962,6 +999,39 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der 0.7.10", + "spki 0.7.3", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "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" @@ -1007,6 +1077,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1081,6 +1157,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -1090,13 +1175,139 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +dependencies = [ + "egui", + "serde", + "serde_json", +] + +[[package]] +name = "craft-menu" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.9.1", + "roxmltree 0.21.2", + "tempfile", + "winprint", + "winsafe", +] + +[[package]] +name = "craft-quit" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +dependencies = [ + "dispatch2", + "egui", + "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=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +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=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" 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]] @@ -1108,6 +1319,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der 0.7.10", + "spki 0.7.3", + "x509-cert", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -1156,6 +1388,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -1200,9 +1442,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "curve25519-dalek-derive", - "digest", + "digest 0.11.3", "fiat-crypto", "rustc_version", "subtle", @@ -1240,17 +1482,41 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1266,15 +1532,25 @@ dependencies = [ "cipher", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", "ctutils", ] @@ -1293,7 +1569,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1378,20 +1654,19 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der", - "digest", + "der 0.8.2", + "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 3.0.0", + "spki 0.8.0", "zeroize", ] [[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", @@ -1404,7 +1679,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "signature", + "signature 3.0.0", ] [[package]] @@ -1415,16 +1690,15 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "sha2", + "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", @@ -1439,7 +1713,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", @@ -1448,6 +1722,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1460,9 +1735,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", @@ -1482,7 +1756,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", @@ -1500,32 +1775,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", @@ -1538,9 +1816,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", @@ -1552,9 +1829,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", @@ -1582,8 +1858,8 @@ checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ "base16ct", "crypto-bigint", - "crypto-common", - "digest", + "crypto-common 0.2.2", + "digest 0.11.3", "ff", "group", "hybrid-array", @@ -1596,9 +1872,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", @@ -1626,22 +1901,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]] @@ -1678,9 +1953,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", @@ -1697,16 +1971,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" @@ -1828,6 +2103,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1835,11 +2120,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] -name = "fixedbitset" +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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flatbuffers" version = "24.12.23" @@ -1867,13 +2169,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "fmt-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" +dependencies = [ + "fmt-derive-proc", +] + +[[package]] +name = "fmt-derive-proc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fmv-macos-events" 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", ] @@ -1884,6 +2208,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" @@ -1909,6 +2239,15 @@ dependencies = [ "serde", ] +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1951,6 +2290,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-concurrency" version = "7.7.1" @@ -2000,6 +2349,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -2013,12 +2368,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2036,8 +2404,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -2094,7 +2464,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", @@ -2106,18 +2476,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]] @@ -2146,7 +2516,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", @@ -2232,6 +2602,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2262,6 +2651,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" @@ -2270,7 +2668,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2281,7 +2679,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2318,8 +2716,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", @@ -2362,8 +2758,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" @@ -2376,6 +2770,7 @@ dependencies = [ "memchr", "rustc-hash 2.1.3", "smallvec", + "stringprep", "zune-jpeg", ] @@ -2403,7 +2798,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -2416,6 +2811,29 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" @@ -2433,6 +2851,68 @@ dependencies = [ "zeroize", ] +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2471,6 +2951,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" @@ -2479,11 +2976,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" @@ -2496,6 +3014,7 @@ dependencies = [ "icu_provider", "smallvec", "utf16_iter", + "utf8_iter", "write16", "zerovec", ] @@ -2506,6 +3025,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" @@ -2535,6 +3080,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2542,6 +3090,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" @@ -2613,6 +3185,12 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be" +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "itertools" version = "0.15.0" @@ -2714,7 +3292,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", ] [[package]] @@ -2736,12 +3324,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]] @@ -2824,6 +3412,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" @@ -2864,15 +3455,21 @@ dependencies = [ "log", "md-5", "nom", - "rand 0.10.3", + "rand", "rangemap", "rayon", - "sha2", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", "weezl 0.2.1", ] +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "mail-parser" version = "0.11.9" @@ -2890,7 +3487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -2917,6 +3514,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2937,6 +3540,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2948,16 +3562,34 @@ dependencies = [ ] [[package]] -name = "multiversion_no_op" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" - -[[package]] -name = "naga" -version = "30.0.1" +name = "muda" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "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.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", "bit-set 0.10.0", @@ -3021,6 +3653,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" @@ -3131,9 +3775,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", ] @@ -3162,7 +3806,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", @@ -3212,7 +3856,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3223,7 +3867,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3279,7 +3923,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3290,7 +3934,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", ] @@ -3326,7 +3970,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", ] @@ -3350,7 +3994,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", @@ -3395,7 +4039,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", ] @@ -3486,6 +4130,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" @@ -3496,7 +4150,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2", + "sha2 0.11.1", ] [[package]] @@ -3510,7 +4164,7 @@ dependencies = [ "fiat-crypto", "primefield", "primeorder", - "sha2", + "sha2 0.11.1", ] [[package]] @@ -3524,7 +4178,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2", + "sha2 0.11.1", ] [[package]] @@ -3568,34 +4222,41 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest", + "digest 0.11.3", "hmac", ] [[package]] name = "pdfcraft" -version = "0.6.0" +version = "0.15.0" dependencies = [ + "chrono", + "craft-update", "eframe", "egui", "fmv-macos-events", "log", + "pdfcraft-engine", "pdfcraft-ui-egui", "pollster 1.0.1", - "rustls-native-certs", + "raw-window-handle", "serde_json", "uds_windows", - "ureq", + "wayland-client", + "wayland-protocols", + "windows", + "winit", "winresource", "winsafe", ] [[package]] name = "pdfcraft-a11y" -version = "0.6.0" +version = "0.15.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-model", "thiserror 2.0.21", @@ -3603,7 +4264,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.6.0" +version = "0.15.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3612,32 +4273,52 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-audit" +version = "0.15.0" +dependencies = [ + "pdfcraft-content", + "pdfcraft-cos", + "pdfcraft-edit", + "pdfcraft-model", + "thiserror 2.0.21", +] + [[package]] name = "pdfcraft-automation" -version = "0.6.0" +version = "0.15.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", + "flate2", "image", "pdfcraft-cos", "pdfcraft-engine", + "pdfcraft-fonts", "pdfcraft-model", "pdfcraft-organize", "pdfcraft-platform", "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", + "rustix 1.1.5", "serde_json", + "sha2 0.11.1", + "tempfile", ] [[package]] name = "pdfcraft-cli" -version = "0.6.0" +version = "0.15.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", + "pdfcraft-audit", "pdfcraft-automation", "pdfcraft-cos", "pdfcraft-engine", + "pdfcraft-platform", + "pdfcraft-preflight", "pdfcraft-render", + "png 0.18.1", "rustix 1.1.5", "serde_json", "winresource", @@ -3645,22 +4326,26 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.6.0" +version = "0.15.0" [[package]] name = "pdfcraft-content" -version = "0.6.0" +version = "0.15.0" dependencies = [ + "hayro-syntax", "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.6.0" +version = "0.15.0" dependencies = [ + "chrono", "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", + "pdfcraft-oracle", + "pdfcraft-testkit", "proptest", "serde_json", "thiserror 2.0.21", @@ -3668,7 +4353,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.6.0" +version = "0.15.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -3677,29 +4362,32 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-xfa", "png 0.18.1", "pulldown-cmark", "thiserror 2.0.21", "tiff", + "zune-jpeg", ] [[package]] name = "pdfcraft-crypt" -version = "0.6.0" +version = "0.15.0" dependencies = [ "aes", "md-5", "proptest", - "sha2", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-edit" -version = "0.6.0" +version = "0.15.0" dependencies = [ "hayro-syntax", + "kurbo 0.13.1", "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", @@ -3711,14 +4399,16 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.6.0" +version = "0.15.0" dependencies = [ "chrono", + "craft-docpath", "icu_properties", "idna", "image", "pdfcraft-a11y", "pdfcraft-annot", + "pdfcraft-audit", "pdfcraft-compare", "pdfcraft-cos", "pdfcraft-create", @@ -3727,6 +4417,7 @@ dependencies = [ "pdfcraft-external", "pdfcraft-fonts", "pdfcraft-forms", + "pdfcraft-geom", "pdfcraft-js", "pdfcraft-measure", "pdfcraft-model", @@ -3738,23 +4429,26 @@ dependencies = [ "pdfcraft-redact", "pdfcraft-render", "pdfcraft-sign", + "pdfcraft-testkit", "pdfcraft-xfa", "pdfcraft-xfdf", "png 0.18.1", "thiserror 2.0.21", + "tiff", + "unicode-segmentation", "url", ] [[package]] name = "pdfcraft-export" -version = "0.6.0" +version = "0.15.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-external" -version = "0.6.0" +version = "0.15.0" dependencies = [ "rustix 1.1.5", "tempfile", @@ -3764,7 +4458,7 @@ dependencies = [ [[package]] name = "pdfcraft-filters" -version = "0.6.0" +version = "0.15.0" dependencies = [ "flate2", "proptest", @@ -3774,116 +4468,136 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.6.0" +version = "0.15.0" dependencies = [ "harfrust", "pdfcraft-cos", "skrifa 0.44.0", + "unicode-bidi", ] [[package]] name = "pdfcraft-forms" -version = "0.6.0" +version = "0.15.0" dependencies = [ "hayro-syntax", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "proptest", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-geom" -version = "0.6.0" +version = "0.15.0" [[package]] name = "pdfcraft-js" -version = "0.6.0" +version = "0.15.0" dependencies = [ "boa_engine", + "chrono", ] [[package]] name = "pdfcraft-measure" -version = "0.6.0" +version = "0.15.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "pdfcraft-testkit", "serde", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-model" -version = "0.6.0" +version = "0.15.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.6.0" +version = "0.15.0" dependencies = [ "ocrs", "pdfcraft-fonts", "rten", + "tempfile", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-optimize" -version = "0.6.0" +version = "0.15.0" dependencies = [ "image", "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-model", "pdfcraft-render", "thiserror 2.0.21", + "zune-jpeg", ] +[[package]] +name = "pdfcraft-oracle" +version = "0.15.0" + [[package]] name = "pdfcraft-organize" -version = "0.6.0" +version = "0.15.0" dependencies = [ "hayro-syntax", "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-oracle", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-platform" -version = "0.6.0" +version = "0.15.0" dependencies = [ + "async-channel", + "futures-lite", + "rustix 1.1.5", "zbus", ] [[package]] name = "pdfcraft-preflight" -version = "0.6.0" +version = "0.15.0" dependencies = [ + "flate2", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "roxmltree 0.21.2", ] [[package]] name = "pdfcraft-print" -version = "0.6.0" +version = "0.15.0" dependencies = [ + "craft-print", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "pdfcraft-render", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-redact" -version = "0.6.0" +version = "0.15.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -3892,12 +4606,14 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", + "pdfcraft-testkit", + "pdfcraft-xfa", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-render" -version = "0.6.0" +version = "0.15.0" dependencies = [ "bytemuck", "flate2", @@ -3909,20 +4625,22 @@ dependencies = [ "lopdf", "pdfcraft-cos", "pdfcraft-fonts", + "serde", "skrifa 0.44.0", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-sign" -version = "0.6.0" +version = "0.15.0" dependencies = [ "aes", "aws-lc-rs", - "base64 0.22.1", + "base64 0.23.1", "bp256", "bp384", "cbc", + "chrono", "crypto-bigint", "des", "ecdsa", @@ -3936,34 +4654,46 @@ dependencies = [ "pdfcraft-annot", "pdfcraft-cos", "pdfcraft-fonts", + "pdfcraft-model", "rc2", "ripemd", "rsa", "rustls-cng", "security-framework", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.1", "sha3", "thiserror 2.0.21", ] +[[package]] +name = "pdfcraft-testkit" +version = "0.15.0" + [[package]] name = "pdfcraft-ui-egui" -version = "0.6.0" +version = "0.15.0" 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", "egui_kittest", + "fontconfig-parser", "getrandom 0.4.3", "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", @@ -3973,9 +4703,11 @@ dependencies = [ "pdfcraft-platform", "pdfcraft-render", "pdfcraft-xfa", + "plist", "png 0.18.1", "pollster 1.0.1", "rfd", + "rustix 1.1.5", "serde", "serde_json", "skrifa 0.44.0", @@ -3985,11 +4717,14 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "web-time", + "winsafe", + "xattr", ] [[package]] name = "pdfcraft-web" -version = "0.6.0" +version = "0.15.0" dependencies = [ "eframe", "egui", @@ -4001,30 +4736,49 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.6.0" +version = "0.15.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "image", "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", - "roxmltree", - "sha2", + "roxmltree 0.21.2", + "sha2 0.11.1", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-xfdf" -version = "0.6.0" +version = "0.15.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", "pdfcraft-forms", "pdfcraft-model", - "roxmltree", + "roxmltree 0.21.2", "thiserror 2.0.21", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -4053,6 +4807,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" @@ -4061,7 +4826,6 @@ checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros 0.13.1", "phf_shared 0.13.1", - "serde", ] [[package]] @@ -4072,6 +4836,7 @@ checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros 0.14.0", "phf_shared 0.14.0", + "serde", ] [[package]] @@ -4197,8 +4962,8 @@ version = "0.8.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" dependencies = [ - "der", - "spki", + "der 0.8.2", + "spki 0.8.0", ] [[package]] @@ -4207,8 +4972,8 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.2", + "spki 0.8.0", ] [[package]] @@ -4223,6 +4988,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" @@ -4299,12 +5077,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", ] @@ -4314,15 +5104,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" @@ -4336,7 +5117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ "crypto-bigint", - "crypto-common", + "crypto-common 0.2.2", "ff", "rand_core 0.10.1", "subtle", @@ -4365,6 +5146,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -4382,16 +5187,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", @@ -4401,9 +5207,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.5" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" +checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b" dependencies = [ "bitflags 2.13.2", "memchr", @@ -4438,12 +5244,78 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.47" +name = "quick-xml" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ - "proc-macro2", + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", ] [[package]] @@ -4458,16 +5330,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" @@ -4479,24 +5341,11 @@ 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.9.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "rand_core" @@ -4504,13 +5353,22 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[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]] @@ -4537,7 +5395,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", @@ -4652,6 +5510,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.45.1" @@ -4687,7 +5588,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", @@ -4722,7 +5623,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -4732,7 +5633,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -4755,20 +5656,29 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ - "const-oid", + "const-oid 0.10.2", "crypto-bigint", "crypto-primes", - "digest", + "digest 0.11.3", "pkcs1", "pkcs8", "rand_core 0.10.1", - "signature", + "signature 3.0.0", "zeroize", ] @@ -4942,6 +5852,7 @@ version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -4964,9 +5875,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", @@ -4980,18 +5891,47 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -5012,6 +5952,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "ryu-js" version = "1.0.3" @@ -5056,7 +6002,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", "ctutils", - "der", + "der 0.8.2", "hybrid-array", "subtle", "zeroize", @@ -5096,6 +6042,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" @@ -5129,9 +6085,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", @@ -5140,6 +6096,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -5160,6 +6127,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serdect" version = "0.4.3" @@ -5170,6 +6149,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -5177,19 +6167,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] name = "sha2" -version = "0.11.0" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -5198,7 +6199,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ - "digest", + "digest 0.11.3", "keccak", "sponge-cursor", ] @@ -5219,16 +6220,185 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest", + "digest 0.11.3", "rand_core 0.10.1", ] +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid 0.9.6", + "der 0.7.10", + "digest 0.10.7", + "pem", + "rand_core 0.10.1", + "sha2 0.10.9", + "signature 2.2.0", + "sigstore-types", + "spki 0.7.3", + "thiserror 2.0.21", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid 0.9.6", + "der 0.7.10", + "hex", + "rand", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid 0.9.6", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5401,6 +6571,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -5410,6 +6590,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -5417,7 +6607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.2", ] [[package]] @@ -5495,6 +6685,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -5517,6 +6717,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -5534,6 +6743,30 @@ 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]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", "libc", ] @@ -5549,6 +6782,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5556,7 +6800,7 @@ 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.61.2", @@ -5694,25 +6938,76 @@ dependencies = [ name = "tinystr" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "displaydoc", - "zerovec", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", ] [[package]] -name = "tinyvec" -version = "1.13.3" +name = "tokio-rustls" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] [[package]] -name = "tokio" -version = "1.53.1" +name = "tokio-util" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", "pin-project-lite", + "tokio", ] [[package]] @@ -5766,6 +7061,51 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -5798,6 +7138,23 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "type-map" version = "0.5.1" @@ -5807,6 +7164,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" @@ -5887,6 +7250,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -5895,24 +7264,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", @@ -5945,7 +7316,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -5986,8 +7357,6 @@ dependencies = [ [[package]] name = "vello_common" version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" dependencies = [ "bytemuck", "fearless_simd", @@ -6019,9 +7388,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", @@ -6035,8 +7404,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", @@ -6047,14 +7414,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]] @@ -6082,6 +7459,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -6272,7 +7658,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -6317,13 +7703,22 @@ 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", "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -6464,7 +7859,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", @@ -6611,6 +8006,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -6635,7 +8041,7 @@ 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]] @@ -6644,16 +8050,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -6671,31 +8068,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]] @@ -6713,99 +8093,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", @@ -6861,11 +8194,42 @@ dependencies = [ "memchr", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -6883,6 +8247,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" @@ -6906,6 +8288,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" @@ -6939,6 +8324,41 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "sha1 0.10.7", + "signature 2.2.0", + "spki 0.7.3", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der 0.7.10", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.5", +] + [[package]] name = "xcursor" version = "0.3.11" @@ -6964,6 +8384,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "xml-rs" version = "0.8.29" @@ -6981,11 +8407,14 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "lopdf", + "pdfcraft-cos", + "pdfcraft-oracle", "png 0.18.1", "serde", "serde_json", - "sha2", + "sha2 0.11.1", "toml", ] @@ -7164,6 +8593,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -7172,8 +8615,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -7182,6 +8628,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -7198,6 +8645,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" @@ -7210,6 +8674,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" @@ -7265,3 +8741,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 7f77d57e14..bf8a8fba87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,47 +5,61 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.15.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" 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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", 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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } +# 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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", 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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } +# 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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" } +# 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 = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.6.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.6.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.6.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.6.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.6.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.6.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.6.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.6.0" } -pdfcraft-model = { path = "crates/model", version = "0.6.0" } -pdfcraft-content = { path = "crates/content", version = "0.6.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.6.0" } -pdfcraft-print = { path = "crates/print", version = "0.6.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.6.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.6.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.6.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.6.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.6.0" } -pdfcraft-js = { path = "crates/js", version = "0.6.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.6.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.6.0" } -pdfcraft-export = { path = "crates/export", version = "0.6.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.6.0" } -pdfcraft-create = { path = "crates/create", version = "0.6.0" } -pdfcraft-external = { path = "crates/external", version = "0.6.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.6.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.6.0" } -pdfcraft-render = { path = "crates/render", version = "0.6.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.6.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.6.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.6.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.6.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.15.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.15.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.15.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.15.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.15.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.15.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.15.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.15.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.15.0" } +pdfcraft-model = { path = "crates/model", version = "0.15.0" } +pdfcraft-content = { path = "crates/content", version = "0.15.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.15.0" } +pdfcraft-print = { path = "crates/print", version = "0.15.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.15.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.15.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.15.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.15.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.15.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.15.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.15.0" } +pdfcraft-js = { path = "crates/js", version = "0.15.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.15.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.15.0" } +pdfcraft-export = { path = "crates/export", version = "0.15.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.15.0" } +pdfcraft-create = { path = "crates/create", version = "0.15.0" } +pdfcraft-external = { path = "crates/external", version = "0.15.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.15.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.15.0" } +pdfcraft-render = { path = "crates/render", version = "0.15.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.15.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.15.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.15.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.15.0", default-features = false } # pdf hayro = "0.7.1" @@ -60,35 +74,47 @@ 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" +incremental = false [profile.release] lto = "thin" @@ -99,7 +125,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 214d3f8968..3939d3782e 100644 --- a/NOTICE +++ b/NOTICE @@ -21,20 +21,22 @@ list (author, source, license) is in ATTRIBUTION.md. - Inter typeface (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt. - JetBrains Mono typeface (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt. - Dancing Script typeface (assets/fonts/DancingScript.ttf), Copyright 2016 The Dancing Script Project Authors — SIL Open Font License 1.1 (Reserved Font Name "Dancing Script"). See assets/fonts/OFL-DancingScript.txt. -- Japanese fonts from craft-fonts (https://github.com/storytold/craft-fonts), an optional build input +- Fonts from craft-fonts (https://github.com/storytold/craft-fonts), an optional build input (CRAFT_FONTS_DIR) that is not part of this repository. Builds made with it, which includes every official release, embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho, BIZ UDMincho; the web build only BIZ UDPGothic Regular) under the SIL Open Font License 1.1. Those - packages carry each font's licence as OFL-.txt. + packages carry each font's licence as OFL-.txt. Builds made with the craft-fonts revision + pinned in .github/workflows/ also include Noto Sans Thai (in the web build too), Copyright 2022 + The Noto Project Authors (https://github.com/notofonts/thai), under SIL OFL 1.1; its licence is + fonts/noto-sans-thai/OFL.txt in the craft-fonts build input. - hayro (rendering, via crates.io) — Apache-2.0 OR MIT. Its `embed-fonts` feature bundles the Foxit standard-14 Type 1 fonts under BSD-3-Clause ("Copyright 2014 PDFium Authors"), and hayro-cmap bundles Adobe CMap resources under BSD-3-Clause. - lopdf (bootstrap document inspection, via crates.io) — MIT. - egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0), Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT). -- Colour profiles in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram) and - LAB.icc (generated by the hayro authors with LittleCMS 2; MIT OR Apache-2.0). +- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram). Required notices: @@ -57,3 +59,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/PATCHES.md b/PATCHES.md new file mode 100644 index 0000000000..68f385aba2 --- /dev/null +++ b/PATCHES.md @@ -0,0 +1,41 @@ +# What this fork carries + +PlutarchPages is upstream [PdfCraft](https://github.com/storytold/pdfcraft) plus the changes below. +[Fork changes](README.md#fork-changes) describes them for users; this file is for whoever merges +upstream next. + +Last upstream merge: `1d1cbc4e` (30 October 2026). + +## Stricter than upstream on purpose + +Upstream's tests for these were changed to assert the fork's behaviour. Keep them that way. + +- **Redaction** refuses when content under the marks is also used where the marks don't reach. + Upstream goes ahead. Tests in `crates/redact` and `crates/automation`. +- **Copy-restricted documents** refuse text extraction through the tools, including `text_find`. +- **An empty password** never skips the Document Open prompt or lifts restrictions. `pdfcraft-cli + check-one` checks before it renders. +- **CSV exports** write formula-like cells as text, and import doesn't strip the apostrophe, so a + neutralised cell doesn't round-trip back into a formula. + +## Only here + +- The PDF-XChange-style converters, speech, bookmark, optimiser, colour and Excel features. +- Mermaid colour scheme: `crates/ui-egui/src/theme.rs` holds every interface colour, and + `chrome_literals_stay_in_the_theme` fails when a merge brings a colour literal back. The catalogue + hues in `crates/engine/src/catalog.rs` and the app icon follow it. +- Brand: no ArtCraft name or logos (`AGENTS.md` section 1.2). Upstream merges add them back, in new + files too, so search for them after every merge. +- Update check and repository links point at EquityBot. +- Shared CI: workflows from `storytold/craft-common-actions` are pinned to a commit, and SonarCloud + receives only `SONAR_TOKEN`. Upstream merges restore `@main` and `secrets: inherit`, so reapply both. +- CodeQL: `.github/codeql/codeql-config.yml` excludes tests and `vendor/`. + +## Merging upstream + +1. `git merge --no-ff` upstream's `main` on a `ryan/` branch. Never rewrite history. +2. Search for ArtCraft names, trademark-licensed assets, `storytold/pdfcraft` links in app code and + new colour literals. +3. Run `cargo xtask ci`. `cargo xtask assets --write` regenerates `ATTRIBUTION.md`. +4. A failure that also occurs on plain upstream isn't the merge's fault: check before patching it. +5. Update the commit above. diff --git a/README.md b/README.md index 7e75f9fca3..b43334ce2c 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@
[![tests](https://github.com/EquityBot/PlutarchPages/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/EquityBot/PlutarchPages/actions/workflows/ci.yml) [![CodeQL](https://github.com/EquityBot/PlutarchPages/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/EquityBot/PlutarchPages/actions/workflows/codeql.yml)
-[![rust: 1.90+](.github/assets/badges/rust.svg)](https://www.rust-lang.org/) [![licence: MIT OR Apache-2.0](.github/assets/badges/licence.svg)](#license-and-credits) [![fork of: PdfCraft](.github/assets/badges/fork-of.svg)](https://github.com/storytold/pdfcraft) +[![rust: 1.95+](.github/assets/badges/rust.svg)](https://www.rust-lang.org/) [![licence: MIT OR Apache-2.0](.github/assets/badges/licence.svg)](#license-and-credits) [![fork of: PdfCraft](.github/assets/badges/fork-of.svg)](https://github.com/storytold/pdfcraft)

- The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.
+ The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.
macOS · Windows · Linux · FreeBSD · the web

@@ -30,9 +30,9 @@

- PdfCraft with the PdfCraft Showcase cover page open, the All tools panel on the left and 20 threaded comments on the right + PlutarchPages with the PlutarchPages Showcase cover page open, the All tools panel on the left and 20 threaded comments on the right
- The PdfCraft Showcase, a 13-page specimen PDF, open with the All tools panel and threaded comments. + The PlutarchPages Showcase, a 13-page specimen PDF, open with the All tools panel and threaded comments.

@@ -48,8 +48,7 @@ How it's built · Get started · What's next · - Downloads · - Crafting Apps + Downloads

--- @@ -101,7 +100,7 @@ PdfCraft renders PDFs with care for the details that make a page feel right: ker - **Deep zoom stays sharp.** Large pages render in tiles, so text stays crisp at any magnification. - **Built to survive bad files.** Every page renders in isolation and damaged documents are repaired. Across the 983-file pdf.js test corpus the result is 0 crashes. - **Layouts for every task:** continuous, single page, two-up, view rotation, full screen and a distraction-free Read mode. -- **Light and dark themes** in black, ivory, Tyrian purple and crimson: ivory chrome in the light theme, and a true black dark theme for OLED screens. Pages stay white. +- **Light and dark themes** in one colour scheme: ivory chrome or true black for OLED screens, crimson and Tyrian purple accents, gold frames and links. Pages stay white. @@ -120,8 +119,12 @@ Search the whole document as you type, step through matches with ⌘G ## Navigate long documents +Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents. + 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`. +
@@ -157,7 +160,7 @@ Open **Organize pages** to see every page at once: **Saves you can trust:** - *Incremental:* the original bytes stay byte-for-byte intact. - *Atomic:* the file is written to a temporary copy, then swapped in. -- *Verified:* independently checked with qpdf. +- *Verified:* independently checked with qpdf, on every CI run. When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view. @@ -184,7 +187,7 @@ Every page of a combined document renders pixel-identical to its source. ```sh pdfcraft-cli combine report.pdf appendix.pdf --out combined.pdf -pdfcraft-cli extract report.pdf --pages 1,3,5 --out highlights.pdf +pdfcraft-cli extract report.pdf --pages 1,3,5-8 --out highlights.pdf pdfcraft-cli split report.pdf --every 10 --out-dir parts/ ``` @@ -265,6 +268,7 @@ Every engine feature is reachable without the GUI, through one table of JSON-Sch pdfcraft-cli run text_find doc=1 query=invoice # key=value; values parse as JSON pdfcraft-cli run --script review.json # e.g. comment_add {"type": "highlight", "find": "total due"} pdfcraft-cli run --script steps.json --root ./work # several steps in one session + pdfcraft-cli run --script steps.json --report # one JSON document: per step its number, tool, status, result or error ``` With `--root`, every file the script's steps read or write stays in that directory, including the PNG a step saves with `"out"` (the script itself is read from wherever you name it). @@ -302,15 +306,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. @@ -345,15 +347,19 @@ On top of those, two corpus sweeps run over real-world files: - **Opening and rendering:** of the 983 pdf.js test files, 963 open and render cleanly, with 0 crashes. - **Open, edit and save round trips:** 958 succeed. -The output is verified with independent tools: hayro, qpdf and poppler. +The output is verified against independent implementations: hayro and qpdf on every CI run, and +poppler over the corpus on demand (`cargo xtask text-oracle`). PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000 specification and black-box observation, never from anyone else's code. Every icon, font and image is openly licensed and listed in [ATTRIBUTION.md](ATTRIBUTION.md). ## Get started +Building needs Rust 1.95 or later. + ```sh -git clone https://github.com/storytold/pdfcraft -cd pdfcraft +git clone https://github.com/EquityBot/PlutarchPages +cd PlutarchPages +cargo xtask models # fetch the OCR models Scan & OCR needs (release builds include them) cargo run --release -p pdfcraft -- some.pdf # desktop app cargo xtask demo-pdf # build the showcase PDF used in these screenshots cargo xtask screenshots # regenerate every screenshot in this README @@ -375,33 +381,20 @@ git clone https://github.com/storytold/craft-fonts ../craft-fonts CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p pdfcraft -- some.pdf ``` -Each [GitHub release](https://github.com/storytold/pdfcraft/releases) has ready-made builds for macOS, -Windows, Linux (AppImage, Flatpak, `.deb`, `.rpm` and a tarball), FreeBSD and the web; see -[Downloads](#downloads). On Gentoo, the community [::snakebyte -overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as -`app-text/pdfcraft-bin` (not maintained by the PdfCraft team): - -```sh -eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git -emaint sync -r snakebyte -echo 'app-text/pdfcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/pdfcraft -emerge --ask app-text/pdfcraft-bin -``` - Logs, environment variables and other development notes are in [docs/development.md](docs/development.md). ## What's next -PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro. +PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, for professional and everyday workflows. -**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: password-protected files Acrobat can't open, no Office-to-PDF, and Word export that loses layout. +**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most essential offline PDF features exist in some form (57% of the features tracked in [docs/target-app-parity.md](docs/target-app-parity.md), 94% of core essentials). For everyday work (viewing, commenting, filling and signing forms, organizing pages) readiness is about 55%; across all professional workflows, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no office-document-to-PDF, and .docx export that loses layout. - **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP. - **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built. -- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing on Windows, and full XFA forms. -- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side. +- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, office document import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. +- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been benchmarked side by side. -**Next, in order:** encrypted files that Acrobat opens, the reported bugs and Windows printing, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. +**Next, in order:** office-document-to-PDF and a .docx export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against a reference renderer, editing existing content, then professional workflows (OCR, office formats, Preflight, signatures, accessibility tagging) and 1.0 polish. The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md). @@ -411,63 +404,6 @@ The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension This fork publishes no builds of its own yet; build it from source (see [Development](docs/development.md)). Upstream PdfCraft builds are on the [latest release](https://github.com/storytold/pdfcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/pdfcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. -### Windows - -| Build | Installer | Portable | -|---|---|---| -| x64 (64-bit Intel/AMD) | `pdfcraft--windows-x64.msi` | `pdfcraft--windows-x64-portable.zip` | -| arm64 (Snapdragon and other ARM PCs) | `pdfcraft--windows-arm64.msi` | `pdfcraft--windows-arm64-portable.zip` | -| x86 (32-bit) | `pdfcraft--windows-x86.msi` | `pdfcraft--windows-x86-portable.zip` | - -Installers and executables are code-signed. - -The portable zip runs from any folder, a USB stick included. Its `portable.txt` keeps the settings, -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. For unattended deployment -without a desktop shortcut, run from an elevated terminal: - -```powershell -msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0 -``` - -Use the MSI for your architecture. Per-user installation overrides are not supported. - -### macOS - -| Build | File | Notes | -|---|---|---| -| App, universal (Apple silicon + Intel) | `pdfcraft--macos-universal.dmg` | Signed and notarized | -| Command-line tool, universal | `pdfcraft-cli--macos-universal.zip` | Signed and notarized | - -### Linux - -| Format | x86_64 | aarch64 (ARM64) | Notes | -|---|---|---|---| -| AppImage | `pdfcraft--linux-x86_64.AppImage` | `pdfcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | -| Flatpak | `pdfcraft--linux-x86_64.flatpak` | `pdfcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | -| Debian/Ubuntu | `pdfcraft--linux-x86_64.deb` | `pdfcraft--linux-aarch64.deb` | | -| Fedora/RHEL/openSUSE | `pdfcraft--linux-x86_64.rpm` | `pdfcraft--linux-aarch64.rpm` | | -| Tarball | `pdfcraft--linux-x86_64.tar.gz` | `pdfcraft--linux-aarch64.tar.gz` | Unpack anywhere | -| Command-line tool | `pdfcraft-cli--linux-x86_64.tar.gz` | `pdfcraft-cli--linux-aarch64.tar.gz` | `pdfcraft-cli` alone (and its opt-in MCP server), for servers, CI and agents | - -RISC-V (riscv64) builds come as tarballs only: `pdfcraft--linux-riscv64.tar.gz` and `pdfcraft-cli--linux-riscv64.tar.gz`. They are cross-compiled and need glibc 2.39 or newer (Ubuntu 24.04+, Debian 13+). - -Every other Linux build needs glibc 2.35 or newer (Ubuntu 22.04+, Debian 12+, Fedora 36+, RHEL 10). - -### FreeBSD - -| Build | File | -|---|---| -| x86_64 | `pdfcraft--freebsd-x86_64.tar.gz` | - -### Web (WebAssembly) - -| Build | File | Notes | -|---|---|---| -| Static site | `pdfcraft-web-.zip` | Runs in a modern browser; host it on any static server | - --- ## Fork changes @@ -540,13 +476,4 @@ The banner's lettering and emblem are drawn from Cascadia Mono glyphs (SIL OFL 1 Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PdfCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -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 8ebf5f515d..0fd79458f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,74 @@ # PdfCraft roadmap -**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (parser cancellation regression; 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) +**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away + +> **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-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) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; 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: AcroForm XML text; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; 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 (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; 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 (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) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; 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: Extract and Split preserve shared Type 3 glyph resources; 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 (M2: packed TIFF-predictor RGB rows retain their byte boundaries; 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 (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; 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 (M5 #1398: stamps read upright on rotated pages; 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 (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; 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 (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; 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 (M5: comment submission shortcuts; 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 (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; 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 (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; 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 (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; 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 (M7 #1200 candidate: local GoToR links and confined automation; 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:** exclusive image-crop resize follow-up; minor (M2 short same-line redraws split before placement; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (M10, #1028: OCR reads pictures on pages that have text, on request); earlier: M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; 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 (#594 paragraph drags validated in the Linux test environment; 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 (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 @@ -31,7 +97,7 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i |---|---|---| | **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)) | -| **Ready for real work**, full Acrobat Pro | **≈ 49%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) | +| **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)) | | Remaining to **beta** | ≈ 400–700 Opus 5.5 agent-hours | estimated | @@ -40,17 +106,17 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i **Why alpha:** all six of Acrobat Pro's everyday workflows can be completed end to end on macOS (the [alpha gate](docs/roadmap.md#alpha-gate)), and users praise them (7.6k stars, ≈ 217k downloads of 0.4–0.5, switching reports such as #179). Two are only partial: editing (no CJK/RTL) and convert -and share (password-protected files we write don't open in Acrobat or Reader, #774; no in-app -Office→PDF; Word export loses layout, #773). Those, unmeasured quality against Acrobat, the borrowed -`hayro` renderer, no printing on Windows (#756) and 245 open issues keep it far from beta. +and share (no in-app Office→PDF; Word export loses layout, #773; password-protected files open in +Acrobat since #836, #774). Those, unmeasured quality against Acrobat, the borrowed `hayro` renderer, +and 245 open issues keep it far from beta. ### Readiness by audience | Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates | |---|---|---|---| -| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | -| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, Windows printing, the bug backlog | -| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, Windows printing, launch and GPU stability, encrypted output Acrobat opens (#774), simple text fixes, Word export layout, Chinese UI glyphs | +| Full Acrobat Pro (ready for real work) | ≈ 50% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions | +| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog | +| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs | Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#effort-and-calibration) (≈ 1k kept lines per agent-hour for new features, 3–10× slower for hardening and fidelity). The audiences are nested subsets, so essentials ≤ mainstream ≤ full. @@ -63,13 +129,13 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef | File formats and conversion | ≈ 40% | 12% | 120–220 (within features) | [file-format-parity.md](docs/file-format-parity.md) | | PDF specification and standards | ≈ 60% | 8% | 150–260 (within features) | [pdf-spec-parity.md](docs/pdf-spec-parity.md) | | Hardware | ≈ 35% | 4% | 25–50 | [hardware-parity.md](docs/hardware-parity.md) | -| Localization | ≈ 59% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) | +| Localization | ≈ 68% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) | | Performance | ≈ 40% | 6% | 25–45 | [gaps.md](docs/gaps.md) gap 13 | | Stability | ≈ 50% | 8% | 45–80 | [gaps.md](docs/gaps.md) gap 6 | | Platforms | ≈ 70% | 5% | 15–30 | [hardware-parity.md](docs/hardware-parity.md#platforms) | | Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 (within features) | [gaps.md](docs/gaps.md) | | AI features | ≈ 0% (in scope: local providers) | 1% | 20–40 (within features) | [gaps.md](docs/gaps.md) gap 23 | -| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | | +| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | | ## Features @@ -94,26 +160,27 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity ## Languages -Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). 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 | | Korean | `ko` | 0% | none | -| Vietnamese | `vi` | 0% | none | +| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) | -Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian -(94–100%) and Czech (menus only): **8 more languages**. +Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian, +Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs** +(24 interface languages including English). ## Upcoming @@ -121,8 +188,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | Rank | Milestone | Estimate (h) | |---|---|---| -| 0 | Convert and share: encrypted output opens in Acrobat (#774), Create PDF from Office, Word export that keeps layout (#773) | 40–70 | -| 1 | Backlog and platform: crash and wrong-result reports, Windows printing (#756), Chinese UI face (#826) | 65–120 | +| 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 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 | @@ -133,12 +200,305 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **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-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-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-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-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. + +- **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-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 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-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. +- **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged. +- **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged. +- **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged. +- **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged. +- **2026-10-12 (M7, local text-box candidate):** Add Text opens source text for editing and both text tools retype added items on one click. Draft boxes grow toward the page edge and downwards, with the grown width saved. Single visual rows grow across style boundaries, while multi-row paragraph widths stay fixed. Added-text previews remove only the selected item on a clone, with UI and read-only tool regressions. Arabic shaping, ordering and alignment are outside this candidate. Estimates unchanged. +- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged. + +- **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged. +- **2026-10-15 (M2, renderer speed):** Three vendored hayro-interpret patches (31–33), pixel for pixel the same over the first three pages of every pdf.js corpus file. A sampled (Type 0) function keeps its samples in one flat array instead of a hash map rebuilt every time a shading using it is painted: `bug1721218` (InDesign) draws in 0.49 s (was 1.34 s), and a 2,097,152-sample ramp painted 30 times in under a second (was 38 s). One decode of a JPEG or Flate image serves every size it is drawn at (only JPX decodes for a size), so zooming a 300-dpi scan in and out costs 35–40% less render work. Coons and tensor patch meshes copy their vertices' colours less: mesh-gradient pages draw 10–16% faster (page 8 of #1584's annual report 773 → 696 ms). Estimates unchanged. +- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged. + +- **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged. +- **2026-10-14 (M14, Italian catalog):** Italian gains the 201 entries the other catalogs had and the code looks up: the sentences the accessibility checker, preflight, signature validation and page-range parser word, certificate and calendar labels, digital-ID dialog messages and five plurals. It now has 2,528 entries. 64 keys other catalogs carry but no code looks up (tooltip texts with a fixed shortcut, older wordings, generic labels only Lithuanian has) were left out. AI-assisted, written from the meaning of the English; native review welcome. Estimates unchanged. +- **2026-10-15 (M3, #2166 part 1):** Save asks before writing over a version of the file the document did not come from (something else wrote it since the open or the last save), with Save as… to keep it, and the changed-on-disk notice says what Reload and Save each do; a file that is open already comes to the front instead of opening twice; a running instance's autosaves are locked, so a second instance neither offers nor discards them. Part 2 (every writer checked and atomic) is #2182. +- **2026-10-14 (M1, #2166):** No PDF leaves the engine unchecked and no writer truncates: new files from Combine, Extract, Split and Create, signed, timestamped and LTV-embedded files and the OCR batch output reopen with their pages before anyone writes them, as saves already did; the desktop app, the automation tools (with or without `--root`) and the CLI's edit, combine, extract and split all write through the one staging-file writer in `pdfcraft-platform`, so a failed write or `--out` naming the input never leaves a half-written file. Special files such as `/dev/stdout` are still written as given. +- **2026-10-14 (M3 #2080 candidate):** Existing per-tab page history gains explicit search jumps, rail buttons and control-channel commands. Continuous scrolling and automatic search refresh stay outside history; page-level restoration and estimates unchanged. + +- **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged. +- **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged. + +- **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged. +- **2026-10-13 (community, M2/M6, form-heavy documents):** An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN spot-colour image, a 1-4-bit gray or indexed one) is converted once per possible pixel value instead of once per pixel (vendored hayro-interpret patch 28). A fillable character sheet whose 3304 × 4105 DeviceN background ran a PostScript tint transform per pixel drew each page in 3.8 s and 430 MB, on opening and again after every field was filled; it now draws in 0.11 s and 100 MB, pixel for pixel the same. The inspector places widgets without `/P` in one pass over the pages (a form of 8,000 such fields opened in 1.17 s), and the Fields panel pairs its rows with the form through one map a frame and draws only the rows in view (8,000 fields: about 15× less CPU a frame with the panel open). Regressions fail before each fix. +- **2026-10-14 (M9, signature appearance labels):** A visible signature's labels ("Digitally signed by", "Reason: ", …) follow the interface language when Helvetica can draw all of them, and fall back to English as a set otherwise, with a note under the preview; the preview shows the same labels and the signing time. A drawn label the font can't draw is refused naming the label and character, never written as `?`. `sign_document` takes `labels` and `appearance`. Sign, automation and kittest regressions cover German, Chinese and the refusals. Estimates unchanged. +- **2026-10-13 (M4, #1910 Organize on the current page):** Opening Organize Pages from the toolbar, tool panel, command or `--organize` selects the page being read and scrolls its cell into view, replacing a stale grid selection that did not include it. One UI regression covers a middle page, the last page and reopening after an earlier selection. Estimates unchanged. +- **2026-10-13 (M5, signature pad undo):** The Draw tab of Create signature/initials has an Undo button and Ctrl/Cmd+Z that take back the last stroke (#1963). Pad is UI-only, so no automation tool. Estimates unchanged. +- **2026-10-13 (M2, raw crop and page-unit floor):** The renderer applies its existing one-point minimum in physical points for non-nearly-zero raw crop dimensions on scaled pages. Five synthetic regressions cover inspection, crop origins and rotations, reciprocal rounding and bounded raster controls. Existing UserUnit policy, nearly-zero fallback, feature statuses and estimates are unchanged. +- **2026-10-13 (M5, text box font size, #1908):** Comment Properties has a Font size control for text boxes, typewriter text and callouts (1-400 pt); it writes `/DA` and the rich-text strings, redraws the appearance and grows the box to fit. `comment_edit` takes `font_size`. Annot, tool and UI regressions. Estimates unchanged. +- **2026-10-13 (M3, Single Page edge paging, #1924):** In Single Page view a page taller than the window pans with the wheel, and one more notch (or a fresh trackpad swipe) at the bottom/top edge shows the next page at its top / the previous page at its bottom. A swipe that panned to the edge, momentum included, does not turn. Estimates unchanged. +- **2026-10-13 (M3, #1980):** Ctrl+Alt+Q in the digital ID email field no longer requests application exit. Quit requires exact Ctrl+Q/Cmd+Q modifiers and uses the existing unsaved-document confirmation. Synthetic keyboard regressions cover email input and clean/dirty quit requests; estimates unchanged. + +- **2026-10-14 (M4, page transitions read, §12.4.4):** `/Trans` and `/Dur` are read into `DocInfo::transitions` and listed by `doc_info`, the data a full-screen presentation needs to play transitions and auto-advance; playing and editing them stay planned. +- **2026-10-14 (M4, page transitions set, §12.4.4):** `page_transition_set` (one undoable edit) writes or removes `/Trans` and `/Dur` on chosen pages with the values checked first, the counterpart of reading them into `DocInfo::transitions`; playing them and the dialog stay planned. +- **2026-10-14 (M12, transition geometry):** `pdfcraft_render::transition` gives the picture of any of the twelve page transitions at a moment of its run (which parts of the old and new page to paint, where, how opaque), in page fractions, tested for every style and direction; the full-screen presentation will paint from it once it plays transitions. +- **2026-10-14 (M3, Open in Full Screen mode honoured):** a document with `/PageMode /FullScreen` now asks (Full Screen / Not now) before the app enters full screen, as Acrobat does; before, the setting was written and read but never acted on when opening. +- **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. +- **2026-10-13 (M6, choice indices):** Multi-select choice fields write ascending `/I` option indices while preserving the caller's `/V` order. Synthetic forms and headless engine regressions cover reverse selection and save/reopen. Estimates unchanged. + +- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. +- **2026-10-12 (M1, #1338):** A full rewrite used to build a new trailer from `/Root`, `/Info`, `/Encrypt` and `/ID`, so accepted unknown entries such as a custom string or `/Version` disappeared, including when an incremental save was forced to rewrite. Those entries are copied, and objects reachable only from them are written and renumbered with the rest of the file. Ordinary incremental saves are unchanged. Estimates unchanged. +- **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. +- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. +- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. +- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged. +- **2026-10-12 (M3, #1364 candidate):** Absolute local GoToR links wait for Allow/Cancel before target-file access. Pending requests preserve their source and page/view destination; relative links retain their existing behavior. Headless `link_open` requires explicit `allow_absolute: true`, preserving existing root, symlink, file and password validation. Estimates unchanged. + +- **2026-10-12 (community, M7, inline Arabic ActualText):** A single-glyph lam-alef `/ActualText` span joins its surrounding word in Edit text instead of splitting it into separate lines. Pure RTL ligature text is put in visual order before line assembly and recovered to typing order with its neighbours. Multi-glyph and mixed-direction replacements retain their authoritative order. Synthetic regressions cover lam-alef variants, marks, two-byte glyph codes, nested spans, replacement and save/reopen. Estimates unchanged. +- **2026-10-12 (M5, #1398):** A stamp placed on a page with `/Rotate` reads upright as that page is displayed. Rubber stamps (including Sign Here), natural-size and explicit-rectangle images, and PDF-page stamps counterrotate their appearance; restyling a picture stamp keeps that correction, and a saved file reopens the same way. Estimates unchanged. +- **2026-10-12 (M10, file formats):** Create PDF reads BigTIFF scans; a malformed GIF header is no longer detected as an image only to fail conversion. file-format-parity.md now lists XDP data import/export for XFA forms (already shipped under `form.xfa-data`) separately from opening `.xdp` as a document. Estimates unchanged. +- **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged. +- **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged. +- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged. +- **2026-10-12 (M5, comment shortcuts):** Ctrl+Enter posts highlight comment text and replies; Shift+Enter adds a newline. Replies keep Enter-to-post, and floating comment composers only submit from their focused editor. Estimates unchanged. + +- **2026-10-12 (M7, #1443):** Adding a word to a multi-line paragraph no longer reflows or merges its lines. Two causes: in Chrome- and Skia-made subset fonts, `/W` leaves out glyphs of the `/DW` width, which were treated as missing, so unchanged rows measured too wide and rewrapped (and new letters fell back to Helvetica); and lines drawn turned on the page (a landscape page drawn a quarter turn round) were compared along the page's x axis, so all of them became one line. Lines and rows are now found along each text's own baseline, a turned paragraph is rewritten in its own text space, and that rewrite keeps explicit line breaks. Formatting part of turned text is refused rather than misplaced. Synthetic regressions cover three turns and a default-width subset. Estimates unchanged. +- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged. +- **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged. +- **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial. +- **2026-10-12 (M2, Lab Bradford follow-up):** Lab white-point adaptation scales Bradford LMS cone responses using LMS white-point ratios. Independent D50 and equal-energy neutral/chromatic render oracles, D65 identity, direct/Indexed consistency with asymmetric chromatic Range, and degenerate-cone fallback cover the correction. CalRGB and the merged Indexed/Range scaling retain their behavior. Estimates unchanged. +- **2026-10-12 (community, M14, recovery metadata):** A recovery snapshot whose metadata is missing, damaged or names another snapshot is no longer deleted when the folder is listed: it is offered for recovery without an original path (Save asks for a destination), and encrypted snapshots still ask for their password. Recovering one writes fresh metadata in place of the damaged file, and clearing the folder also removes metadata left without a snapshot. Before, a failed metadata write meant the next start deleted the finished snapshot. Synthetic regressions cover the failed write, missing, corrupt, mismatched and encrypted cases. Estimates unchanged. +- **2026-10-12 (M7, Arabic ActualText):** Add text records each shaped line in typing order with `/ActualText`, so copy and search preserve mixed Arabic, Latin, digits and brackets. Edit text keeps the logical order supplied by `/ActualText` instead of reversing Arabic or Hebrew a second time. Adjacent visual glyph runs retain their own bidi recovery. Font-independent tests cover Arabic marks, numbers, Latin words, reopen and complete replacement; automation checks saved editor text and unchanged rendering. Font fallback and bidi typing remain separate gaps; estimates unchanged. +- **2026-10-12 (M5, Fill & Sign text size):** The active Fill & Sign toolbar now chooses 4–300 pt for new text and dates; the in-place editor and saved annotation use that size, which persists in local settings. `fill_sign_add` accepts the same `font_size`. Feature and readiness estimates unchanged. +- **2026-10-12 (community, M3, Greek search):** Case-insensitive Find treats normal, final and capital sigma (`σ`, `ς`, `Σ`) as the same letter, so `κόσμος` and `ΚΌΣΜΟΣ` find each other. Case-sensitive and whole-word options, glyph ranges and match limits retain their behavior. Synthetic text-search regressions cover both directions. The tab-menu regression selects the platform's Reveal label (Finder on macOS), restoring the macOS test without changing the menu. Estimates unchanged. +- **2026-10-12 (M2, soft scans):** A shrunk, interpolated, axis-aligned image (a 300 dpi scan on a screen) is snapped to whole device pixels and shrunk once, not resampled a second time at a fractional scale and offset. On a synthetic scan page the error against an area-average reference fell from 3.4 to 1.6 grey levels at 96 dpi, and edge sharpness is within 2% of the reference (was 10% soft). Rotated, skewed, flipped and non-interpolated images are unchanged; downscaling of those is still open. +- **2026-10-12 (M14, Lithuanian localization):** Added Lithuanian as another interface and date language alongside the existing languages, with a 2,578-entry catalog and three-form plurals. Calendar, inspector and password-dialog regressions check localized labels and unchanged document values. The contributor has reviewed the translation as a native Lithuanian speaker. Estimates unchanged. +- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged. +- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged. +- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged. +- **2026-10-12 (M1, #1337):** COS opening repairs exhausted cross-reference entries and records the skipped row; allocation safely falls back to unused numbers for exhausted /Size hints and high edits. Classic-table and stream regressions reproduce the former panic; ordinary, zero-offset and repaired-range controls preserve opening and allocation. Estimates unchanged. +- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. + +- **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged. + +- **2026-10-12 (M14, gap 13, large-file memory):** Measured on synthetic 1 GiB and 100,000-page files. Edits let go of the previous working file (working bytes, display bytes, renderer) before writing the next, so a 1 GiB file peaks at two copies while editing instead of three (five edits: 3.1 → 2.1 GiB, 16 → 8.5 s). Saving still peaks at three; reading from disk on demand is blocked on the renderer (gap 2). Estimates unchanged. +- **2026-10-12 (M2, short text redraws):** Same-line redraws that restart a matching three-glyph prefix after more than one glyph-height of progress split before placement, so short doubled words no longer interleave. The existing redraw-position tolerance is reused; controls cover negative TJ movement, combining marks, RTL, CJK, vertical and turned text. Estimates unchanged. +- **2026-10-11 (M5, #1191):** Quick-bar colour changes now restyle the selected comment when it matches the active comment tool, while continuing to update that tool's default; added an accessible label to the swatch controls and a UI regression proving the saved style and undo step. Estimates unchanged. +- **2026-10-11 (M10.5, focused follow-up to #599):** Carry the architectural/ANSI/large ISO paper presets, current-page matching at 100%, and previewed PDF scale into the current print dialog and `doc_print`. Retains the #847/#1007 spooler and background discovery. Driver substitution, physical calibration, vector Windows printing and managed-endpoint restrictions remain separate gaps. Overall estimates unchanged. + +- **2026-10-11 (contributor fork, M7):** Full matching font import completes source-subset glyphs; installed/file font selection, TrueType/OpenType-CFF mapping, disjoint range styles, marked paragraph boundaries and single-face contextual shaping/bidi added. Private Chinese keyboard edits on all four pages preserve source anchors and save/reopen renders. Curved clips exclude holes from hit tests; shared path scopes are isolated for image transforms without moving neighbouring paint. No font binary or private fixture is published; release font inputs and readiness estimates unchanged. + +- **2026-10-11 (M7, cropped image resize follow-up):** Resize from the visible (clipped) frame; an image's own clip moves with it (#1049) and shared clips stay fixed. Selected corner grips keep control over overlapping text; horizontal/vertical-only shrinking responds without freezing; one drag frame is previewed and one edit is applied on release. Synthetic pointer/tool regressions and private grow/shrink/save/reopen/undo rendering checks cover the regression. Shared clipping scopes containing unrelated painted content are refused. Estimates unchanged. + +- **2026-10-11 (M7, cropped artwork selection):** Image/Form inventories now expose page/path-clipped visible bounds while retaining full placement geometry for transforms. Edit clicks prioritize text over background artwork; image frames stay within their own page and fixed clips remain fixed during movement. Engine/tool/UI regressions and an opt-in four-page private-file test cover direct access to 73 text regions and five cropped placements. Rectangular clips are exact; curved/complex clips use conservative bounds. Estimates unchanged. + +- **2026-10-11 (M7, live text editor follow-up):** Reuse validated embedded sfnt source faces; adapt Mac format-6 subset cmaps through PDF Unicode mappings in memory without changing or installing source fonts. Preserve source baseline positions on entry. Show draft text and a single frame that grows/shrinks as rows are added or removed; single rows can grow toward the page edge. `text_edit_layout` exposes read-only draft bounds through the engine and automation. Licensed synthetic font/frame regressions and an opt-in all-page private-file check cover source font usage, stable positions, typing and deletion. Unsupported source programs, shaping and installed-font selection remain limited; estimates unchanged. + +- **2026-10-11 (M7, mixed-style regions follow-up):** Group adjacent source runs into visual rows and aligned rows into regions across subset fonts, colours, weights and text-matrix sizes. One inline TextEdit uses styled spans and source row breaks; rewriting preserves surviving source-run styles, source subset encodings and centered mixed-size title alignment. Added engine, automation and UI regressions for edit/delete, read-only preview, save/reopen and undo; privately verified the supplied real PDF's three-row title and six-row body, including selecting both in one editor without extra rows. `text_paragraphs` exposes `editor_text` and `rows`. Installed-font selection, exact editor glyphs, complex shaping and arbitrary multi-range style edits remain open. Readiness estimates unchanged. + +- **2026-10-11 (M7, contributor fork):** Join compatible words across text objects and subset-font spaces; preserve source fonts that lack a space glyph using text positioning; recognize component colour operators. Inline editing uses one editor frame, resets its available width when switching blocks, keeps source line breaks, and renders a COS-clone background without the original glyphs, preserving unselected text advances. `page_render.hide_text_block` exposes the same read-only preview. Mixed-style paragraph grouping, installed-font selection and exact source-font editor rendering remain open. + Newest first: the date, what moved, and the new overall estimate. - **2026-10-17 (M0, cancellation regression):** Replace the parser wait test's yield loop with a blocking channel handshake. The test observes cancellation as false before requesting it and requires the waiter to finish while initialisation remains blocked. Feature readiness was not remeasured. - **2026-10-15 (M0, dependency audit):** Updated the locked rustls dependency to 0.23.46 for RUSTSEC-2026-0346 through RUSTSEC-2026-0349. This maintenance change does not remeasure feature readiness. +- **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. +- 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged. + +- **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged. +- **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes. +- **2026-10-11 (M3, animation effects):** PdfCraft follows Windows' Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects, and Preferences has its own Animation effects: Use system setting (the default), On or Off. With animation effects off, a wheel notch scrolls its whole distance, and Ctrl with the wheel zooms its whole step, in the frame it arrives. Both were eased over about a quarter of a second whatever the setting: egui spreads a coarse wheel step over later frames and has no switch for it, so `crates/ui-egui/src/motion.rs` reads each frame's wheel input the way egui does, minus the easing, and hands that to the scroll areas and the views (a test sums what egui eases and compares it with what is read at once, and fails if egui stops easing). egui's own animations (menus, sections, scrolling to a row) take no time, the chrome's hover and press washes are simply there, and the progress card's bar steps without its sweep. The desktop app asks WinRT's `UISettings.AnimationsEnabled` at launch and every two seconds of use, so a change applies without a restart. How far a notch scrolls or zooms is unchanged. The preference set to On or Off decides all of this whatever the system says; it is saved, set as `animation_effects` (`system`, `on`, `off`) on the control channel, and translated in all 20 catalogs. Not done: macOS (Reduce motion), Linux (GNOME's animations switch) and the browser (`prefers-reduced-motion`) aren't asked yet, so Use system setting animates there as before; Off works everywhere. Estimates unchanged. +- **2026-10-11 (M10, gap 4):** The web build prints. Its Print dialog offers "Browser's print dialog" (the default there), which reaches every printer the operating system knows. PdfCraft draws the print-ready PDF's sheets itself with its own renderer, a sheet a frame because the web build has no threads: 300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000, at most 64 megapixels a sheet and 1 GiB of pictures a job. It then prints them from a hidden iframe, one sheet per printed page with no margin. A landscape sheet among portrait ones is turned onto the paper, as a driver does, because Chromium prints every page at the default `@page` size. Copies, two-sided and colour are set in the browser's dialog, so the dialog greys them out. If the browser refuses to print (a sandboxed frame, for example), the print-ready PDF downloads instead. Verified in headless Chromium: the right sheets at 300 dpi, the landscape sheet turned, no blank trailing page, and the download fallback. Not yet tested in Firefox or Safari. Gap 4 closed; rank 1 is 65–120 → 50–90 h; other estimates unchanged. +- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change. +- **2026-10-11 (M10, #1028):** Recognize Text can read pages that already have text: "Also recognize pages that already have text" in the dialog (remembered with the other choices, and used by Recognize text in multiple files) and `skip_text_pages: false` on `ocr_recognize` and, newly, `ocr_recognize_files`. A picture on such a page becomes searchable, and a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out, so nothing is found twice; `ocr_recognize` reports how many were left out as `already_on_page`. Before, the option existed only for agents and added the page's text a second time. The default still skips pages with text. Synthetic tests (a picture of a sentence plus real text) cover the engine, the tool and the dialog, and fail with the filter turned off. Still missing: reading only the picture areas (the whole page is read, as for a scan). Separately, `recognize_text_makes_a_scanned_page_searchable` fails on main wherever the models are installed (the model reads "quick" as "auick"). Estimates unchanged. +- **2026-10-11 (M2, page-label prefixes):** The page-label readers bound the total size of decoded prefixes. Each range decodes its own prefix, and ranges may share one prefix object, so a small file could ask for a copy per range: within the 200,000-entry tree limit, about 200 MiB of copies of one 1,024-byte prefix. Inspection and Organize now refuse a tree whose decoded prefixes pass 4 MiB in total (`MAX_PREFIX_TOTAL_BYTES`), before any label is made, as the other tree limits do; relabelling such a tree changes nothing. Synthetic tests share one 1,000-byte prefix across 5,000 ranges and keep exactly 4 MiB of prefixes readable. Overall estimate unchanged. +- **2026-10-11 (UI, checkboxes and radio buttons):** Checkboxes no longer look like radio buttons, and on the light theme neither is invisible. With the theme's 6 px widget radius egui drew a checkbox's 14 px box as a circle, so View ▸ Page display's Continuous scrolling and Show cover page read as two more layout choices; on the light theme an unticked box or an unselected radio button was white on white with no edge. Every checkbox (81) and radio button (79) now goes through `widgets::checkbox` / `widgets::radio` / `widgets::radio_value`: a 1 px outline (darker on hover), the plain fill on the light theme as dialogs already had, and for checkboxes a rounded-square box (3 px), keeping what the caller set (dialogs, the search panel). `clippy.toml` disallows `ui.checkbox`, `ui.radio`, `ui.radio_value`, `egui::Checkbox` and `egui::RadioButton` so new ones use them too. Estimates unchanged. +- **2026-10-11 (M13, #971):** `text_find` now bounds each page's temporary match-range list to the remaining result limit plus one, which is enough to detect and return a continuation without allocating every match on a dense page. Added limited-search coverage; the existing end-to-end continuation test passes. The text cache byte budget and per-page extraction warnings remain open. +- **2026-10-11 (M7, #594):** Ordinary paragraph body drags now use the existing source-preserving object translation path; edge drags still rewrap. An in-progress drag is discarded if the document generation changes, and added-content gestures keep their own edit handler. Added real-shell source regressions for overlapping and identical labels, history, save/reopen, partial overlap, kerning, rotation/crop/UserUnit and atomic refusal. Linux validation passed: focused UI/engine regressions, exact-base source-loss failures, native drag/undo/redo/save/reopen with independent Poppler renders, and all eight CI gates. Text replacement and resizing over unrelated overlapping content remain outside this repair. Estimates unchanged. + +- **2026-10-10 (community, M7, Thai Add Text):** Optional craft-fonts Thai face (Noto Sans Thai) for the UI and shaped Add Text through the engine, CLI and MCP. Stacked marks and Sara Am are kept through Type 3 clusters with ToUnicode; regular style, and wrapping at cluster boundaries (no dictionary word segmentation). The craft-fonts pin for CI and releases moves to the revision with the Thai face. Forms and Fill & Sign are outside this change. Estimates unchanged. +- **2026-10-11 (#995, #1077, permissions password after opening):** A PDF with only a permissions password opens without asking, restricted, and nothing let the user enter that password afterwards: the notice and the hint pointed at reopening with it. Now the security notice and Properties ▸ Security offer Unlock…, and Change settings / Remove security on a restricted document ask for the permissions password first and then carry on (as Acrobat does). The open password is refused there. `Session::unlock` upgrades the open document in place (owner and user passwords unlock the same file key, so nothing is re-read), unlocks the undo and redo states and reopens saves as owner; it is not an edit and doesn't mark the document unsaved. New command `protect.unlock`; the new strings are in all 15 full catalogs. Tests cover crypt, cos, engine (undo, redo, save, separate open password) and the UI. Still missing: an automation tool for it. Estimates unchanged. +- **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged. +- **2026-10-11 (M4, Organize overview):** Opening Organize pages from All tools, Home, the tool palette or `ui.set tool=organize` opens the thumbnail grid directly. Checkboxes toggle individual pages without modifier keys; page controls rotate either way or delete one page, while the toolbar acts on the selection. Portrait and landscape previews retain their aspect ratio. UI and control regressions cover entry, selection, single-page versus batch actions, undo, permissions and retaining the last page. Milestone and effort estimates unchanged. + +- **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged. + +- **2026-10-15 (M9, #1895):** Preserve original signature artwork and show validation through a separate page overlay. Generic in-place appearance rewriting is deferred: neither a caption substring nor a unique drawing establishes an icon role. Signed regressions cover chamfered borders, mixed signer/caption text, image/text exports, both print modes and saves. Snapshot reuse and trust/cache refresh remain. Baked Not Verified captions are not replaced; #79 remains open. Estimates unchanged. +- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged. +- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %). +- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted. +- **2026-10-11 (M10, #773 integration):** Merged current upstream updates into the Word export fix and adopted the shared Windows symlink test setup. Word layout scope and estimates unchanged. +- **2026-10-11 (M10, #773):** Word export no longer invents visible borders for tables inferred from text alignment. Rotated text, and pages where text overlaps an image or a watermark-sized text block, use positioned, editable shapes so invoice watermarks do not reserve body space (pages whose logo or title sits clear of the text keep flowing paragraphs, tables and inline images); source pages have separate sizes and section breaks. Synthetic export and automation regressions cover the invoice failure. Exact font matching, vector rules and general layout fidelity remain partial; estimates unchanged. +- **2026-10-11 (docs, closed issues):** The progress docs caught up with issues closed on 2026-10-10: encrypted output opens in Acrobat (#774, #836; gap 1 closed, rank 0 is 32–55 h), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), and the #799–#821 batch of wrong-result fixes including the #816 panic. Scores and readiness not re-judged; overall estimate unchanged. +- **2026-10-11 (M7, watermark placement):** A watermark can be moved from the page centre by a horizontal and a vertical offset in points: Add/Update Watermark has an "Offset from centre" row (the preview follows it, for text and for pictures) and `doc_watermark` takes `offset_x` and `offset_y`. A non-finite offset is refused instead of written into the page. `edit.watermark-appearance` stays partial: no offset from the page edge or in page units, no print/screen-only variants, no repeat. Image and PDF watermarks, already shipped, no longer appear as missing. The overall estimate is unchanged. +- **2026-10-11 (M12, XFA script time limits):** An XFA script's time limit (1 s for scripts that run unasked, on open and on every change) counted the engine's start-up too: the script's thread, a new context and, for the first script in a process, the engine's own set-up (≈ 60–170 ms when idle). On a busy machine a trivial initialize script could pass the limit, be abandoned and turn the form's scripts off; that made `xfa_buttons_run_their_scripts_in_the_app` and `messages_from_scripts_run_on_open_show_right_away` flaky (under full CPU load 25 of 40 runs of the test binary failed, with "ran longer than 1.0 s and was abandoned" in the console). The limit now runs from when the engine is ready; start-up has its own 10 s cap, so nothing waits forever, and a trivial warm-up script takes the first-in-process set-up. Under the same load none of 40 runs fails, and runaway scripts are abandoned as before. The two UI tests print the script console when they fail. Overall unchanged. +- **2026-10-11 (M4, #569):** Combine files takes images and text files as well as PDFs. PNG, JPEG, TIFF, GIF, BMP, JPEG 2000 and .txt files become PDFs as they are added (Add files…, drops, folder scans), so each is an ordinary row with its pages, thumbnails and page range; a file that can't be read (a damaged image or PDF, a type that can't be converted) stays in the list with why in its Warnings, and Combine asks before leaving such files out (Cancel changes nothing); password-protected files still have to be unlocked first. `doc_combine` accepts the same types, and a converted file's bookmark drops its extension. A PNG whose header claims more than 2 GiB of pixels is now refused before its buffer is allocated (the png crate's limits don't cover it), as TIFF already was. Office documents and HTML are still not converted. Overall estimate unchanged. +- **2026-10-11 (M3, Windows renderer startup):** Renderer-creation GPU errors return through the existing scoped handler so Auto startup can use the existing OpenGL fallback. A real-device invalid/valid pipeline regression covers error propagation and device reuse; the independent Windows hostile-argument fixture reports unavailable symbolic-link privileges. The underlying x86 DX12 compiler error remains unresolved; see `docs/windows-renderer-startup.md`. Milestone and effort estimates unchanged. + +- **2026-10-11 (M6):** Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. Estimates unchanged. + +- **2026-10-11 (M6):** Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Estimates unchanged. + +- **2026-10-11 (M13):** CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. Estimates unchanged. + +- **2026-10-11 (M14, #307, Plan 2):** Forms now return terminal fields and the page-widget adoption count from one enumeration, and engine open reuses that result while retaining XFA script-button marking. The 12,000-page/no-form PDF (1,442,052 bytes) and 2,500-orphan-widget PDF (673,432 bytes) produce byte-identical `doc_open`/`doc_info` outputs against upstream `33c14e07cfd742a82bf03baa6b103c01008fa68c`, including the orphan repair note. After one warm-up per binary and three interleaved base/candidate rounds, median open times were 0.16/0.15 s and 0.08/0.07 s, with median peak RSS 62.69/59.98 MiB and 33.28/32.20 MiB. Workspace tests, Clippy, formatting, focused forms/engine/organize tests, and the WASM check pass. A behavior-preserving local binding in `organize` also lets the workspace Clippy gate pass. No estimate change. +- **2026-10-11 (M14, Traditional Chinese semantics):** Corrected accessibility pass/fail labels, the inverted script-accessibility rule, Taiwan row/column terminology and form choice/read-only labels; added the missing table-row explanation. Catalog regressions cover the rule metadata and preserve field names. Milestone and effort estimates unchanged. + + +- **2026-10-11 (M14, no silent `?`):** Text PdfCraft's fonts can't draw (Chinese, Greek, Czech's ř in Helvetica) is never written into a PDF as `?` without a word. Form values and button captions, text boxes, callouts, Fill & Sign text, measurement labels and dynamic stamps' by-lines are kept as typed, as Acrobat keeps them: the appearance draws `?` there, the annotation is marked (`PCUndrawn`), and the edit says so (the interface shows a translated notice; tools return `warnings`). A form also sets `/NeedAppearances`, so other viewers redraw the field from its value (not in a PDF/A document, which forbids it); a restyle doesn't replace a comment another program drew with those characters. Where `?` would be permanent, the edit is refused with the character and what to do instead: flattening what PdfCraft drew with `?`, redaction overlay text (also checked before anything is redacted), typed signatures through `fill_sign_add`, visible signature details, and new documents from text (with the line). Reports PdfCraft writes from documents (comment summaries, compare reports) write such characters as `[U+XXXX]` and say so; a dynamic XFA form's own text is laid out and reported. One end-to-end test drives every path through the tools. Not yet: drawing those characters (embedding a font that has them). Overall estimate unchanged. - **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %. - **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged. @@ -147,6 +507,8 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-10 (M12, XFA rows and areas):** Dynamic XFA forms lay out a repeating subform inside a top-to-bottom container in positioned content (a table in an area) with all its instances, drawn and measured, instead of one; and areas no longer appear in data paths (`pdfcraft_xfa::data_path`), since they are not data scopes. Before, a form whose fields sit in an area read none of the values Adobe's viewers saved, wrote what was filled here under a new element named after the area, which Adobe's viewers would most likely not read, and a FormCalc total looping over the table's rows failed on the rows that were missing. Layout, the script tree, reading and writing the data and the engine's value and row operations share the one path rule. Synthetic tests cover the layout, the path rule and the whole round trip (rows, Adobe-written values, a FormCalc total over all rows, the written data, reopening). Still open for such forms: XFA script objects (`.pdf", "sha256": "a", "summary": { "passed": 0, "failed": 1, "manual": 0, "skipped": 0 } }, + "after": { "name": "safe.pdf", "sha256": "b", "summary": { "passed": 1, "failed": 0, "manual": 0, "skipped": 0 } }, + "rules": [{ "rule": "primary-language", "before": { "status": "failed", "findings": 1 }, "after": { "status": "passed", "findings": 0 } }], + "review_required": [], + "note": "Review independently.", + }); + let html = review_html(&record).unwrap(); + assert!(html.contains("<script>alert('x')</script>.pdf")); + assert!(!html.contains(""), 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 { + // `max(0.0)` also turns a NaN side into 0: a degenerate box covers nothing. + let area = |[x0, y0, x1, y1]: [f32; 4]| (x1 - x0).max(0.0) * (y1 - y0).max(0.0); + let total = area(word); + if !total.is_finite() || total <= 0.0 { + return 0.0; + } + let inside: f32 = + text.iter().map(|g| area([word[0].max(g[0]), word[1].max(g[1]), word[2].min(g[2]), word[3].min(g[3])])).filter(|a| a.is_finite()).sum(); + (inside / total).min(1.0) } 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 } } } @@ -36,6 +65,8 @@ impl Default for OcrSettings { pub struct OcrPage { pub page: usize, pub words: Vec, + /// Words recognised but left out because the page's own text already has them there. + pub already_on_page: usize, /// Why the page was not read, if it was skipped. pub skipped: Option, } @@ -49,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()); } @@ -79,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(); @@ -88,19 +120,18 @@ impl OcrJob { break; } let Some(info) = self.infos.get(page) else { continue }; - let skip = |why: &str| OcrPage { page, words: Vec::new(), skipped: Some(why.into()) }; - if self.settings.skip_text_pages { - let t = r.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); - if t.text.as_ref().is_some_and(|t| !t.plain_text().trim().is_empty()) { - out.push(skip("the page already contains text")); - continue; - } + let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()) }; + // The boxes of the page's own text (an earlier OCR layer's included), in view space. + let existing: Vec<[f32; 4]> = r + .render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() }) + .text + .map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect()) + .unwrap_or_default(); + if self.settings.skip_text_pages && !existing.is_empty() { + out.push(skip("the page already contains text")); + continue; } - // A scan is read at its own resolution (enlarging it only blurs the letters), at most - // the chosen one. - let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0; - let scale = self.native.get(page).copied().flatten().map_or(wanted, |n| n.clamp(1.0, wanted)); - let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + let shot = r.render(RenderRequest { page, scale: self.scale(page, info), ..Default::default() }); if let Some(e) = shot.error { out.push(skip(&e)); continue; @@ -117,12 +148,27 @@ impl OcrJob { let [u, v] = info.view_to_user(x / scale, y / scale); [u as f64, v as f64] }; - let words = lines.iter().flat_map(|l| &l.words).map(|w| PlacedWord::place(w, to_user)).collect(); - out.push(OcrPage { page, words, skipped: None }); + // Words the page's own text already has are not added a second time. + let (fresh, already): (Vec<_>, Vec<_>) = + lines.iter().flat_map(|l| &l.words).partition(|w| covered(w.rect.map(|v| v / scale), &existing) < ALREADY_ON_PAGE); + let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect(); + out.push(OcrPage { page, words, already_on_page: already.len(), skipped: None }); } progress(total, total); out } + + /// The render scale (pixels per point) `page` is read at. A scan is read at its own + /// resolution (enlarging it only blurs the letters), at most the chosen one. + fn scale(&self, page: usize, info: &PageInfo) -> f32 { + let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0; + let Some(native) = self.native.get(page).copied().flatten() else { return wanted }; + // A scan whose page is larger than a sheet (often a box given in pixels, one point per + // pixel, #1848) is measured as if shrunk to one: otherwise 1 px/pt is already its full + // image, and no "Downsample to" choice could ask for less. + let fit = (SHEET / info.width.max(info.height).max(1e-3)).min(1.0); + native.clamp(fit, wanted * fit) + } } impl Session { @@ -208,5 +254,67 @@ pub fn recognize_file( if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 { return Ok(FileResult { bytes, pages }); } - Ok(FileResult { bytes: s.save_bytes(id).map_err(|e| e.to_string())?, pages }) + // Checked like a save before the caller writes it (#2166). + let bytes = s.save_bytes(id).map_err(|e| e.to_string())?; + s.check_save(id, &bytes).map_err(|e| e.to_string())?; + Ok(FileResult { bytes, pages }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::export; + + /// #1028: a recognised word over the page's own text is that text again; one in an empty + /// part of the page (a picture) is new. + #[test] + fn words_over_existing_text_count_as_already_there() { + // Glyph em boxes of "Total" at 12 pt, five letters side by side. + let glyphs: Vec<[f32; 4]> = (0..5).map(|i| [100.0 + 7.0 * i as f32, 50.0, 107.0 + 7.0 * i as f32, 62.0]).collect(); + // OCR's tight box around the same ink. + assert!(covered([100.5, 52.0, 134.0, 61.0], &glyphs) >= ALREADY_ON_PAGE); + // A word in a picture further down, and one beside the text on the same line. + assert_eq!(covered([100.0, 200.0, 140.0, 212.0], &glyphs), 0.0); + assert_eq!(covered([140.0, 52.0, 170.0, 61.0], &glyphs), 0.0); + // Touching the text's last letter is not enough. + assert!(covered([131.0, 52.0, 170.0, 61.0], &glyphs) < ALREADY_ON_PAGE); + // A page without text has nothing to cover a word. + assert_eq!(covered([100.5, 52.0, 134.0, 61.0], &[]), 0.0); + } + + /// #1848: an A4 scan at 300 dpi whose page box is its pixel size (2480 x 3508 pt, one pixel + /// per point) gets a smaller bitmap for each lower "Downsample to" choice, and a low-resolution + /// letter page is still never shrunk below one pixel per point. + #[test] + fn downsample_shrinks_a_page_box_given_in_pixels() { + let mut s = Session::new(); + let page = |w: u32, h: u32| export::encode_png(w, h, &vec![255; (w * h * 4) as usize]).unwrap(); + let bytes = s.create_from_images(&[("a4.png".into(), page(2480, 3508)), ("letter.png".into(), page(612, 792))]).unwrap(); + let id = s.open("scan.pdf", None, bytes, None).unwrap(); + let mut r = PageRenderer::new(s.get(id).unwrap().bytes.clone(), RenderConfig::default()); + for (dpi, a4) in [(72.0, [595, 842]), (150.0, [1240, 1754]), (300.0, [2480, 3508]), (600.0, [2480, 3508])] { + let job = s.ocr_job(id, &[], OcrSettings { dpi, ..Default::default() }).unwrap(); + for (page, want) in [(0, a4), (1, [612, 792])] { + let scale = job.scale(page, &job.infos[page]); + let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + assert!(shot.error.is_none(), "{:?}", shot.error); + let near = |got: u32, want: u32| got.abs_diff(want) <= 1; + assert!(near(shot.width, want[0]) && near(shot.height, want[1]), "{dpi} dpi, page {page}: {}x{}", shot.width, shot.height); + } + } + } + + #[test] + fn coverage_of_overlapping_or_broken_boxes_stays_between_zero_and_one() { + // A glyph drawn twice (fake bold) does not cover more than the whole word. + let twice = [[0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 10.0, 10.0]]; + assert_eq!(covered([0.0, 0.0, 10.0, 10.0], &twice), 1.0); + // Empty, inverted and non-finite boxes cover nothing and are covered by nothing. + for word in [[5.0, 5.0, 5.0, 9.0], [9.0, 0.0, 1.0, 9.0], [f32::NAN, 0.0, 9.0, 9.0], [0.0, 0.0, f32::INFINITY, 9.0]] { + assert_eq!(covered(word, &twice), 0.0, "{word:?}"); + } + let broken = [[f32::NAN; 4], [f32::NEG_INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY]]; + let c = covered([0.0, 0.0, 10.0, 10.0], &broken); + assert!((0.0..=1.0).contains(&c), "{c}"); + } } diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs new file mode 100644 index 0000000000..da8868aab0 --- /dev/null +++ b/crates/engine/src/open.rs @@ -0,0 +1,364 @@ +//! 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, 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)); + // §7.6.4.4 (#603): a document may carry a non-empty Document Open password yet have the + // empty password authenticate as *owner*. The COS reader refuses that silent open with + // NeedsPassword; the renderer (hayro) opens R5/6 owner-first and would not. Honour the + // COS verdict so the user is always prompted instead of shown the content. + if password.is_none_or(str::is_empty) && matches!(&cos, Ok(Err(pdfcraft_cos::CosError::NeedsPassword))) { + return Err(OpenError::NeedsPassword); + } + // 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(), 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(), + } + } +} diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index 34deec2a5b..993c2162cc 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -17,6 +17,8 @@ pub enum OptimizeStage { /// Removing the user data chosen under Discard User Data. #[default] Discarding, + /// Merging the fragments of a page picture into one image. + Fragments, /// About to process image `done` (0-based) of `total`. Images { done: usize, total: usize }, /// Discarding objects and compressing unencoded streams. @@ -33,6 +35,7 @@ impl OptimizeStage { pub fn fraction(self) -> f32 { match self { OptimizeStage::Discarding => 0.0, + OptimizeStage::Fragments => 0.03, OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 }, OptimizeStage::CleaningUp => 0.8, OptimizeStage::Merging => 0.85, @@ -76,7 +79,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() + }, }) } } @@ -91,11 +98,15 @@ 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)? }; let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| { step(match s { + optimize::Stage::Fragments => OptimizeStage::Fragments, optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total }, optimize::Stage::CleanUp => OptimizeStage::CleaningUp, }) 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 new file mode 100644 index 0000000000..4ed5895860 --- /dev/null +++ b/crates/engine/src/print_raster.rs @@ -0,0 +1,69 @@ +//! 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) 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::{Content, Raster}; +use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest}; + +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, 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(); + // 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 { + let (w, h) = src.sizes.get(page).copied().ok_or_else(|| format!("page {} does not exist", page + 1))?; + let scale = pdfcraft_render::effective_scale(w, h, (dpi / 72.0) as f32); + let page_dpi = f64::from(scale) * 72.0; + // Every page of the image document shares one resolution, so its pages keep the + // source's sizes; the lowest a page needed is used for all. + used = Some(used.map_or(page_dpi, |u: f64| u.min(page_dpi))); + let r = renderer.render(RenderRequest { page, scale, tag: PRINT_TAG, ..Default::default() }); + if let Some(e) = r.error { + return Err(format!("page {}: {e}", page + 1)); + } + let mut rgba = r.rgba.to_vec(); + over_white(&mut rgba, raster.grayscale); + images.push((format!("page-{}.png", page + 1), encode_png(r.width, r.height, &rgba)?)); + } + let dpi = used.unwrap_or(dpi); + let doc = pdfcraft_create::from_images_with_resolution(&images, ImageResolution::Dpi(dpi)).map_err(|e| e.to_string())?; + pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string()) +} + +/// Premultiplied RGBA composed over white paper (opaque), in grey when asked (Rec. 601 luma). +fn over_white(rgba: &mut [u8], grayscale: bool) { + for px in rgba.as_chunks_mut::<4>().0 { + let gap = 255 - u32::from(px[3]); + let (r, g, b) = ((u32::from(px[0]) + gap).min(255), (u32::from(px[1]) + gap).min(255), (u32::from(px[2]) + gap).min(255)); + let (r, g, b) = if grayscale { + let y = ((299 * r + 587 * g + 114 * b) / 1000).min(255); + (y, y, y) + } else { + (r, g, b) + }; + px[0] = r as u8; + px[1] = g as u8; + px[2] = b as u8; + px[3] = 255; + } +} diff --git a/crates/engine/src/printcheck.rs b/crates/engine/src/printcheck.rs new file mode 100644 index 0000000000..744e52d4e4 --- /dev/null +++ b/crates/engine/src/printcheck.rs @@ -0,0 +1,474 @@ +//! Print readiness, verification only. +//! +//! Measures what a press looks at and changes nothing: effective image resolution where each +//! image is drawn, the page boxes (media, crop, trim, bleed), the output intent, fonts that +//! aren't embedded, and the existing PDF/A-2b result. There is no fixup and no generated colour +//! profile. A screen profile such as sRGB is reported as a screen profile, not accepted as a +//! press condition. + +use pdfcraft_cos::Document; +use pdfcraft_edit::PageImage; +use pdfcraft_preflight::{Issue, Level}; + +use crate::Document as OpenDocument; + +/// Below this, ordinary sheet printing cannot hold the picture. Large-format work sometimes can. +const FAIL_PPI: f64 = 150.0; +/// Ordinary close-up print. Between this and [`FAIL_PPI`] the picture is usable but soft. +const WARN_PPI: f64 = 300.0; +/// Less than this past the trim, on the tightest side, is not a normal 3 mm bleed. +const MIN_BLEED_MM: f64 = 2.0; + +/// One thing a printer should see, without the file having been rewritten. +#[derive(Clone, Debug, PartialEq)] +pub struct Finding { + pub id: &'static str, + pub severity: &'static str, + pub message: String, + /// 1-based page, when the finding is about one page. + pub page: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PageBoxes { + pub page: u32, + pub media_mm: [f64; 2], + pub crop_mm: [f64; 2], + pub trim_mm: [f64; 2], + pub bleed_mm: [f64; 2], + pub trim_set: bool, + pub bleed_set: bool, + /// Left, bottom, right, top, in millimetres, from the trim out to the bleed. + pub bleed_margin_mm: [f64; 4], +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DrawnImage { + pub page: u32, + pub name: String, + pub ppi: f64, + pub pixels: [u32; 2], + pub size_mm: [f64; 2], +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PrintReadiness { + pub pages: Vec, + pub images: Vec, + pub output_intents: Vec, + pub fonts_not_embedded: Vec, + pub pdfa_issues: Vec, + pub findings: Vec, + /// Image placements whose resolution couldn't be measured (a degenerate or non-finite + /// placement matrix). They are neither passed nor failed. + pub resolution_unknown: usize, +} + +impl PrintReadiness { + /// A plain-text report. The document is unchanged. + pub fn text(&self) -> String { + let mut out = String::from("Print readiness (verification only; the file was not changed)\n"); + if self.output_intents.is_empty() { + out.push_str("Output intent: none\n"); + } else { + out.push_str(&format!("Output intent: {}\n", self.output_intents.join(", "))); + } + for page in &self.pages { + if !page.trim_set { + out.push_str(&format!("Page {}: media {} mm, no TrimBox\n", page.page, size_mm(page.media_mm))); + continue; + } + out.push_str(&format!( + "Page {}: media {} mm, trim {} mm, bleed margin {} mm{}\n", + page.page, + size_mm(page.media_mm), + size_mm(page.trim_mm), + min_mm(page.bleed_margin_mm), + if page.bleed_set { "" } else { " (no BleedBox)" }, + )); + } + for image in &self.images { + out.push_str(&format!( + "Image {} on page {}: {:.0} ppi, {} × {} px, {} mm\n", + image.name, + image.page, + image.ppi, + image.pixels[0], + image.pixels[1], + size_mm(image.size_mm), + )); + } + if self.resolution_unknown > 0 { + out.push_str(&format!("Images with unknown resolution: {}\n", self.resolution_unknown)); + } + if self.fonts_not_embedded.is_empty() { + out.push_str("Fonts: every referenced font is embedded, or the file has none\n"); + } else { + out.push_str(&format!("Fonts not embedded: {}\n", self.fonts_not_embedded.join(", "))); + } + let level = Level::A2b.label(); + if self.pdfa_issues.is_empty() { + out.push_str(&format!("{level}: no problems found by the structural check\n")); + } else { + out.push_str(&format!("{level}: {} problem{}\n", self.pdfa_issues.len(), if self.pdfa_issues.len() == 1 { "" } else { "s" })); + for issue in &self.pdfa_issues { + match issue.page { + Some(p) => out.push_str(&format!("- page {}: {}\n", p + 1, issue.message)), + None => out.push_str(&format!("- {}\n", issue.message)), + } + } + } + if self.findings.is_empty() { + out.push_str("Print checks: no stop for image resolution or bleed\n"); + } else { + out.push_str("Print checks:\n"); + for finding in &self.findings { + out.push_str(&format!("- [{}] {}\n", finding.severity, finding.message)); + } + } + out.push_str("Not checked: PDF/X, overprint, total area coverage, spot colours, transparency flattening.\n"); + out + } +} + +impl OpenDocument { + /// A print-readiness report. Reads the file and does not edit it. + pub fn print_readiness(&self) -> PrintReadiness { + match &self.editor { + Some(editor) => inspect(&editor.cos), + None => PrintReadiness { + pages: Vec::new(), + images: Vec::new(), + output_intents: Vec::new(), + fonts_not_embedded: Vec::new(), + pdfa_issues: Vec::new(), + resolution_unknown: 0, + findings: vec![Finding { + id: "document", + severity: "info", + message: "The document has no object graph to measure.".into(), + page: None, + }], + }, + } + } +} + +fn inspect(doc: &Document) -> PrintReadiness { + let declared = pdfcraft_preflight::declared(doc); + let pdfa_issues = pdfcraft_preflight::verify(doc, Level::A2b); + let fonts_not_embedded = pdfa_issues + .iter() + .filter(|issue| issue.pattern.contains("isn't embedded") || issue.pattern == "A font isn't embedded") + .map(|issue| issue.value.clone().unwrap_or_else(|| "unnamed font".into())) + .collect(); + let model_pages = pdfcraft_model::pages(doc); + let pages = page_reports(doc, &model_pages); + let (images, resolution_unknown) = image_reports(doc, &model_pages); + let mut findings = Vec::new(); + // Without a TrimBox the trim, the bleed and the crop are the same box, so there is no bleed to + // measure. That is a property of the document, not a fault on every page: report it once. + let untrimmed = pages.iter().filter(|page| !page.trim_set).count(); + if untrimmed > 0 { + findings.push(Finding { + id: "page-trim", + severity: "info", + message: if untrimmed == pages.len() { + "No page has a TrimBox, so bleed can't be measured.".to_string() + } else { + format!("{untrimmed} of {} pages have no TrimBox, so their bleed can't be measured.", pages.len()) + }, + page: None, + }); + } + for page in pages.iter().filter(|page| page.trim_set) { + let tight = page.bleed_margin_mm.into_iter().fold(f64::INFINITY, f64::min); + if tight < MIN_BLEED_MM { + findings.push(Finding { + id: "page-bleed", + severity: "warn", + message: format!( + "Page {} has {:.1} mm of bleed past the trim on its tightest side (a press usually wants about 3 mm). Nothing was added.", + page.page, + tight.max(0.0) + ), + page: Some(page.page), + }); + } + } + for image in &images { + if image.ppi < FAIL_PPI { + findings.push(Finding { + id: "image-ppi", + severity: "fail", + message: format!( + "Image {} on page {} is drawn at {:.0} ppi, below {:.0}. Enlarging it would not add detail.", + image.name, image.page, image.ppi, FAIL_PPI + ), + page: Some(image.page), + }); + } else if image.ppi < WARN_PPI { + findings.push(Finding { + id: "image-ppi", + severity: "warn", + message: format!( + "Image {} on page {} is drawn at {:.0} ppi, below {:.0} for close-up print.", + image.name, image.page, image.ppi, WARN_PPI + ), + page: Some(image.page), + }); + } + } + if declared.output_intents.iter().any(|intent| is_screen_profile(intent)) + && !declared.output_intents.iter().any(|intent| is_press_profile(intent)) + { + findings.push(Finding { + id: "output-intent", + severity: "warn", + message: format!( + "The output intent ({}) is a screen profile. A press condition was not added, because inventing one would retint the file.", + declared.output_intents.join(", ") + ), + page: None, + }); + } + if resolution_unknown > 0 { + findings.push(Finding { + id: "image-ppi-unknown", + severity: "info", + message: format!( + "The resolution of {resolution_unknown} image placement{} couldn't be measured (its placement matrix is degenerate or not finite).", + if resolution_unknown == 1 { "" } else { "s" } + ), + page: None, + }); + } + findings.sort_by(|a, b| a.page.cmp(&b.page).then(a.id.cmp(b.id)).then(a.message.cmp(&b.message))); + PrintReadiness { pages, images, output_intents: declared.output_intents, fonts_not_embedded, pdfa_issues, findings, resolution_unknown } +} + +fn page_reports(doc: &Document, pages: &[pdfcraft_model::Page]) -> Vec { + let boxes = pdfcraft_organize::page_boxes(doc).unwrap_or_default(); + pages + .iter() + .enumerate() + .map(|(i, page)| { + let row = boxes.get(i).copied().unwrap_or([[0.0; 4]; 5]); + let trim = row[3]; + let bleed = row[2]; + PageBoxes { + page: (i + 1) as u32, + media_mm: wh_mm(row[0]), + crop_mm: wh_mm(row[1]), + trim_mm: wh_mm(trim), + bleed_mm: wh_mm(bleed), + trim_set: page.dict.contains(b"TrimBox"), + bleed_set: page.dict.contains(b"BleedBox"), + bleed_margin_mm: [mm(trim[0] - bleed[0]), mm(trim[1] - bleed[1]), mm(bleed[2] - trim[2]), mm(bleed[3] - trim[3])], + } + }) + .collect() +} + +/// The images drawn on each page, and how many placements had no measurable resolution. +fn image_reports(doc: &Document, pages: &[pdfcraft_model::Page]) -> (Vec, usize) { + let mut out = Vec::new(); + let mut unknown = 0usize; + let mut seen = std::collections::HashSet::new(); + for (i, page) in pages.iter().enumerate() { + let page_no = (i + 1) as u32; + if let Ok(placed) = pdfcraft_edit::page_images(doc, i) { + for image in placed { + if let Some(ppi) = placed_ppi(&image) { + if let Some(object) = image.object { + seen.insert((page_no, object)); + } + if !ppi.is_finite() { + unknown = unknown.saturating_add(1); + continue; + } + let w = (image.rect[2] - image.rect[0]).abs(); + let h = (image.rect[3] - image.rect[1]).abs(); + out.push(DrawnImage { page: page_no, name: image.name, ppi, pixels: [image.width, image.height], size_mm: [mm(w), mm(h)] }); + } + } + } + // Images drawn inside a form XObject are not listed above. Their resolution is the + // smallest use on this page, which is what the optimizer already measures. + for (object, ppi) in pdfcraft_optimize::effective_resolutions(doc, &[page.obj]) { + if seen.contains(&(page_no, object)) { + continue; + } + if !ppi.is_finite() { + unknown = unknown.saturating_add(1); + continue; + } + out.push(DrawnImage { page: page_no, name: format!("object {object}"), ppi, pixels: [0, 0], size_mm: [0.0, 0.0] }); + } + } + out.sort_by(|a, b| a.page.cmp(&b.page).then(a.name.cmp(&b.name)).then(a.ppi.total_cmp(&b.ppi))); + (out, unknown) +} + +/// The effective resolution of a raster placement, `None` for Form artwork. Not finite (NaN) +/// when the placement can't be measured: callers report that rather than pass it. +fn placed_ppi(image: &PageImage) -> Option { + if image.is_form { + return None; + } + Some(matrix_ppi(image.matrix, image.width, image.height)) +} + +/// Pixels per inch of a `width` × `height` image drawn through `matrix`, or NaN when the matrix +/// is degenerate or not finite or the image has no pixels. +fn matrix_ppi(matrix: [f64; 6], width: u32, height: u32) -> f64 { + let [a, b, c, d, ..] = matrix; + let (sx, sy) = (a.hypot(b), c.hypot(d)); + if width == 0 || height == 0 || !sx.is_finite() || !sy.is_finite() || sx <= 1e-6 || sy <= 1e-6 { + return f64::NAN; + } + let ppi = (f64::from(width) / (sx / 72.0)).min(f64::from(height) / (sy / 72.0)); + if ppi.is_finite() { ppi } else { f64::NAN } +} + +fn is_screen_profile(intent: &str) -> bool { + let id = intent.to_ascii_lowercase(); + id.contains("srgb") || id.contains("adobe rgb") || id.contains("display") +} + +fn is_press_profile(intent: &str) -> bool { + let id = intent.to_ascii_lowercase(); + id.contains("cmyk") || id.contains("fogra") || id.contains("coated") || id.contains("swop") || id.contains("gracol") +} + +fn mm(points: f64) -> f64 { + points * 25.4 / 72.0 +} + +fn wh_mm(rect: [f64; 4]) -> [f64; 2] { + [mm((rect[2] - rect[0]).abs()), mm((rect[3] - rect[1]).abs())] +} + +fn size_mm(wh: [f64; 2]) -> String { + format!("{:.1} × {:.1}", wh[0], wh[1]) +} + +fn min_mm(sides: [f64; 4]) -> String { + let n = sides.into_iter().fold(f64::INFINITY, f64::min); + if n.is_finite() { format!("{n:.1}") } else { "0.0".into() } +} + +#[cfg(test)] +mod tests { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + + use super::*; + + fn image(doc: &mut Document, w: u32, h: u32) -> pdfcraft_cos::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("DeviceGray")); + doc.add(Object::Stream(Stream::flate(d, &vec![0; (w as usize) * (h as usize)]))) + } + + fn page(doc: &mut Document, name: &str, object: pdfcraft_cos::ObjRef, content: &str) -> pdfcraft_cos::ObjRef { + 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(name.as_bytes().to_vec(), Object::Ref(object)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let contents = 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(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default(); + 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(); + r + } + + #[test] + fn a_drawn_image_is_measured_where_it_is_placed() { + let mut doc = Document::new_empty(); + // 100 px across 144 pt (2 in) is 50 ppi, not "100 px on a letter page". + let soft = image(&mut doc, 100, 100); + // 600 px across 72 pt (1 in) is 600 ppi. + let sharp = image(&mut doc, 600, 600); + page(&mut doc, "Soft", soft, "q 144 0 0 144 36 600 cm /Soft Do Q"); + let sharp_page = page(&mut doc, "Sharp", sharp, "q 72 0 0 72 36 36 cm /Sharp Do Q"); + doc.update_dict(sharp_page, |d| { + let inset = |n: i64| Object::Array(vec![n.into(), n.into(), (612 - n).into(), (792 - n).into()]); + d.set(b"TrimBox".to_vec(), inset(18)); + d.set(b"BleedBox".to_vec(), inset(0)); + }) + .unwrap(); + + let report = inspect(&doc); + let soft = report.images.iter().find(|image| image.name == "Soft").unwrap(); + let sharp = report.images.iter().find(|image| image.name == "Sharp").unwrap(); + assert!((soft.ppi - 50.0).abs() < 0.5, "{}", soft.ppi); + assert!((sharp.ppi - 600.0).abs() < 0.5, "{}", sharp.ppi); + assert!(report.findings.iter().any(|f| f.id == "image-ppi" && f.severity == "fail" && f.page == Some(1))); + assert!(!report.findings.iter().any(|f| f.id == "image-ppi" && f.page == Some(2))); + // Page 1 has no TrimBox: one document-level note, not a bleed warning per page. + assert!(!report.findings.iter().any(|f| f.id == "page-bleed"), "{:?}", report.findings); + let trim: Vec<_> = report.findings.iter().filter(|f| f.id == "page-trim").collect(); + assert_eq!(trim.len(), 1, "{:?}", report.findings); + assert_eq!((trim[0].severity, trim[0].page), ("info", None)); + assert!(trim[0].message.starts_with("1 of 2 pages"), "{}", trim[0].message); + assert_eq!(report.resolution_unknown, 0); + assert!(report.text().contains("50 ppi")); + assert!(report.text().contains("verification only")); + } + + #[test] + fn untrimmed_pages_get_one_note_and_a_tight_trim_gets_a_warning() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 600, 600); + for _ in 0..3 { + page(&mut doc, "Im", img, "q 72 0 0 72 36 36 cm /Im Do Q"); + } + let report = inspect(&doc); + let notes: Vec<_> = report.findings.iter().filter(|f| f.id.starts_with("page-")).collect(); + assert_eq!(notes.len(), 1, "{notes:?}"); + assert_eq!(notes[0].message, "No page has a TrimBox, so bleed can't be measured."); + + // A TrimBox 1 pt inside the crop, with no BleedBox: 0.35 mm of bleed is a real warning. + let tight = page(&mut doc, "Im", img, "q 72 0 0 72 36 36 cm /Im Do Q"); + doc.update_dict(tight, |d| d.set(b"TrimBox".to_vec(), Object::Array(vec![1.into(), 1.into(), 611.into(), 791.into()]))).unwrap(); + let report = inspect(&doc); + assert!(report.findings.iter().any(|f| f.id == "page-bleed" && f.severity == "warn" && f.page == Some(4))); + assert_eq!(report.findings.iter().filter(|f| f.id == "page-bleed").count(), 1); + } + + #[test] + fn an_unmeasurable_placement_is_never_passed() { + assert!(matrix_ppi([f64::NAN, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan()); + assert!(matrix_ppi([f64::INFINITY, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan()); + assert!(matrix_ppi([0.0, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600).is_nan()); + assert!(matrix_ppi([72.0, 0.0, 0.0, 72.0, 0.0, 0.0], 0, 600).is_nan()); + assert!((matrix_ppi([72.0, 0.0, 0.0, 72.0, 0.0, 0.0], 600, 600) - 600.0).abs() < 1e-9); + + // A placement scaled past f64's range and then multiplied by zero has a NaN matrix. The + // content readers drop it before it is measured; it must never be reported as passing. + let mut doc = Document::new_empty(); + let img = image(&mut doc, 100, 100); + let huge = format!("1{}", "0".repeat(400)); + page(&mut doc, "Im", img, &format!("q {huge} 0 0 {huge} 0 0 cm 0 1 1 0 0 0 cm /Im Do Q")); + let report = inspect(&doc); + assert!(report.images.iter().all(|image| image.ppi.is_finite()), "{:?}", report.images); + assert!(!report.findings.iter().any(|f| f.id == "image-ppi"), "{:?}", report.findings); + } +} 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/scan_colours.rs b/crates/engine/src/scan_colours.rs new file mode 100644 index 0000000000..86094099cb --- /dev/null +++ b/crates/engine/src/scan_colours.rs @@ -0,0 +1,84 @@ +//! Edit text on a scanned page: an OCR word is invisible text over the scan, so rewriting it must +//! also hide the scanned word. The paper colour around the word becomes the cover drawn over it +//! ([`pdfcraft_edit::BlockStyle::cover`]) and the colour of its ink becomes the new text's. + +use std::sync::Arc; + +use pdfcraft_edit::TextBlock; +use pdfcraft_render::{PageInfo, PageRenderer, RenderConfig, RenderRequest, Tile}; + +/// Device pixels per point the word's surroundings are read at. +const SCALE: f32 = 3.0; +/// The most pixels read for one paragraph; a larger area is read at a lower resolution. +const MAX_PIXELS: f32 = 4_000_000.0; +/// How far around the word (points) the paper is read. +const RING: f32 = 4.0; + +/// The paper and ink colours (RGB 0–1) of invisible paragraph `b` on `page`, or `None` when the +/// paragraph is visible text or the page can't be read. +pub(crate) fn sample(cos: &pdfcraft_cos::Document, info: &PageInfo, page: usize, b: &TextBlock) -> Option<([f64; 3], [f64; 3])> { + if !b.invisible { + return None; + } + // The word's box in view space (points, y down). + let corners = [[b.rect[0], b.rect[1]], [b.rect[2], b.rect[1]], [b.rect[0], b.rect[3]], [b.rect[2], b.rect[3]]] + .map(|[x, y]| info.user_to_view(x as f32, y as f32)); + let (x0, x1) = corners.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, z), c| (a.min(c[0]), z.max(c[0]))); + let (y0, y1) = corners.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, z), c| (a.min(c[1]), z.max(c[1]))); + let word = [x0, y0, x1, y1]; + let area = [x0 - RING, y0 - RING, x1 + RING, y1 + RING]; + if word.iter().any(|v| !v.is_finite()) || x1 <= x0 || y1 <= y0 { + return None; + } + let pixels = (area[2] - area[0]) * (area[3] - area[1]) * SCALE * SCALE; + let scale = if pixels > MAX_PIXELS { SCALE * (MAX_PIXELS / pixels).sqrt() } else { SCALE }; + let (page_w, page_h) = (info.width * scale, info.height * scale); + let clamp = |v: f32, max: f32| v.clamp(0.0, max.max(0.0)).floor() as u32; + let (tx0, ty0) = (clamp(area[0] * scale, page_w), clamp(area[1] * scale, page_h)); + let (tx1, ty1) = (clamp(area[2] * scale, page_w), clamp(area[3] * scale, page_h)); + if tx1 <= tx0 || ty1 <= ty0 { + return None; + } + let tile = Tile { x: tx0, y: ty0, w: tx1 - tx0, h: ty1 - ty0 }; + // The working file as it is: written out only if the document holds unsaved changes, which + // it doesn't between edits (each one reopens the file it wrote). + let bytes = if cos.is_modified() { + Arc::new(pdfcraft_cos::write_incremental(cos, &pdfcraft_cos::SaveOptions::default()).ok()?) + } else { + cos.bytes().clone() + }; + // The document's true colours, whatever the reading colours are set to. + let shot = PageRenderer::new(bytes, RenderConfig::default()).render(RenderRequest { page, scale, tile: Some(tile), ..Default::default() }); + if shot.error.is_some() { + return None; + } + let pad = ((y1 - y0) * 0.1).clamp(0.5, 2.0); + let (mut paper, mut ink) = (Vec::new(), Vec::new()); + for (i, px) in shot.rgba.as_chunks::<4>().0.iter().enumerate() { + let (col, row) = (i % shot.width.max(1) as usize, i / shot.width.max(1) as usize); + let x = (tx0 as f32 + col as f32 + 0.5) / scale; + let y = (ty0 as f32 + row as f32 + 0.5) / scale; + let rgb = [px[0], px[1], px[2]].map(|c| f64::from(c) / 255.0); + if x >= x0 && x <= x1 && y >= y0 && y <= y1 { + ink.push(rgb); + } else if x < x0 - pad || x > x1 + pad || y < y0 - pad || y > y1 + pad { + paper.push(rgb); + } + } + let luma = |c: &[f64; 3]| 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; + let by_luma = |a: &[f64; 3], b: &[f64; 3]| luma(a).total_cmp(&luma(b)); + paper.sort_by(by_luma); + ink.sort_by(by_luma); + // The paper is the ring's median, so a table rule or a neighbouring letter in it doesn't count. + let paper = paper.get(paper.len() / 2).copied().unwrap_or([1.0; 3]); + // The ink is the average of the word's darkest pixels; a box without any is printed in black. + let darkest = ink.get(..ink.len().div_ceil(8)).unwrap_or_default(); + let ink = if darkest.is_empty() { + [0.0; 3] + } else { + let sum = darkest.iter().fold([0.0; 3], |s, c| [s[0] + c[0], s[1] + c[1], s[2] + c[2]]); + sum.map(|v| v / darkest.len() as f64) + }; + let ink = if luma(&paper) - luma(&ink) < 0.25 { [0.0; 3] } else { ink }; + Some((paper, ink)) +} diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs new file mode 100644 index 0000000000..8287743fc5 --- /dev/null +++ b/crates/engine/src/scan_fields.rs @@ -0,0 +1,144 @@ +//! Prepare a form ▸ field detection on scanned pages. A page that is a picture has no text and +//! no drawn shapes to read, so it is rendered and its lines and boxes are read from the pixels +//! ([`pdfcraft_forms::detect::raster`]). The labels come from the page's text layer (an earlier +//! Recognize text), else from text recognition when its models are installed. Without either, +//! the fields are still found and get the default names (Text1, Check Box1, …). +//! +//! Detection works in the page as displayed (after `/Rotate`), in points with y up, so a scan +//! stored sideways and turned upright by `/Rotate` reads like any other page. The fields are +//! mapped back to user space and turned with the page (`/MK /R`), so typed text reads upright. + +use pdfcraft_forms::detect::{Candidate, Word, detect, raster}; +use pdfcraft_render::{PageInfo, PageRenderer, PageText, RenderRequest}; + +use crate::{DocId, Document, Session, ocr}; + +/// Pixels per point the page is rendered at for its lines (144 dpi). +const SCALE: f32 = 2.0; +/// The longest side of that rendering, in pixels. +const MAX_SIDE: f32 = 3000.0; +/// A page is a scan when its images cover at least this share of it. +const COVERED: f64 = 0.5; +/// Recognised "words" less tall than this (points) are specks read as letters, and would make +/// the box they sit on look filled in. +const MIN_WORD_HEIGHT: f64 = 4.0; + +/// Whether `page` is a picture of a page: images cover most of it. +pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let page_area = (cx1 - cx0) * (cy1 - cy0); + if !page_area.is_finite() || page_area <= 0.0 { + return false; + } + let covered: f64 = doc + .page_images(page) + .iter() + .filter(|i| !i.is_form && i.width > 1 && i.height > 1) + .map(|i| { + let r = i.visible_rect.unwrap_or(i.rect); + let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); + let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); + w * h + }) + .filter(|a| a.is_finite()) + .sum(); + covered >= COVERED * page_area +} + +/// The page as displayed, in points with y up: where detection on a scan works. +struct Frame<'a>(&'a PageInfo); + +impl Frame<'_> { + /// The box around view-space points (y down). + fn view_box(&self, points: impl IntoIterator) -> Option<[f64; 4]> { + let h = f64::from(self.0.height); + let mut out = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for [x, y] in points { + let (x, y) = (f64::from(x), h - f64::from(y)); + out = [out[0].min(x), out[1].min(y), out[2].max(x), out[3].max(y)]; + } + out.iter().all(|v| v.is_finite()).then_some(out) + } + + /// A user-space rectangle (or the box around user-space points) in the frame. + fn user_box(&self, points: impl IntoIterator) -> Option<[f64; 4]> { + self.view_box(points.into_iter().map(|[x, y]| self.0.user_to_view(x as f32, y as f32))) + } + + /// A frame rectangle in user space. + fn user_rect(&self, r: [f64; 4]) -> [f64; 4] { + let h = f64::from(self.0.height); + let a = self.0.view_to_user(r[0] as f32, (h - r[1]) as f32); + let b = self.0.view_to_user(r[2] as f32, (h - r[3]) as f32); + [f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))] + } +} + +fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { + [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] +} + +impl Session { + /// The fields on scanned `page` of `id`, in user space, and how far the page turns them + /// (its `/Rotate`). `text` is the page's text layer, `existing` its widget rectangles. + pub(crate) fn detect_on_scan( + &self, + id: DocId, + r: &mut PageRenderer, + page: usize, + text: Option<&PageText>, + existing: &[[f64; 4]], + taken: &[String], + ) -> (Vec, i64) { + let Some(info) = self.get(id).and_then(|d| d.info.pages.get(page)) else { return (Vec::new(), 0) }; + let frame = Frame(info); + let scale = SCALE.min(MAX_SIDE / info.width.max(info.height).max(1.0)); + let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + if shot.error.is_some() || shot.width == 0 { + return (Vec::new(), 0); + } + let px_per_pt = f64::from(shot.width) / f64::from(info.width.max(1e-3)); + let shapes = raster::shapes(&shot.rgba, shot.width, shot.height, px_per_pt); + if shapes.boxes.is_empty() && shapes.rules.is_empty() { + return (Vec::new(), 0); + } + let mut words: Vec = text + .map(|t| crate::js::words_with(t, |x, y| [x, y])) + .unwrap_or_default() + .into_iter() + .filter_map(|(text, v)| Some(Word { text, rect: frame.view_box([[v[0], v[1]], [v[2], v[3]]])? })) + .collect(); + if words.is_empty() { + words = self.recognized_words(id, page, &frame); + } + let existing: Vec<[f64; 4]> = existing.iter().filter_map(|r| frame.user_box(corners(*r))).collect(); + let found = detect(&words, &shapes, &existing, taken); + let turn = i64::from(info.rotation % 360); + (found.into_iter().map(|c| Candidate { rect: frame.user_rect(c.rect), ..c }).collect(), turn) + } + + /// The words text recognition reads on `page`, in the frame; none without the models. + fn recognized_words(&self, id: DocId, page: usize, frame: &Frame) -> Vec { + if !ocr::available() { + return Vec::new(); + } + let (Ok(engine), Some(job)) = (ocr::engine(), self.ocr_job(id, &[page], ocr::OcrSettings::default())) else { + return Vec::new(); + }; + job.run(&engine, |_, _| true) + .into_iter() + .flat_map(|p| p.words) + .filter_map(|w| { + let o = w.origin; + let points = [ + o, + [o[0] + w.across[0], o[1] + w.across[1]], + [o[0] + w.up[0], o[1] + w.up[1]], + [o[0] + w.across[0] + w.up[0], o[1] + w.across[1] + w.up[1]], + ]; + Some(Word { rect: frame.user_box(points)?, text: w.text }) + }) + .filter(|w| w.rect[3] - w.rect[1] >= MIN_WORD_HEIGHT) + .collect() + } +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs new file mode 100644 index 0000000000..1d085fadc0 --- /dev/null +++ b/crates/engine/src/session.rs @@ -0,0 +1,54 @@ +//! 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, 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, + /// Preferences ▸ External converters (off by default). + pub(crate) external: crate::external::ExternalConverters, + /// What the last Convert colours to grayscale did. + pub(crate) last_color_report: Option, + /// What the last Apply redactions did. + pub(crate) last_redaction_report: 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..1a7135d731 --- /dev/null +++ b/crates/engine/src/session/editing.rs @@ -0,0 +1,542 @@ +#[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, 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, +}; + +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> { + self.last_color_report = None; + self.last_redaction_report = None; + 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())); + } + // 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); + 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()); + } + self.last_color_report = cx.colors.take(); + self.last_redaction_report = cx.redaction.take(); + Ok(()) + } + + /// What the last Apply Redactions did (`None` after any other edit). + pub fn last_redaction_report(&self) -> Option<&pdfcraft_redact::Report> { + self.last_redaction_report.as_ref() + } + + /// What the last Convert colours to grayscale did (`None` after any other edit). + pub fn last_color_report(&self) -> Option<&pdfcraft_optimize::ColorReport> { + self.last_color_report.as_ref() + } + + 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(), 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). + 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(), 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. + 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(), doc.config.clone()); + } + + /// 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(), doc.config.clone()); + doc.bytes = bytes; + Ok(()) + } +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs new file mode 100644 index 0000000000..1e0dc9a34f --- /dev/null +++ b/crates/engine/src/session/files.rs @@ -0,0 +1,446 @@ +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, +}; + +use crate::{Converted, conversion_note_replaced, conversion_notes_csv, conversion_notes_email}; + +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) + } + + /// A new document from CSV or tab-separated text: one table, its first row a header repeated + /// on every page (wide tables on landscape pages). + pub fn create_from_csv(&self, title: &str, name: &str, text: &str) -> Result<(Arc>, pdfcraft_create::CsvReport), EditError> { + let (doc, report) = pdfcraft_create::from_csv(title, name, text, pdfcraft_create::LETTER)?; + Ok((self.write_new(&doc)?, report)) + } + + /// A new document from Markdown, with how many characters the built-in fonts couldn't show. + pub fn create_from_markdown(&self, title: &str, text: &str) -> Result<(Arc>, usize), EditError> { + let (doc, replaced) = pdfcraft_create::from_markdown(title, text, pdfcraft_create::LETTER)?; + Ok((self.write_new(&doc)?, replaced)) + } + + /// A new document from an email message (`.eml`): its headers and text. Attachments are listed, + /// and embedded as file attachments only when `embed_attachments` asks for it. + pub fn create_from_email( + &self, + title: &str, + bytes: &[u8], + embed_attachments: bool, + ) -> Result<(Arc>, pdfcraft_create::EmailReport), EditError> { + let (doc, report) = pdfcraft_create::from_email(title, bytes, pdfcraft_create::LETTER, embed_attachments)?; + Ok((self.write_new(&doc)?, report)) + } + + /// Convert a file Create understands (an image, plain text, CSV, Markdown or an email message) + /// 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> { + self.convert_to_pdf_noted(name, bytes).map(|(kind, pdf, _)| (kind, pdf)) + } + + /// [`Self::convert_to_pdf`], with notes for the user about what the conversion couldn't keep + /// (characters the built-in fonts can't show, rows or columns cut from a table, attachments + /// listed but not embedded). + pub fn convert_to_pdf_noted(&self, name: &str, bytes: &Arc>) -> Result { + 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, .csv, .md or .eml file" + ))); + }; + let title = name.rsplit_once('.').map_or(name, |(s, _)| s); + // Decoders and parsers 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(), Vec::new())), + SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]).map(|b| (b, Vec::new())), + SourceKind::Text => self.create_from_text(title, &decode_text(bytes)).map(|b| (b, Vec::new())), + SourceKind::Csv => self.create_from_csv(title, name, &decode_text(bytes)).map(|(b, r)| (b, conversion_notes_csv(&r))), + SourceKind::Markdown => { + self.create_from_markdown(title, &decode_text(bytes)).map(|(b, n)| (b, conversion_note_replaced(n).into_iter().collect())) + } + SourceKind::Email => self.create_from_email(title, bytes, false).map(|(b, r)| (b, conversion_notes_email(&r))), + SourceKind::Html | SourceKind::Office => { + self.external.convert(kind, name, bytes, &std::sync::atomic::AtomicBool::new(false)).map(|b| (Arc::new(b), Vec::new())) + } + }) + .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; + let (pdf, notes) = created?; + Ok((kind, pdf, notes)) + } + + /// 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(_) => 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 => 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..d4156a2eb1 --- /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, 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(), doc.config.clone()); + } + // 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(), 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(); + } + // 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..055437f562 --- /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, 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(), 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) + } + + /// 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 8891954145..daff674c19 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -52,8 +52,8 @@ impl Document { let obj = editor.cos.get(picture); let pdfcraft_cos::Object::Stream(s) = &*obj else { return Err("the signature image is damaged".into()) }; let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0)); - if w <= 0 || h <= 0 || w > 4096 || h > 4096 || w.saturating_mul(h) > MAX_PIXELS as i64 { - return Err(SignatureImageError::Size.to_string()); + if w <= 0 || h <= 0 || w > i64::from(MAX_SIDE) || h > i64::from(MAX_SIDE) || w.saturating_mul(h) > MAX_PIXELS as i64 { + return Err(SignatureImageError::PlacedSize.to_string()); } let (_, bytes) = pdfcraft_create::image_file(&editor.cos, picture)?; let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?; @@ -90,13 +90,20 @@ pub fn upright_box(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Opti /// Saved signatures live in settings, so both source and normalized PNG are bounded. pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20; const MAX_PIXELS: u64 = 4 << 20; +const MAX_SIDE: u32 = 4096; +/// Larger sources (scans, phone photos) are decoded within these bounds, then scaled down. +const MAX_SOURCE_SIDE: u32 = 8192; +const MAX_SOURCE_PIXELS: u64 = 32 << 20; #[derive(Debug, thiserror::Error)] pub enum SignatureImageError { #[error("Choose a PNG or JPEG image.")] Format, - #[error("Choose an image smaller than 4 MiB and 4 megapixels (at most 4096 pixels per side).")] + #[error("Choose an image smaller than 4 MiB and 32 megapixels (at most 8192 pixels per side).")] Size, + /// An image already in the document, which was stored at 4 megapixels or less when placed. + #[error("This signature image is larger than 4 megapixels (or 4096 pixels per side), so it can't be previewed.")] + PlacedSize, #[error("Couldn't read the signature image: {0}")] Read(#[from] std::io::Error), #[error("Couldn't decode the signature image: {0}")] @@ -111,6 +118,43 @@ pub struct SignatureImage { size: [usize; 2], } +/// Decode an imported PNG or JPEG within the source limits (file size, 32 megapixels, 8192 pixels +/// per side, bounded allocation), upright per its EXIF orientation and scaled down to at most +/// 4 megapixels and 4096 pixels per side. Shared by image signatures and scanned photos. +pub(crate) fn decode_source(bytes: &[u8]) -> Result { + if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES { + return Err(SignatureImageError::Size); + } + let format = image::guess_format(bytes).map_err(|_| SignatureImageError::Format)?; + if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) { + return Err(SignatureImageError::Format); + } + let mut reader = ImageReader::with_format(Cursor::new(bytes), format); + let mut limits = Limits::default(); + limits.max_image_width = Some(MAX_SOURCE_SIDE); + limits.max_image_height = Some(MAX_SOURCE_SIDE); + // 8-bit RGBA at the pixel cap; 16-bit sources that large hit this and report Size. + limits.max_alloc = Some(MAX_SOURCE_PIXELS * 4); + reader.limits(limits); + let too_big = |e: image::ImageError| if matches!(e, image::ImageError::Limits(_)) { SignatureImageError::Size } else { e.into() }; + let mut decoder = reader.into_decoder().map_err(too_big)?; + let (w, h) = decoder.dimensions(); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_SOURCE_PIXELS { + return Err(SignatureImageError::Size); + } + let orientation = decoder.orientation()?; + let mut image = DynamicImage::from_decoder(decoder).map_err(too_big)?; + image.apply_orientation(orientation); + let (w, h) = (f64::from(image.width()), f64::from(image.height())); + let scale = (f64::from(MAX_SIDE) / w.max(h)).min((MAX_PIXELS as f64 / (w * h)).sqrt()); + if scale < 1.0 { + let (nw, nh) = (((w * scale) as u32).max(1), ((h * scale) as u32).max(1)); + // `thumbnail` resamples in one integer pass, without the large float buffers `resize` keeps. + image = image.thumbnail(nw, nh); + } + Ok(image) +} + impl SignatureImage { /// Read at most the file limit plus one byte, even if the file grows while being read. pub fn read(reader: impl Read) -> Result { @@ -120,27 +164,7 @@ impl SignatureImage { } pub fn from_bytes(bytes: &[u8]) -> Result { - if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES { - return Err(SignatureImageError::Size); - } - let format = image::guess_format(bytes).map_err(|_| SignatureImageError::Format)?; - if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) { - return Err(SignatureImageError::Format); - } - let mut reader = ImageReader::with_format(Cursor::new(bytes), format); - let mut limits = Limits::default(); - limits.max_image_width = Some(4096); - limits.max_image_height = Some(4096); - limits.max_alloc = Some(32 << 20); - reader.limits(limits); - let mut decoder = reader.into_decoder()?; - let (w, h) = decoder.dimensions(); - if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { - return Err(SignatureImageError::Size); - } - let orientation = decoder.orientation()?; - let mut image = DynamicImage::from_decoder(decoder)?; - image.apply_orientation(orientation); + let image = decode_source(bytes)?; let rgba = image.into_rgba8(); let size = [rgba.width() as usize, rgba.height() as usize]; let mut png = Cursor::new(Vec::new()); @@ -164,6 +188,13 @@ impl SignatureImage { self.size } + /// This image as the graphic of a visible digital signature. + pub fn graphic(&self) -> pdfcraft_sign::Graphic { + // Sides are at most 4096 pixels; 0 would be refused by the signer, never drawn. + let side = |v: usize| u32::try_from(v).unwrap_or(0); + pdfcraft_sign::Graphic::Image { width: side(self.size[0]), height: side(self.size[1]), rgba: self.rgba.clone() } + } + /// Make near-white paper transparent, keeping darker ink. pub fn remove_white_background(&self, cutoff: u8, feather: u8) -> Result { let [w, h] = self.size; @@ -243,12 +274,23 @@ mod tests { assert!(SignatureImage::from_bytes(b"not an image").is_err()); assert!(SignatureImage::from_bytes(b"\x89PNG\r\n\x1a\ntruncated").is_err()); assert!(SignatureImage::from_bytes(&vec![0; MAX_SIGNATURE_IMAGE_BYTES + 1]).is_err()); - assert!(SignatureImage::from_bytes(&png(4097, 1)).is_err()); - assert!(SignatureImage::from_bytes(&png(4096, 1025)).is_err()); + assert!(matches!(SignatureImage::from_bytes(&png(8193, 1)), Err(SignatureImageError::Size))); + assert!(matches!(SignatureImage::from_bytes(&png(8192, 4097)), Err(SignatureImageError::Size))); assert!(SignatureImage::read(std::io::repeat(0)).is_err()); assert!(matches!(SignatureImage::from_bytes(b"GIF89a\x02\0\x02\0"), Err(SignatureImageError::Format))); } + /// #1075: a 6025 x 2506 scan is scaled down to fit 4096 per side and 4 megapixels. + #[test] + fn large_signature_images_are_scaled_down() { + let image = SignatureImage::from_bytes(&png(6025, 2506)).unwrap(); + let [w, h] = image.size(); + assert!(w <= 4096 && (w * h) as u64 <= MAX_PIXELS, "{w} x {h}"); + assert!((w as f64 / h as f64 - 6025.0 / 2506.0).abs() < 0.01, "{w} x {h}"); + assert_eq!(SignatureImage::from_bytes(&png(4096, 1)).unwrap().size(), [4096, 1]); + assert_eq!(SignatureImage::from_bytes(&png(8192, 1)).unwrap().size(), [4096, 1]); + } + #[test] fn jpeg_signatures_are_normalized_to_png() { let mut jpeg = Cursor::new(Vec::new()); @@ -323,7 +365,7 @@ mod tests { stream.dict.set(b"Width".to_vec(), pdfcraft_cos::Object::Int(i64::MAX)); cos.set(picture, pdfcraft_cos::Object::Stream(stream)); let error = doc.image_signature_preview(0, 0).err().unwrap(); - assert!(error.contains("4 megapixels"), "{error}"); + assert_eq!(error, SignatureImageError::PlacedSize.to_string()); } /// "Left edge at the point, centered vertically" is as displayed: on a turned page the box diff --git a/crates/engine/src/signature_scan.rs b/crates/engine/src/signature_scan.rs new file mode 100644 index 0000000000..4742754b01 --- /dev/null +++ b/crates/engine/src/signature_scan.rs @@ -0,0 +1,605 @@ +//! Signatures scanned from a photo of paper (Fill & Sign ▸ Sign ▸ Scan). +//! +//! People sign a sheet of paper and photograph it, usually with a phone. Such a photo is far from +//! a clean signature image: it shows the table around the sheet, shadows of the phone or hand, +//! uneven light and sensor noise, and it is often larger than an image signature may be. This +//! module turns the framed part of the photo into a [`SignatureImage`]: the ink in its own colour +//! on a transparent background, trimmed, so it is placed, saved and reused like any image +//! signature. +//! +//! Everything is local and bounded: the photo is decoded within the same limits as an imported +//! image signature (a 4 MiB file, 32 megapixels, 8192 pixels per side), downscaled to at most +//! 4 megapixels and [`WORKING_MAX_SIDE`] pixels per side, and only that copy is kept. So one +//! scan of the whole frame touches at most 4 megapixels. +//! +//! The ink is separated from the paper in four steps: +//! 1. **Paper estimate.** A grayscale morphological closing (a maximum filter, then a minimum +//! filter, van Herk/Gil-Werman, O(1) per pixel) removes every dark detail narrower than the +//! window, i.e. the pen strokes, while following lighting gradients and sharp shadow edges. +//! Bright noise peaks are clipped first, and edge pixels are repeated outside the image, so a +//! table cut off by the frame keeps its size and stays background. +//! 2. **Darkness** of each pixel relative to that paper, slightly smoothed so that glossy +//! ballpoint strokes with a lighter centre come out solid. The border of large dark areas +//! that are not paper (the table) is cleared; shadows on the paper are kept. +//! 3. **Hysteresis.** Pixels darker than the threshold are ink; fainter pixels are kept only when +//! they belong to a blob with real ink, so faint stroke ends survive and stray marks don't. +//! Blobs with too little ink are dropped as dust. +//! 4. **Output.** The ink is trimmed with a small margin, drawn in its average colour with the +//! coverage as alpha, and normalized through [`SignatureImage`]. + +use std::io::{Cursor, Read}; +use std::sync::Arc; + +use image::{DynamicImage, ImageFormat, RgbImage}; + +use crate::signature_image::{SignatureImage, SignatureImageError}; + +/// Largest photo file accepted: the image signature limit (decoding shares its limits too). +pub const MAX_PHOTO_BYTES: usize = crate::signature_image::MAX_SIGNATURE_IMAGE_BYTES; +/// Longest side of the copy that is kept and scanned (it also holds at most 4 megapixels). +pub const WORKING_MAX_SIDE: u32 = 4096; +/// Longest side of the framed area that is cleaned for the final signature. +pub const SCAN_MAX_SIDE: u32 = 2400; +/// Longest side used for a quick preview, e.g. while the frame is being dragged. +pub const PREVIEW_MAX_SIDE: u32 = 900; +/// Longest side of the downscaled photo used to find the signature. +const DETECTION_MAX_SIDE: u32 = 1024; +/// Largest signature produced, in pixels (the image signature limit). +const MAX_OUTPUT_PIXELS: u64 = 4 << 20; + +/// The default of the Ink sensitivity slider (0–100). +pub const DEFAULT_SENSITIVITY: u8 = 50; +/// Detection uses a higher sensitivity so that faint strokes are framed too. +const DETECTION_SENSITIVITY: u8 = 70; +/// Darkness threshold (0–255, relative to the paper) at sensitivity 0 and 100. +const MAX_THRESHOLD: f32 = 100.0; +const MIN_THRESHOLD: f32 = 12.0; +/// Fainter pixels down to this fraction of the threshold are kept when they touch ink. +const HYSTERESIS_RATIO: f32 = 0.5; +/// Paper estimate window, relative to the photo's longest side; wider than half a felt-tip stroke. +const BACKGROUND_RADIUS_RATIO: f32 = 0.012; +const MIN_BACKGROUND_RADIUS: usize = 4; +/// One pixel of smoothing per this many pixels of the longest side. +const SMOOTHING_SCALE: usize = 1000; +/// A background darker than this fraction of the nearby paper is not paper (e.g. the table). +const DARK_AREA_RATIO: f32 = 0.45; +/// Blobs with fewer ink pixels than this fraction of the area (at least the minimum) are dust. +const SPECKLE_AREA_RATIO: f64 = 0.000_02; +const MIN_SPECKLE_AREA: usize = 6; +/// Blobs with less ink than this fraction of the largest one are not part of the signature. +const DETECTION_MIN_INK_RATIO: f64 = 0.1; +/// A detected signature this much taller than wide was photographed sideways. +const UPRIGHT_RATIO: u32 = 2; +/// Transparent margin around the trimmed signature, in pixels. +const TRIM_PADDING: usize = 8; + +#[derive(Debug, thiserror::Error)] +pub enum ScanError { + #[error("Choose a PNG or JPEG photo.")] + Format, + #[error("Choose a photo smaller than 4 MiB and 32 megapixels (at most 8192 pixels per side).")] + Size, + #[error("No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity.")] + NoInk, + #[error("Couldn't read the photo: {0}")] + Read(#[from] std::io::Error), + #[error("Couldn't decode the photo: {0}")] + Decode(#[from] image::ImageError), + #[error(transparent)] + Signature(#[from] SignatureImageError), +} + +/// A rectangle in photo pixels, top-left origin. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PhotoRect { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +impl PhotoRect { + /// Keep the rectangle inside a `width`×`height` photo, at least one pixel large. + pub fn clamped(self, width: u32, height: u32) -> Self { + let w = self.width.clamp(1, width.max(1)); + let h = self.height.clamp(1, height.max(1)); + Self { x: self.x.min(width.saturating_sub(w)), y: self.y.min(height.saturating_sub(h)), width: w, height: h } + } + + /// The same area after the photo (`width`×`height` before turning) is turned a quarter. + pub fn turned(self, turn: Turn, width: u32, height: u32) -> Self { + match turn { + Turn::Clockwise => { + Self { x: height.saturating_sub(self.y.saturating_add(self.height)), y: self.x, width: self.height, height: self.width } + } + Turn::CounterClockwise => { + Self { x: self.y, y: width.saturating_sub(self.x.saturating_add(self.width)), width: self.height, height: self.width } + } + } + } +} + +/// A quarter turn of the photo. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Turn { + Clockwise, + CounterClockwise, +} + +/// A decoded, upright and downscaled photo of a signature on paper. +#[derive(Clone, Debug, PartialEq)] +pub struct SignaturePhoto { + rgb: Arc, +} + +impl SignaturePhoto { + /// Read at most the file limit plus one byte, even if the file grows while being read. + pub fn read(reader: impl Read) -> Result { + let mut bytes = Vec::new(); + reader.take(MAX_PHOTO_BYTES as u64 + 1).read_to_end(&mut bytes)?; + Self::from_bytes(&bytes) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() > MAX_PHOTO_BYTES { + return Err(ScanError::Size); + } + // The image signature decoder: same limits, EXIF orientation (phones store portrait + // photos as landscape pixels plus an orientation), scaled down to 4 megapixels. + let image = crate::signature_image::decode_source(bytes).map_err(|e| match e { + SignatureImageError::Size => ScanError::Size, + SignatureImageError::Format => ScanError::Format, + e => ScanError::Signature(e), + })?; + Ok(Self { rgb: Arc::new(image.into_rgb8()) }) + } + + /// Width and height in pixels. + pub fn size(&self) -> [u32; 2] { + [self.rgb.width(), self.rgb.height()] + } + + /// The photo as RGB pixels, for showing it. + pub fn rgb(&self) -> &RgbImage { + &self.rgb + } + + /// The photo downscaled so that its longest side is at most `max_side`, as RGBA for display. + pub fn thumbnail(&self, max_side: u32) -> image::RgbaImage { + let [w, h] = self.size(); + let scale = (f64::from(max_side.max(1)) / f64::from(w.max(h).max(1))).min(1.0); + let nw = ((f64::from(w) * scale).round() as u32).max(1); + let nh = ((f64::from(h) * scale).round() as u32).max(1); + DynamicImage::ImageRgb8(image::imageops::resize(&*self.rgb, nw, nh, image::imageops::FilterType::Triangle)).into_rgba8() + } + + /// Turn the photo a quarter, returning `rect` moved along with it. + pub fn turn(&mut self, turn: Turn, rect: PhotoRect) -> PhotoRect { + let [w, h] = self.size(); + self.rgb = Arc::new(match turn { + Turn::Clockwise => image::imageops::rotate90(&*self.rgb), + Turn::CounterClockwise => image::imageops::rotate270(&*self.rgb), + }); + rect.turned(turn, w, h) + } + + /// Find the signature, or `None` when the photo shows no clear ink. + pub fn detect(&self) -> Option { + let [w, h] = self.size(); + let small = scaled(&self.rgb, PhotoRect { x: 0, y: 0, width: w, height: h }, DETECTION_MAX_SIDE); + let (sw, sh) = (small.width(), small.height()); + let region = detect_region(&luminance(&small), sw as usize, sh as usize)?; + let ratio = f64::from(w) / f64::from(sw.max(1)); + let scale = |v: usize| ((v as f64) * ratio).round().clamp(0.0, f64::from(u32::MAX)) as u32; + Some(PhotoRect { x: scale(region.x), y: scale(region.y), width: scale(region.width), height: scale(region.height) }.clamped(w, h)) + } + + /// Frame the signature for the user: turn a sideways signature upright, then return its + /// frame, or a centred default frame when no signature was found. + pub fn auto_frame(&mut self) -> PhotoRect { + if let Some(rect) = self.detect() { + if rect.height > rect.width.saturating_mul(UPRIGHT_RATIO) { + // Signatures are written horizontally. The direction is a guess; the user can + // turn the photo further. + return self.turn(Turn::CounterClockwise, rect); + } + return rect; + } + let [w, h] = self.size(); + PhotoRect { x: w / 7, y: h * 7 / 20, width: w * 5 / 7, height: h * 3 / 10 }.clamped(w, h) + } + + /// Clean the framed area into a transparent signature. `max_side` bounds the processed area: + /// [`SCAN_MAX_SIDE`] for the result, [`PREVIEW_MAX_SIDE`] for a quick preview. + pub fn scan(&self, rect: PhotoRect, sensitivity: u8, max_side: u32) -> Result { + let [w, h] = self.size(); + let rect = rect.clamped(w, h); + let area = scaled(&self.rgb, rect, max_side.clamp(16, WORKING_MAX_SIDE)); + let (aw, ah) = (area.width() as usize, area.height() as usize); + // Size the paper estimate on the whole photo, not on the frame, so that thick strokes in + // a tight frame are not taken for paper. + let scale = aw as f32 / rect.width.max(1) as f32; + let radius = (w.max(h) as f32 * BACKGROUND_RADIUS_RATIO * scale).round() as usize; + let ink = extract_ink(&luminance(&area), aw, ah, Options { sensitivity, despeckle: true, background_radius: Some(radius) }); + let bounds = ink.bounds.ok_or(ScanError::NoInk)?; + let png = render(&area, &ink.alpha, bounds)?; + Ok(SignatureImage::from_bytes(&png)?) + } +} + +/// The framed area of `rgb`, downscaled so that its longest side is at most `max_side`. +fn scaled(rgb: &RgbImage, rect: PhotoRect, max_side: u32) -> RgbImage { + let rect = rect.clamped(rgb.width(), rgb.height()); + let view = image::imageops::crop_imm(rgb, rect.x, rect.y, rect.width, rect.height).to_image(); + let longest = rect.width.max(rect.height); + if longest <= max_side { + return view; + } + let scale = f64::from(max_side) / f64::from(longest); + let nw = ((f64::from(rect.width) * scale).round() as u32).max(1); + let nh = ((f64::from(rect.height) * scale).round() as u32).max(1); + image::imageops::resize(&view, nw, nh, image::imageops::FilterType::Triangle) +} + +/// Perceived luminance (ITU BT.601). +fn luminance(rgb: &RgbImage) -> Vec { + rgb.pixels().map(|p| (0.299 * f32::from(p[0]) + 0.587 * f32::from(p[1]) + 0.114 * f32::from(p[2])).round().clamp(0.0, 255.0) as u8).collect() +} + +/// A rectangle in the pixels of a processed buffer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Region { + x: usize, + y: usize, + width: usize, + height: usize, +} + +#[derive(Clone, Copy, Debug)] +struct Options { + sensitivity: u8, + despeckle: bool, + /// Paper estimate window; by default a fraction of the buffer's longest side. + background_radius: Option, +} + +/// Ink coverage (0 = paper, 255 = solid ink) and the bounding box of the solid ink. +struct Ink { + alpha: Vec, + bounds: Option, +} + +/// The darkness threshold for a sensitivity of 0–100; higher sensitivity keeps fainter strokes. +fn threshold(sensitivity: u8) -> f32 { + let s = f32::from(sensitivity.min(100)) / 100.0; + MAX_THRESHOLD - s * (MAX_THRESHOLD - MIN_THRESHOLD) +} + +/// In-place separable box blur. +fn box_blur(values: &mut [f32], width: usize, height: usize, radius: usize) { + if radius == 0 || width == 0 || height == 0 || values.len() != width * height { + return; + } + let mut line = vec![0.0_f32; width.max(height)]; + let mut blur = |get: &dyn Fn(usize) -> f32, len: usize, out: &mut dyn FnMut(usize, f32)| { + for (i, slot) in line.iter_mut().take(len).enumerate() { + *slot = get(i); + } + let (mut sum, mut count) = (0.0_f32, 0_u32); + for v in line.iter().take(radius.min(len)) { + sum += v; + count += 1; + } + for i in 0..len { + if let Some(v) = line.get(i + radius).filter(|_| i + radius < len) { + sum += v; + count += 1; + } + if let Some(v) = i.checked_sub(radius + 1).and_then(|j| line.get(j)) { + sum -= v; + count = count.saturating_sub(1); + } + out(i, if count == 0 { 0.0 } else { sum / count as f32 }); + } + }; + let mut row = vec![0.0_f32; width]; + let mut tmp = vec![0.0_f32; width]; + for y in 0..height { + let start = y * width; + let Some(src) = values.get(start..start + width) else { return }; + row.copy_from_slice(src); + blur(&|x| row.get(x).copied().unwrap_or(0.0), width, &mut |x, v| { + if let Some(slot) = tmp.get_mut(x) { + *slot = v; + } + }); + if let Some(dst) = values.get_mut(start..start + width) { + dst.copy_from_slice(&tmp); + } + } + let mut column = vec![0.0_f32; height]; + let mut tmp = vec![0.0_f32; height]; + for x in 0..width { + for (y, slot) in column.iter_mut().enumerate() { + *slot = values.get(y * width + x).copied().unwrap_or(0.0); + } + blur(&|y| column.get(y).copied().unwrap_or(0.0), height, &mut |y, v| { + if let Some(slot) = tmp.get_mut(y) { + *slot = v; + } + }); + for (y, v) in tmp.iter().enumerate() { + if let Some(slot) = values.get_mut(y * width + x) { + *slot = *v; + } + } + } +} + +/// Sliding-window maximum or minimum over `radius` pixels on each side (van Herk/Gil-Werman). +/// Edge values are repeated outside the line. +fn sliding_extremum(src: &[u8], dst: &mut [u8], radius: usize, max: bool) { + let len = src.len().min(dst.len()); + if len == 0 { + return; + } + let pick = |a: u8, b: u8| if max { a.max(b) } else { a.min(b) }; + let size = 2 * radius + 1; + let total = len + 2 * radius; + let at = |i: usize| src.get(i.saturating_sub(radius).min(len - 1)).copied().unwrap_or(0); + let mut g = vec![0_u8; total]; + let mut h = vec![0_u8; total]; + let mut prev = 0; + for (i, slot) in g.iter_mut().enumerate() { + let v = at(i); + prev = if i % size == 0 { v } else { pick(prev, v) }; + *slot = prev; + } + let mut next = 0; + for (i, slot) in h.iter_mut().enumerate().rev() { + let v = at(i); + next = if i == total - 1 || (i + 1) % size == 0 { v } else { pick(next, v) }; + *slot = next; + } + for (i, out) in dst.iter_mut().take(len).enumerate() { + if let (Some(&a), Some(&b)) = (h.get(i), g.get(i + 2 * radius)) { + *out = pick(a, b); + } + } +} + +/// Separable square maximum (`max`) or minimum filter. +fn square_filter(src: &[u8], width: usize, height: usize, radius: usize, max: bool) -> Vec { + let mut rows = vec![0_u8; src.len()]; + for (s, d) in src.chunks_exact(width.max(1)).zip(rows.chunks_exact_mut(width.max(1))) { + sliding_extremum(s, d, radius, max); + } + let mut out = vec![0_u8; src.len()]; + let mut column = vec![0_u8; height]; + let mut filtered = vec![0_u8; height]; + for x in 0..width { + for (y, slot) in column.iter_mut().enumerate() { + *slot = rows.get(y * width + x).copied().unwrap_or(0); + } + sliding_extremum(&column, &mut filtered, radius, max); + for (y, v) in filtered.iter().enumerate() { + if let Some(slot) = out.get_mut(y * width + x) { + *slot = *v; + } + } + } + out +} + +/// The brightness of the paper behind every pixel (see the module documentation). +fn estimate_background(gray: &[u8], width: usize, height: usize, radius: usize) -> Vec { + let r = radius.max(MIN_BACKGROUND_RADIUS); + // Clip bright noise peaks, which the maximum filter would otherwise pick, so that the faint + // halo around every stroke doesn't count as ink. Only clipping keeps shadow edges sharp. + let mut blurred: Vec = gray.iter().map(|&v| f32::from(v)).collect(); + box_blur(&mut blurred, width, height, (r / 6).max(1)); + let clipped: Vec = gray.iter().zip(&blurred).map(|(&g, &b)| f32::from(g).min(b).round().clamp(0.0, 255.0) as u8).collect(); + let closed = square_filter(&square_filter(&clipped, width, height, r, true), width, height, r, false); + let mut smooth: Vec = closed.iter().map(|&v| f32::from(v)).collect(); + box_blur(&mut smooth, width, height, (r / 4).max(1)); + // The blur must never make the paper darker than the photo itself. + smooth.iter().zip(gray).map(|(&s, &g)| s.max(f32::from(g)).round().clamp(1.0, 255.0) as u8).collect() +} + +/// Label 8-connected blobs of non-zero `mask` pixels; returns the labels (0 = none) and count. +fn label_components(mask: &[bool], width: usize, height: usize) -> (Vec, usize) { + let mut labels = vec![0_u32; mask.len()]; + let mut stack: Vec = Vec::new(); + let mut count = 0_usize; + for start in 0..mask.len() { + if !mask.get(start).copied().unwrap_or(false) || labels.get(start).copied().unwrap_or(1) != 0 { + continue; + } + count += 1; + let label = u32::try_from(count).unwrap_or(u32::MAX); + if let Some(l) = labels.get_mut(start) { + *l = label; + } + stack.push(start); + while let Some(index) = stack.pop() { + let (x, y) = (index % width, index / width); + for ny in y.saturating_sub(1)..=(y + 1).min(height.saturating_sub(1)) { + for nx in x.saturating_sub(1)..=(x + 1).min(width.saturating_sub(1)) { + let n = ny * width + nx; + if mask.get(n).copied().unwrap_or(false) + && let Some(l) = labels.get_mut(n) + && *l == 0 + { + *l = label; + stack.push(n); + } + } + } + } + } + (labels, count) +} + +/// Separate the ink from the paper (see the module documentation). +fn extract_ink(gray: &[u8], width: usize, height: usize, options: Options) -> Ink { + let n = width * height; + if n == 0 || gray.len() != n { + return Ink { alpha: vec![0; gray.len()], bounds: None }; + } + let radius = + options.background_radius.unwrap_or_else(|| (width.max(height) as f32 * BACKGROUND_RADIUS_RATIO).round() as usize).max(MIN_BACKGROUND_RADIUS); + let background = estimate_background(gray, width, height, radius); + + let mut darkness: Vec = + gray.iter().zip(&background).map(|(&g, &bg)| if g >= bg { 0.0 } else { 255.0 * f32::from(bg - g) / f32::from(bg.max(1)) }).collect(); + let smoothing = width.max(height) / SMOOTHING_SCALE; + box_blur(&mut darkness, width, height, smoothing); + + // Where the sheet meets a much darker area (the table) is not ink; a shadow on the paper is + // only moderately darker and is kept. + let edge = smoothing + 2; + let brightest = square_filter(&background, width, height, edge, true); + let darkest = square_filter(&background, width, height, edge, false); + for ((d, &lo), &hi) in darkness.iter_mut().zip(&darkest).zip(&brightest) { + if f32::from(lo) < f32::from(hi) * DARK_AREA_RATIO { + *d = 0.0; + } + } + + let strong = threshold(options.sensitivity); + let weak = strong * HYSTERESIS_RATIO; + let candidates: Vec = darkness.iter().map(|&d| d >= weak).collect(); + let (labels, count) = label_components(&candidates, width, height); + let mut strong_pixels = vec![0_usize; count + 1]; + for (&label, &d) in labels.iter().zip(&darkness) { + if label != 0 + && d >= strong + && let Some(c) = strong_pixels.get_mut(label as usize) + { + *c += 1; + } + } + let min_strong = if options.despeckle { MIN_SPECKLE_AREA.max((n as f64 * SPECKLE_AREA_RATIO).round() as usize) } else { 1 }; + + let mut alpha = vec![0_u8; n]; + let (mut min_x, mut min_y, mut max_x, mut max_y) = (usize::MAX, usize::MAX, 0, 0); + for (i, ((a, &label), &d)) in alpha.iter_mut().zip(&labels).zip(&darkness).enumerate() { + if label == 0 || strong_pixels.get(label as usize).copied().unwrap_or(0) < min_strong { + continue; + } + // Smooth ramp from the weak to the strong threshold. + let c = ((d - weak) / (strong - weak).max(f32::EPSILON)).clamp(0.0, 1.0); + *a = (255.0 * c * c * (3.0 - 2.0 * c)).round() as u8; + if *a >= 128 { + let (x, y) = (i % width, i / width); + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + let bounds = (min_x <= max_x && min_y <= max_y).then(|| Region { x: min_x, y: min_y, width: max_x - min_x + 1, height: max_y - min_y + 1 }); + Ink { alpha, bounds } +} + +/// Where the signature is in a (downscaled) photo of the whole sheet: nearby strokes are merged, +/// blobs touching the photo border (paper edges, table, fingers) are ignored, and the blob with +/// the most ink is framed together with the other words on its line. +fn detect_region(gray: &[u8], width: usize, height: usize) -> Option { + let ink = extract_ink(gray, width, height, Options { sensitivity: DETECTION_SENSITIVITY, despeckle: true, background_radius: None }); + ink.bounds?; + let solid: Vec = ink.alpha.iter().map(|&a| if a >= 128 { 255 } else { 0 }).collect(); + let radius = ((width.max(height) as f64) * 0.03).round().max(2.0) as usize; + let merged: Vec = square_filter(&solid, width, height, radius, true).iter().map(|&v| v != 0).collect(); + let (labels, count) = label_components(&merged, width, height); + + #[derive(Clone, Copy)] + struct Blob { + ink: usize, + min_x: usize, + min_y: usize, + max_x: usize, + max_y: usize, + border: bool, + } + let mut blobs = vec![Blob { ink: 0, min_x: usize::MAX, min_y: usize::MAX, max_x: 0, max_y: 0, border: false }; count + 1]; + for (i, (&label, &s)) in labels.iter().zip(&solid).enumerate() { + let Some(blob) = blobs.get_mut(label as usize).filter(|_| label != 0) else { continue }; + let (x, y) = (i % width, i / width); + if x == 0 || y == 0 || x + 1 == width || y + 1 == height { + blob.border = true; + } + if s != 0 { + blob.ink += 1; + blob.min_x = blob.min_x.min(x); + blob.min_y = blob.min_y.min(y); + blob.max_x = blob.max_x.max(x); + blob.max_y = blob.max_y.max(y); + } + } + let candidates = || blobs.iter().skip(1).filter(|b| !b.border && b.ink > 0); + let best = *candidates().max_by_key(|b| b.ink)?; + // Signatures often have several words: add every blob with a fair amount of ink on the same line. + let span = best.max_y - best.min_y; + let (top, bottom) = (best.min_y.saturating_sub(span), best.max_y + span); + let min_ink = best.ink as f64 * DETECTION_MIN_INK_RATIO; + let mut frame = best; + for b in candidates().filter(|b| b.ink as f64 >= min_ink && b.max_y >= top && b.min_y <= bottom) { + frame.min_x = frame.min_x.min(b.min_x); + frame.min_y = frame.min_y.min(b.min_y); + frame.max_x = frame.max_x.max(b.max_x); + frame.max_y = frame.max_y.max(b.max_y); + } + let pad = radius; + let (x, y) = (frame.min_x.saturating_sub(pad), frame.min_y.saturating_sub(pad)); + Some(Region { x, y, width: (frame.max_x + 1 + pad).min(width) - x, height: (frame.max_y + 1 + pad).min(height) - y }) +} + +/// The trimmed ink in its average colour with the coverage as alpha, as PNG. +fn render(area: &RgbImage, alpha: &[u8], bounds: Region) -> Result, ScanError> { + let (w, h) = (area.width() as usize, area.height() as usize); + let x0 = bounds.x.saturating_sub(TRIM_PADDING); + let y0 = bounds.y.saturating_sub(TRIM_PADDING); + let x1 = (bounds.x + bounds.width + TRIM_PADDING).min(w); + let y1 = (bounds.y + bounds.height + TRIM_PADDING).min(h); + let (ow, oh) = (x1.saturating_sub(x0), y1.saturating_sub(y0)); + if ow == 0 || oh == 0 { + return Err(ScanError::NoInk); + } + + // One ink colour: the average of the solid strokes, so blue ink stays blue and paper tint at + // the stroke edges doesn't show. + let (mut sum, mut solid) = ([0_u64; 3], 0_u64); + for (p, &a) in area.pixels().zip(alpha) { + if a == 255 { + for (s, &c) in sum.iter_mut().zip(p.0.iter()) { + *s += u64::from(c); + } + solid += 1; + } + } + let colour = if solid == 0 { [0, 0, 0] } else { sum.map(|s| (s / solid).min(255) as u8) }; + + let mut out = image::RgbaImage::new(u32::try_from(ow).unwrap_or(1), u32::try_from(oh).unwrap_or(1)); + for (y, row) in out.rows_mut().enumerate() { + for (x, px) in row.enumerate() { + let a = alpha.get((y0 + y) * w + x0 + x).copied().unwrap_or(0); + *px = image::Rgba([colour[0], colour[1], colour[2], a]); + } + } + let mut out = DynamicImage::ImageRgba8(out); + let pixels = (ow as u64).saturating_mul(oh as u64); + if pixels > MAX_OUTPUT_PIXELS || ow > WORKING_MAX_SIDE as usize || oh > WORKING_MAX_SIDE as usize { + let scale = (MAX_OUTPUT_PIXELS as f64 / pixels as f64).sqrt().min(f64::from(WORKING_MAX_SIDE) / ow.max(oh) as f64); + let nw = ((ow as f64 * scale).floor() as u32).max(1); + let nh = ((oh as f64 * scale).floor() as u32).max(1); + out = out.resize_exact(nw, nh, image::imageops::FilterType::Triangle); + } + let mut png = Cursor::new(Vec::new()); + out.write_to(&mut png, ImageFormat::Png)?; + // A PNG larger than a saved signature may be is scaled down by `SignatureImage::from_bytes`. + Ok(png.into_inner()) +} + +#[cfg(test)] +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..6e5758a7ce --- /dev/null +++ b/crates/engine/src/stamp_preview.rs @@ -0,0 +1,83 @@ +//! A stamp while it is moved or resized: the page without it, and the stamp 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::delete_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() })) + })? + } +} 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 0076856323..9b0a0229dc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,3069 +1,9167 @@ -use std::sync::Arc; - -use super::*; - -/// A minimal `n`-page document; page `i` shows "Page i+1". -pub(crate) fn fixture(n: usize) -> Vec { - let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; - 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(" ")).into_bytes()); - objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); - 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).into_bytes()); - 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()).into_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 -} - -fn session_with(n: usize) -> (Session, DocId) { - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens"); - (s, id) -} - -#[test] -fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { - let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; - let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)])); - let mut png = std::io::Cursor::new(Vec::new()); - image.write_to(&mut png, image::ImageFormat::Png).unwrap(); - let bytes = Arc::new(png.into_inner()); - for degrees in [0, 90, 180, 270] { - let (mut session, id) = session_with(1); - session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap(); - let at = session.get(id).unwrap().info.pages[0].view_to_user(100.0, 100.0).map(f64::from); - session - .apply( - id, - Edit::AddCustomStamp { - page: 0, - rect: [at[0], at[1], at[0], at[1]], - name: "Original quadrants".into(), - file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, - author: "Test".into(), - }, - ) - .unwrap(); - let check = |session: &Session, points: [(u32, u32); 4]| { - let doc = session.get(id).unwrap(); - let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); - let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); - assert!(rendered.error.is_none(), "{:?}", rendered.error); - for ((x, y), colour) in points.into_iter().zip(colours) { - let offset = ((y * rendered.width + x) * 4) as usize; - assert_eq!(&rendered.rgba[offset..offset + 4], &colour, "page rotation {degrees}; displayed point ({x}, {y})"); - } - }; - let points = [(80, 90), (120, 90), (80, 110), (120, 110)]; - check(&session, points); - let info = session.get(id).unwrap().info.pages[0].clone(); - let rect = session.get(id).unwrap().info.annotations[0].rect; - let a = info.user_to_view(rect[0], rect[1]); - let b = info.user_to_view(rect[2], rect[3]); - let bounds = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; - for (actual, expected) in bounds.into_iter().zip([60.0, 80.0, 140.0, 120.0]) { - assert!((actual - expected).abs() < 0.0001, "displayed bounds {bounds:?}"); - } - session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap(); - check(&session, points); - let resized = info.view_rect_to_user([40.0, 70.0, 160.0, 130.0]).map(f64::from); - session.apply(id, Edit::ResizeAnnotation { page: 0, index: 0, rect: resized }).unwrap(); - let resized_points = [(70, 85), (130, 85), (70, 115), (130, 115)]; - check(&session, resized_points); - session.undo(id).unwrap(); - check(&session, points); - session.redo(id).unwrap(); - check(&session, resized_points); - let from = info.view_to_user(100.0, 100.0); - let to = info.view_to_user(110.0, 115.0); - session.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: f64::from(to[0] - from[0]), dy: f64::from(to[1] - from[1]) }).unwrap(); - check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]); - session.undo(id).unwrap(); - check(&session, resized_points); - session.redo(id).unwrap(); - check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]); - } -} - -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() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - (0..doc.info.pages.len()) - .map(|p| { - let r = r.render(RenderRequestFor::text(p)); - r.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default() - }) - .collect() -} - -/// Shorthand for building text-extraction requests. -struct RenderRequestFor; -impl RenderRequestFor { - fn text(page: usize) -> pdfcraft_render::RenderRequest { - pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() } - } -} - -#[test] -fn edits_update_view_data_and_mark_dirty() { - let (mut s, id) = session_with(3); - assert!(s.get(id).unwrap().editable()); - assert!(!s.get(id).unwrap().dirty); - s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); - let doc = s.get(id).unwrap(); - assert!(doc.dirty); - assert_eq!(doc.info.pages.len(), 2, "inspection refreshed"); - assert_eq!(doc.can_undo(), Some("Delete page")); - assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed"); -} - -#[test] -fn metadata_edit_keeps_the_renderer_without_changing_page_content() { - let (mut s, id) = session_with(1); - let request = pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }; - let render = |session: &Session| { - let doc = session.get(id).unwrap(); - doc.renderer.set_queue(vec![request]); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - loop { - if let Some(page) = doc.renderer.try_recv() { - assert!(page.error.is_none(), "{:?}", page.error); - return (doc.renderer.stats(), page.rgba); - } - assert!(std::time::Instant::now() < deadline, "page render did not finish"); - std::thread::yield_now(); - } - }; - - let (before, before_pixels) = render(&s); - assert_eq!(before.page_interpretations, 1); - s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap(); - assert_eq!(s.get(id).unwrap().info_value("Title").as_deref(), Some("Report")); - assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1, "a metadata edit keeps the renderer and its page interpretation"); - let (after, after_pixels) = render(&s); - assert_eq!(after.page_interpretations, 2, "the same renderer serves the second request, so its count continues"); - assert_eq!(before_pixels, after_pixels, "document properties do not change page pixels"); -} - -#[test] -fn metadata_edits_and_history_reuse_the_render_pool() { - let (mut s, id) = session_with(1); - let pixels = shown(&s, id, 0).rgba; - let doc = s.get(id).unwrap(); - let stats = doc.renderer.stats(); - let display = doc.display.clone(); - assert_eq!(stats.page_interpretations, 1); - - s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap(); - let doc = s.get(id).unwrap(); - assert_eq!(doc.info.title.as_deref(), Some("Report")); - assert_eq!(doc.renderer.stats(), stats); - assert!(Arc::ptr_eq(&doc.display, &display)); - assert_eq!(pixels.as_ref().len(), 200 * 300 * 4); - - s.undo(id).unwrap(); - let doc = s.get(id).unwrap(); - assert_eq!(doc.info.title, None); - assert_eq!(doc.renderer.stats(), stats); - assert!(Arc::ptr_eq(&doc.display, &display)); - - s.redo(id).unwrap(); - let doc = s.get(id).unwrap(); - assert_eq!(doc.info.title.as_deref(), Some("Report")); - assert_eq!(doc.renderer.stats(), stats); - assert!(Arc::ptr_eq(&doc.display, &display)); -} - -#[test] -fn field_and_comment_edits_still_rebuild_the_render_pool() { - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("form.pdf", None, Arc::new(scripted_form()), None).unwrap(); - let _ = shown(&s, id, 0); - assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1); - - s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); - assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 0); - let _ = shown(&s, id, 0); - assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1); - - let shape = Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] }; - s.apply( - id, - Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Test".into() }), - ) - .unwrap(); - assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 0); -} - -#[test] -fn undo_and_redo_restore_exact_states() { - let (mut s, id) = session_with(3); - let original = s.get(id).unwrap().bytes.clone(); - s.apply(id, Edit::MovePages { pages: vec![2], to: 0 }).unwrap(); - s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); - assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); - assert_eq!(s.undo(id).unwrap(), "Rotate page"); - assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0); - assert_eq!(s.undo(id).unwrap(), "Move page"); - assert_eq!(s.get(id).unwrap().bytes, original, "undoing everything returns the original bytes"); - assert_eq!(s.undo(id), Err(EditError::NothingToUndo)); - assert_eq!(s.redo(id).unwrap(), "Move page"); - assert_eq!(page_texts(&s, id), ["Page 3", "Page 1", "Page 2"]); - // A new edit clears the redo stack. - s.apply(id, Edit::InsertBlankPage { at: 0, width: 612.0, height: 792.0 }).unwrap(); - assert_eq!(s.redo(id), Err(EditError::NothingToRedo)); - assert_eq!(s.get(id).unwrap().info.pages.len(), 4); -} - -#[test] -fn failed_edit_changes_nothing() { - let (mut s, id) = session_with(2); - let before = s.get(id).unwrap().bytes.clone(); - let err = s.apply(id, Edit::DeletePages { pages: vec![0, 1] }).unwrap_err(); - assert_eq!(err, EditError::Organize(pdfcraft_organize::OrganizeError::WouldRemoveAllPages)); - let doc = s.get(id).unwrap(); - assert!(!doc.dirty); - assert_eq!(doc.can_undo(), None); - assert_eq!(doc.bytes, before); -} - -#[test] -fn save_is_incremental_stamps_mod_date_and_rebases() { - let (mut s, id) = session_with(2); - let original = s.get(id).unwrap().bytes.clone(); - s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Quarterly report".into() }).unwrap(); - let saved = s.save_bytes(id).unwrap(); - assert_eq!(&saved[..original.len()], &original[..], "incremental save keeps the original bytes"); - let text = String::from_utf8_lossy(&saved[original.len()..]); - assert!(text.contains("/ModDate (D:20231114221320Z)"), "mod date stamped: {text}"); - s.mark_saved(id, saved.clone(), Some("/tmp/out/report.pdf".into())).unwrap(); - let doc = s.get(id).unwrap(); - assert!(!doc.dirty); - assert_eq!(doc.name, "report.pdf"); - assert_eq!(doc.info_value("Title").as_deref(), Some("Quarterly report")); - assert_eq!(doc.info.title.as_deref(), Some("Quarterly report")); - // Saving again without edits writes the same bytes; a later edit appends just one revision. - assert_eq!(s.save_bytes(id).unwrap(), saved); - s.apply(id, Edit::RotatePages { pages: vec![1], degrees: -90 }).unwrap(); - let second = s.save_bytes(id).unwrap(); - assert_eq!(&second[..saved.len()], &saved[..]); - let reopened = pdfcraft_cos::Document::open(second).unwrap(); - assert_eq!(reopened.revisions().len(), 3); -} - -#[test] -fn undo_still_works_after_save() { - let (mut s, id) = session_with(2); - s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); - let saved = s.save_bytes(id).unwrap(); - s.mark_saved(id, saved, None).unwrap(); - s.undo(id).unwrap(); - let doc = s.get(id).unwrap(); - assert!(doc.dirty, "undoing past a save makes the document dirty again"); - assert_eq!(doc.info.pages.len(), 2); -} - -#[test] -fn full_save_is_readable_and_smaller_after_deletes() { - let (mut s, id) = session_with(5); - s.apply(id, Edit::DeletePages { pages: vec![0, 1, 2] }).unwrap(); - let full = s.save_full_bytes(id).unwrap(); - assert!(full.len() < s.save_bytes(id).unwrap().len()); - let mut s2 = Session::new(); - let id2 = s2.open("full.pdf", None, full, None).unwrap(); - assert_eq!(page_texts(&s2, id2), ["Page 4", "Page 5"]); -} - -#[test] -fn layer_choices_survive_edits() { - // No layers in the fixture: just make sure refresh keeps working with an empty list and - // that unknown layers are rejected. - let (mut s, id) = session_with(1); - assert!(!s.set_layer_visible(id, 0, false)); - s.apply(id, Edit::InsertBlankPage { at: 1, width: 100.0, height: 100.0 }).unwrap(); - assert_eq!(s.get(id).unwrap().info.pages.len(), 2); -} - -#[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 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)]; - let changes: Vec<_> = changes.iter().map(|&(op, n)| (op, (n, 0))).collect(); - let changed = apply_layer_state(&mut layers, &[vec![(1, 0), (2, 0)]], &changes, preserve_rb); - (changed, layers.iter().map(|l| l.visible).collect::>()) - }; - // Turning a layer on turns off the rest of its group, unless the action says not to. - assert_eq!(run(&[(On, 2)], true), (true, vec![false, true, true])); - assert_eq!(run(&[(On, 2)], false), (true, vec![true, true, true])); - // A toggle that turns a layer on does the same. - assert_eq!(run(&[(Toggle, 2)], true), (true, vec![false, true, true])); - // Turning a layer off leaves its group alone. - assert_eq!(run(&[(Off, 1)], true), (true, vec![false, false, true])); - // Each toggle sees the state the changes before it left: twice is no change. - assert_eq!(run(&[(Toggle, 3), (Toggle, 3)], true), (false, vec![true, false, true])); - // Later changes win, and groups that aren't layers are skipped. - assert_eq!(run(&[(On, 1), (On, 2), (Off, 99)], true), (true, vec![false, true, true])); - // What counts is the end state: on and back off again is no change. - assert_eq!(run(&[(On, 2), (On, 1), (Off, 99)], true), (false, vec![true, false, true])); -} - -#[test] -fn set_layer_actions_change_what_renders() { - // A red left half on layer Red (on) and a green right half on layer Green (off); the two - // 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 objs = [ - "<< /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]] >> >> >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /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 >> >> >>".into(), - format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), - "<< /Type /OCG /Name (Red) >>".into(), - "<< /Type /OCG /Name (Green) >>".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()); - let mut s = Session::new(); - let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); - // The colours at the middle of the left and right halves, as the document draws now. - let halves = |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 at = |x: usize| { - let i = (50 * p.width as usize + x) * 4; - p.rgba[i..i + 3].to_vec() - }; - (at(25), at(75)) - }; - let (red, green, white) = (vec![255, 0, 0], vec![0, 255, 0], vec![255, 255, 255]); - assert_eq!(halves(&s), (red.clone(), white.clone())); - // Showing Green hides Red, its radio-button partner. - assert!(s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true)); - let visible: Vec<_> = s.get(id).unwrap().info.layers.iter().map(|l| (l.name.as_str(), l.visible)).collect(); - assert_eq!(visible, [("Red", false), ("Green", true)]); - assert_eq!(halves(&s), (white.clone(), green)); - // Running it again changes nothing. - assert!(!s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true)); - // Turning Green off brings nothing back on. - assert!(s.set_layer_state(id, &[(LayerOp::Off, (6, 0))], true)); - assert_eq!(halves(&s), (white.clone(), white)); - assert!(!s.set_layer_state(DocId(999), &[(LayerOp::On, (5, 0))], true)); -} - -#[test] -fn edits_to_unknown_documents_are_rejected() { - let (mut s, _) = session_with(1); - assert_eq!(s.apply(DocId(999), Edit::DeletePages { pages: vec![0] }), Err(EditError::NoDocument)); - assert_eq!(s.undo(DocId(999)), Err(EditError::NoDocument)); - assert!(s.save_bytes(DocId(999)).is_err()); -} - -#[test] -fn batch_is_one_undo_step_and_all_or_nothing() { - let (mut s, id) = session_with(3); - let batch = Edit::Batch { - label: "Change properties".into(), - edits: vec![Edit::SetInfo { key: "Title".into(), value: "T".into() }, Edit::SetInfo { key: "Author".into(), value: "A".into() }], - }; - s.apply(id, batch).unwrap(); - assert_eq!(s.get(id).unwrap().info_value("Author").as_deref(), Some("A")); - assert_eq!(s.undo(id).unwrap(), "Change properties"); - assert_eq!(s.get(id).unwrap().info_value("Title"), None); - assert_eq!(s.get(id).unwrap().can_undo(), None, "one step"); - // A failing member rolls back the whole batch. - let bad = Edit::Batch { label: "x".into(), edits: vec![Edit::RotatePages { pages: vec![0], degrees: 90 }, Edit::DeletePages { pages: vec![9] }] }; - assert!(s.apply(id, bad).is_err()); - assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0); -} - -#[test] -fn insert_blank_between_pages_keeps_both_neighbours() { - let (mut s, id) = session_with(2); - s.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap(); - assert_eq!(page_texts(&s, id), ["Page 1", "", "Page 2"]); -} - -#[test] -fn combine_extract_split_and_insert_from_file() { - let (mut s, id) = session_with(3); - let other = Arc::new(fixture(2)); - // Combine: the first file then the second. - let combined = s.combine(&[("a.pdf".into(), Arc::new(fixture(3))), ("b.pdf".into(), other.clone())]).unwrap(); - let cid = s.open_new("Combined.pdf", combined).unwrap(); - assert_eq!(page_texts(&s, cid), ["Page 1", "Page 2", "Page 3", "Page 1", "Page 2"]); - assert!(s.get(cid).unwrap().dirty, "a new document starts unsaved"); - assert_eq!(s.get(cid).unwrap().info.outline.len(), 2, "one bookmark per file"); - // Extract pages 3 and 1 into a new document. - let ex = s.extract(id, &[2, 0]).unwrap(); - let eid = s.open_new("Extract.pdf", ex).unwrap(); - assert_eq!(page_texts(&s, eid), ["Page 3", "Page 1"]); - // Split every 2 pages. - let parts = s.split(id, &pdfcraft_organize::SplitBy::PageCount(2)).unwrap(); - assert_eq!(parts.iter().map(|(a, b, _)| (*a, *b)).collect::>(), [(1, 2), (3, 3)]); - // Insert pages from a file, undoably, into the open document. - s.apply(id, Edit::InsertPagesFrom { name: "b.pdf".into(), bytes: other, pages: Some(vec![1]), at: 1 }).unwrap(); - assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]); - assert_eq!(s.undo(id).unwrap(), "Insert pages from b.pdf"); - assert_eq!(page_texts(&s, id).len(), 3); - // Garbage sources fail cleanly. - let bad = s.apply(id, Edit::InsertPagesFrom { name: "junk.pdf".into(), bytes: Arc::new(b"nope".to_vec()), pages: None, at: 0 }); - assert!(matches!(bad, Err(EditError::Source(_)))); -} - -/// A minimal baseline JPEG (headers only; the data is embedded as is). -fn jpeg_bytes() -> Vec { - let mut v = vec![0xFF, 0xD8]; - v.extend_from_slice(&[0xFF, 0xE0, 0, 16, b'J', b'F', b'I', b'F', 0, 1, 1, 1, 0x01, 0x2C, 0x01, 0x2C, 0, 0]); - v.extend_from_slice(&[0xFF, 0xC0, 0, 17, 8, 0, 2, 0, 3, 3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1]); - v.extend_from_slice(&[0xFF, 0xD9]); - v -} - -#[test] -fn mixed_files_convert_and_combine_in_order() { - let mut s = Session::new(); - let pdf = Arc::new(fixture(2)); - let (kind, same) = s.convert_to_pdf("a.pdf", &pdf).unwrap(); - assert_eq!(kind, SourceKind::Pdf); - assert!(Arc::ptr_eq(&same, &pdf), "a PDF is passed through untouched"); - let (kind, image) = s.convert_to_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap(); - assert_eq!(kind, SourceKind::Image); - let (kind, text) = s.convert_to_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap(); - assert_eq!(kind, SourceKind::Text); - let combined = s.combine_ranges(&[("notes".into(), text, None), ("a".into(), pdf, Some("2".into())), ("scan".into(), image, None)]).unwrap(); - let id = s.open_new("Combined.pdf", combined).unwrap(); - assert_eq!(page_texts(&s, id), ["hello", "Page 2", ""]); - assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["notes→1", "a→2", "scan→3"]); -} - -#[test] -fn utf16_and_code_page_text_files_convert_to_their_text() { - let mut s = Session::new(); - // Notepad's "Unicode": UTF-16 little endian with a byte order mark. - let mut utf16 = vec![0xFF, 0xFE]; - utf16.extend("Grüße aus Köln".encode_utf16().flat_map(u16::to_le_bytes)); - let (_, pdf) = s.convert_to_pdf("notes.txt", &Arc::new(utf16)).unwrap(); - let id = s.open_new("notes.pdf", pdf).unwrap(); - assert_eq!(page_texts(&s, id), ["Grüße aus Köln"]); - // A UTF-8 byte order mark is not text; Windows-1252 bytes are not replacement characters. - let (_, pdf) = s.convert_to_pdf("menu.txt", &Arc::new(b"\xEF\xBB\xBFCaf\xC3\xA9".to_vec())).unwrap(); - let id = s.open_new("menu.pdf", pdf).unwrap(); - assert_eq!(page_texts(&s, id), ["Café"]); - let (_, pdf) = s.convert_to_pdf("old.txt", &Arc::new(b"Cr\xE8me br\xFBl\xE9e".to_vec())).unwrap(); - let id = s.open_new("old.pdf", pdf).unwrap(); - assert_eq!(page_texts(&s, id), ["Crème brûlée"]); -} - -#[test] -fn a_file_split_around_another_combines_in_order_with_one_bookmark() { - let mut s = Session::new(); - let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2))); - // a's pages 1–2, then b, then a's page 3: one file (group 7) split around another. - let sources: Vec = vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a, Some("3".into()))]; - let combined = s.combine_grouped(&sources, &[7, 9, 7], &[]).unwrap(); - let id = s.open_new("Combined.pdf", combined).unwrap(); - assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]); - assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["a→1", "b→3"], "one bookmark for a, at its first page"); - // Without groups each source is a file of its own, as combine_ranges. - let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2))); - let sources: Vec = vec![("a".into(), a.clone(), Some("1".into())), ("b".into(), b, None), ("a".into(), a, Some("3".into()))]; - let combined = s.combine_grouped(&sources, &[], &[]).unwrap(); - let id = s.open_new("Combined 2.pdf", combined).unwrap(); - assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["a→1", "b→2", "a→4"]); - // A bad range is refused with the file's name. - let bad: Vec = vec![("a".into(), Arc::new(fixture(1)), Some("9".into()))]; - assert!(matches!(s.combine_grouped(&bad, &[1], &[]), Err(EditError::Print(e)) if e.starts_with("a:"))); -} - -#[test] -fn a_page_with_a_form_field_shown_twice_keeps_one_field() { - // Regression (found by review): the repeated page copied the whole field again, so the - // result had two fields named "shared", filled in separately. - use pdfcraft_cos::{Dict, Document, Object, PdfString}; - let mut doc = Document::open(Arc::new(fixture(2))).unwrap(); - let pages: Vec<_> = pdfcraft_organize::pages(&doc).unwrap().into_iter().map(|p| p.obj).collect(); - let field = doc.add(Object::Null); - let widgets: Vec<_> = pages - .iter() - .map(|p| { - let mut w = Dict::new(); - w.set(b"Type".to_vec(), Object::name("Annot")); - w.set(b"Subtype".to_vec(), Object::name("Widget")); - w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(100), Object::Int(20)])); - w.set(b"Parent".to_vec(), Object::Ref(field)); - w.set(b"P".to_vec(), Object::Ref(*p)); - doc.add(w) - }) - .collect(); - let mut f = Dict::new(); - f.set(b"FT".to_vec(), Object::name("Tx")); - f.set(b"T".to_vec(), Object::String(PdfString::text("shared"))); - f.set(b"V".to_vec(), Object::String(PdfString::text("Ada"))); - f.set(b"Kids".to_vec(), Object::Array(widgets.iter().copied().map(Object::Ref).collect())); - doc.set(field, Object::Dict(f)); - for (p, w) in pages.iter().zip(&widgets) { - doc.update_dict(*p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(*w)]))).unwrap(); - } - let mut form = Dict::new(); - form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(field)])); - doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap(); - let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); - // Pages 1, 2, then page 1 again: one file split around nothing but itself. - let s = Session::new(); - let sources: Vec = vec![("form".into(), bytes.clone(), Some("1-2".into())), ("form".into(), bytes, Some("1".into()))]; - let out = s.combine_grouped(&sources, &[1, 1], &[]).unwrap(); - let mut out = Document::open(out).unwrap(); - pdfcraft_forms::set_value(&mut out, "shared", &pdfcraft_forms::FieldValue::Text("Grace".into())).unwrap(); - let fields = pdfcraft_forms::fields(&out); - assert!( - !fields.is_empty() && fields.iter().all(|f| f.name == "shared" && f.value == ["Grace"]), - "one field, one value: {:?}", - fields.iter().map(|f| (&f.name, &f.value)).collect::>() - ); - let catalog = out.get(out.root().unwrap()).as_dict().cloned().unwrap(); - let form = out.resolve(catalog.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); - let listed = form.get(b"Fields").and_then(|f| f.as_array()).cloned().unwrap(); - assert_eq!(listed.len(), 1, "the field isn't copied again for the repeated page"); - // Its widgets: one on each of the three pages, each belonging to its own page. - let out_pages: Vec<_> = pdfcraft_organize::pages(&out).unwrap().into_iter().map(|p| p.obj).collect(); - let kids = out.resolve(&listed[0]).as_dict().and_then(|f| f.get(b"Kids").and_then(|k| k.as_array()).cloned()).unwrap(); - assert_eq!(kids.len(), 3); - let mut on: Vec<_> = kids.iter().map(|k| out.resolve(k).as_dict().and_then(|w| w.reference(b"P")).unwrap()).collect(); - on.sort(); - let mut expected = out_pages.clone(); - expected.sort(); - assert_eq!(on, expected, "a widget per page, on its own page"); -} - -#[test] -fn files_that_cannot_be_converted_are_refused_clearly() { - let s = Session::new(); - let err = s.convert_to_pdf("report.zip", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); - assert_eq!( - err, - EditError::Source("report.zip: this file type can't be converted; use a PDF, an image, or a .txt, .csv, .md or .eml file".into()) - ); - // Office and HTML files need the external converters, which are off unless the user turns them on. - for name in ["report.docx", "page.html"] { - let err = s.convert_to_pdf(name, &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); - assert!( - matches!(&err, EditError::Source(m) if m.starts_with(name) && m.contains("can't be converted") && m.contains("External converters")), - "{err}" - ); - } - assert!(matches!(s.convert_to_pdf("empty", &Arc::new(Vec::new())), Err(EditError::Source(_)))); - // Damaged inputs of a known type fail with an error, not a panic. - assert!(s.convert_to_pdf("bad.png", &Arc::new(b"\x89PNG\r\n\x1a\nnope".to_vec())).is_err()); - assert!(s.convert_to_pdf("bad.jpg", &Arc::new(vec![0xFF, 0xD8, 0xFF])).is_err()); - assert!(s.convert_to_pdf("bad.pdf", &Arc::new(b"%PDF-1.7 nope".to_vec())).is_err()); - let err = s.convert_to_pdf("locked.pdf", &protected("pw", "o", -1)).unwrap_err(); - assert_eq!(err, EditError::Source("locked.pdf: it is password-protected".into())); -} - -fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { - let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); - doc.set_encryption(&pdfcraft_cos::NewEncryption { - algorithm: pdfcraft_cos::Algorithm::Aes256, - user_password: user, - owner_password: owner, - permissions, - encrypt_metadata: true, - seed: [7; 32], - }) - .unwrap(); - Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) -} - -#[test] -fn encrypted_documents_open_edit_and_save_encrypted() { - let bytes = protected("pw", "owner", -1); - let mut s = Session::new(); - assert!(s.open("x.pdf", None, bytes.clone(), None).is_err(), "needs a password"); - let id = s.open("x.pdf", None, bytes.clone(), Some("pw")).unwrap(); - assert!(s.get(id).unwrap().editable(), "{:?}", s.get(id).unwrap().read_only_reason); - assert_eq!(s.get(id).unwrap().security_summary().unwrap().method, "AES, 256-bit"); - assert!(s.get(id).unwrap().info.encrypted, "reported as encrypted"); - s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); - let saved = s.save_bytes(id).unwrap(); - assert_eq!(&saved[..bytes.len()], &bytes[..], "incremental"); - assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "still protected after saving"); - let mut s2 = Session::new(); - let id2 = s2.open("x.pdf", None, saved, Some("pw")).unwrap(); - assert_eq!(s2.get(id2).unwrap().info.pages[0].rotation, 90); -} - -#[test] -fn restricted_documents_refuse_changes_unless_opened_by_the_owner() { - let bytes = protected("", "owner", 0b0100); // print only, no password to open - let mut s = Session::new(); - let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); - let d = s.get(id).unwrap(); - assert!(!d.allows_assembly() && !d.allows_modification()); - // Print-only permissions clear the copy/extract bit (#568): copying text is refused. - assert!(!d.allows_copy(), "copy must be blocked when the extract bit is clear"); - let summary = d.security_summary().unwrap(); - assert!(!summary.owner && summary.permissions.print() && !summary.permissions.copy()); - assert_eq!(s.apply(id, Edit::DeletePages { pages: vec![0] }), Err(EditError::NotPermitted("page changes"))); - assert_eq!(s.apply(id, Edit::SetInfo { key: "Title".into(), value: "x".into() }), Err(EditError::NotPermitted("changes to the document"))); - assert!(matches!(s.extract(id, &[0]), Err(EditError::NotPermitted(_)))); - assert!(!s.get(id).unwrap().dirty); - // The owner password lifts the restrictions. - let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); - assert!(s.get(oid).unwrap().allows_assembly()); - assert!(s.get(oid).unwrap().allows_copy(), "the owner password lifts the copy restriction"); - // An unencrypted document imposes no copy restriction. - let plain = s.open("plain.pdf", None, Arc::new(fixture(1)), None).unwrap(); - assert!(s.get(plain).unwrap().allows_copy()); - s.apply(oid, Edit::DeletePages { pages: vec![0] }).unwrap(); -} - -/// The Document Open password matrix (#603) for every algorithm: no password and an explicit -/// empty password behave the same, a non-empty user password always prompts even when the owner -/// password is empty, and an empty password that is both user and owner opens as the user, so -/// the document's restrictions still apply. -#[test] -fn empty_passwords_never_lift_restrictions_or_skip_the_open_prompt() { - use pdfcraft_cos::Algorithm::{Aes128, Aes256, Rc4_40, Rc4_128}; - let make = |algorithm, user, owner| { - let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); - doc.set_encryption(&pdfcraft_cos::NewEncryption { - algorithm, - user_password: user, - owner_password: owner, - permissions: 0b0100, - encrypt_metadata: true, - seed: [3; 32], - }) - .unwrap(); - Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) - }; - for alg in [Rc4_40, Rc4_128, Aes128, Aes256] { - let mut s = Session::new(); - // Both empty: opens without a prompt, as the user (print only, no copying). - let both = make(alg, "", ""); - for pw in [None, Some("")] { - let id = s.open("both.pdf", None, both.clone(), pw).unwrap_or_else(|e| panic!("{alg:?} {pw:?}: {e}")); - let d = s.get(id).unwrap(); - assert!(!d.allows_copy() && !d.allows_assembly(), "{alg:?} {pw:?}: the empty password must not open as owner"); - } - // A Document Open password with an empty owner password: always prompts. - let open_pw = make(alg, "secret", ""); - for pw in [None, Some("")] { - assert!(s.open("open.pdf", None, open_pw.clone(), pw).is_err(), "{alg:?} {pw:?}: must ask for the Document Open password"); - } - assert!(s.open("open.pdf", None, open_pw, Some("secret")).is_ok(), "{alg:?}"); - // An ordinary restricted file: opens as the user without a prompt. - let id = s.open("r.pdf", None, make(alg, "", "owner"), None).unwrap(); - assert!(!s.get(id).unwrap().allows_copy(), "{alg:?}"); - } -} - -#[test] -fn combining_protected_files_is_refused_clearly() { - let s = Session::new(); - let err = s.combine(&[("a.pdf".into(), protected("pw", "o", -1)), ("b.pdf".into(), Arc::new(fixture(1)))]).unwrap_err(); - assert_eq!(err, EditError::Source("a.pdf: it is password-protected".into())); - let err = s.combine(&[("c.pdf".into(), protected("", "o", 0b0100))]).unwrap_err(); - assert!(matches!(err, EditError::Source(m) if m.contains("don't allow copying pages"))); -} - -#[test] -fn owner_password_of_older_revisions_opens_the_viewer_too() { - for alg in [pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128] { - 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 mut s = Session::new(); - let id = s.open("x.pdf", None, bytes, Some("o")).unwrap_or_else(|e| panic!("{alg:?}: {e}")); - let d = s.get(id).unwrap(); - assert!(d.security_summary().unwrap().owner && d.allows_modification(), "{alg:?}"); - assert_eq!(page_texts(&s, id), ["Page 1"], "{alg:?}: the renderer reads it"); - } -} - -#[test] -fn autosave_snapshots_only_changed_documents() { - let (mut s, id) = session_with(2); - assert!(s.autosave_snapshots().is_empty(), "clean documents are not snapshotted"); - s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); - let snaps = s.autosave_snapshots(); - 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"); - s.undo(id).unwrap(); - assert_eq!(s.autosave_snapshots().len(), 1, "undo is a change too"); - let saved = s.save_bytes(id).unwrap(); - s.mark_saved(id, saved, None).unwrap(); - assert!(s.autosave_snapshots().is_empty(), "saved documents need no recovery"); -} - -#[test] -fn autosave_snapshots_a_new_unsaved_document_once() { - let (mut s, clean) = session_with(1); - let id = s.open_new("Untitled.pdf", Arc::new(fixture(1))).unwrap(); - let snaps = s.autosave_snapshots(); - assert_eq!(snaps.len(), 1, "the new document is unsaved work"); - assert_eq!(snaps[0].doc, id); - assert!(snaps.iter().all(|x| x.doc != clean), "the clean document stays out"); - assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot"); - s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); - assert_eq!(s.autosave_snapshots().len(), 1, "a later edit is snapshotted again"); -} - -#[test] -fn recovered_documents_reopen_unsaved_at_their_original_path() { - let (mut s, id) = session_with(2); - s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); - let snap = s.autosave_snapshots().remove(0); - // A new session after a crash. - let mut s2 = Session::new(); - let rid = s2.open(snap.name.clone(), None, snap.bytes.clone(), None).unwrap(); - s2.mark_recovered(rid, Some("/docs/report.pdf".into())); - let d = s2.get(rid).unwrap(); - assert!(d.dirty); - assert_eq!(d.path.as_deref(), Some("/docs/report.pdf")); - assert_eq!(d.info.pages.len(), 1, "the edit survived"); - assert!(s2.autosave_snapshots().is_empty(), "already in the recovery store"); -} - -fn outline_titles(items: &[pdfcraft_render::OutlineItem]) -> Vec { - items - .iter() - .map(|o| { - if o.children.is_empty() { - format!("{}→{}", o.title, o.page.map_or(0, |p| p + 1)) - } else { - format!("{}→{}[{}]", o.title, o.page.map_or(0, |p| p + 1), outline_titles(&o.children).join(",")) - } - }) - .collect() -} - -#[test] -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(); - // 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(); - s.apply(id, Edit::RenameBookmark { path: vec![2], title: "Finish".into() }).unwrap(); - s.apply(id, Edit::SetBookmarkPage { path: vec![1], page: 1 }).unwrap(); - assert_eq!(titles(&s), ["Détail→2", "Start→2", "Finish→3"]); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Set bookmark destination")); - s.undo(id).unwrap(); - assert_eq!(titles(&s), ["Détail→2", "Start→1", "Finish→3"]); - s.apply(id, Edit::DeleteBookmark { path: vec![0] }).unwrap(); - assert!(matches!(s.apply(id, Edit::DeleteBookmark { path: vec![7] }), Err(EditError::Bookmark(_)))); - - let saved = s.save_bytes(id).unwrap(); - let mut again = Session::new(); - let id2 = again.open("again.pdf", None, saved, None).unwrap(); - assert_eq!(outline_titles(&again.get(id2).unwrap().info.outline), ["Start→1", "Finish→3"]); -} - -/// Two pages tagged with an H1 on the first and an H2 on the second. -fn tagged_headings_fixture() -> Vec { - let objs = [ - "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R >>", - "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] /Resources << /Font << /F1 6 0 R >> >> >>", - "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>", - "<< /Type /Page /Parent 2 0 R >>", - "<< /Length 52 >>\nstream\n/H1 << /MCID 0 >> BDC BT /F1 12 Tf (Intro) Tj ET EMC\nendstream", - "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", - "<< /Type /StructTreeRoot /K [8 0 R 9 0 R] >>", - "<< /S /H1 /Pg 3 0 R /K 0 >>", - "<< /S /H2 /Pg 4 0 R /ActualText (Details) >>", - ]; - 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 -} - -#[test] -fn bookmarks_from_structure_nest_the_headings_and_undo() { - let mut s = Session::new(); - let id = s.open("tagged.pdf", None, Arc::new(tagged_headings_fixture()), None).unwrap(); - s.apply(id, Edit::BookmarksFromStructure).unwrap(); - assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Intro→1[Details→2]]"]); - assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from structure")); - s.undo(id).unwrap(); - assert!(s.get(id).unwrap().info.outline.is_empty()); - - // Untagged: a clear error and nothing changes. - let (mut s, id) = session_with(2); - let err = s.apply(id, Edit::BookmarksFromStructure).unwrap_err(); - assert_eq!(err.to_string(), "this document has no tagged headings to make bookmarks from"); - assert!(s.get(id).unwrap().info.outline.is_empty()); -} - -#[test] -fn number_pages_shows_in_the_viewer_and_undoes() { - let (mut s, id) = session_with(5); - let labels = |s: &Session| s.get(id).unwrap().info.pages.iter().map(|p| p.label.clone()).collect::>(); - use pdfcraft_organize::LabelStyle; - s.apply(id, Edit::NumberPages { from: 0, to: 1, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 }).unwrap(); - s.apply(id, Edit::NumberPages { from: 2, to: 4, style: LabelStyle::Decimal, prefix: "§".into(), first: 10 }).unwrap(); - // The inspector (lopdf-based, independent) formats them the same way. - assert_eq!(labels(&s), ["i", "ii", "§10", "§11", "§12"]); - s.undo(id).unwrap(); - assert_eq!(labels(&s), ["i", "ii", "3", "4", "5"]); - assert!(s.apply(id, Edit::NumberPages { from: 3, to: 9, style: LabelStyle::Decimal, prefix: String::new(), first: 1 }).is_err()); -} - -/// RGBA of the pixel at PDF point (x, y) on `page`, rendered at 1 px/pt (page height 300). -fn pixel(s: &Session, id: DocId, page: usize, x: u32, y: u32) -> [u8; 4] { - let doc = s.get(id).unwrap(); - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); - let out = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }); - assert!(out.error.is_none(), "{:?}", out.error); - let i = (((300 - y) * out.width + x) * 4) as usize; - out.rgba[i..i + 4].try_into().unwrap() -} - -fn rect_comment(page: usize, rect: [f64; 4]) -> Edit { - Edit::AddAnnotation(NewAnnotation { - page, - shape: Shape::Rectangle { rect }, - style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) }, - contents: "Look here".into(), - author: "Reviewer".into(), - }) -} - -#[test] -fn comments_are_added_drawn_threaded_and_undone() { - let (mut s, id) = session_with(2); - assert_eq!(pixel(&s, id, 1, 100, 100), [255, 255, 255, 255]); - s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap(); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Add rectangle")); - let px = pixel(&s, id, 1, 100, 100); - assert!(px[0] > 200 && px[1] < 40 && px[2] < 40, "the rectangle is drawn: {px:?}"); - let a = &s.get(id).unwrap().info.annotations; - assert_eq!(a.len(), 1); - assert_eq!((a[0].page, a[0].index, a[0].author.as_deref(), a[0].contents.as_deref()), (1, 0, Some("Reviewer"), Some("Look here"))); - assert_eq!(a[0].modified.as_deref().map(|m| m.contains("2023")), Some(true), "dated by the session clock"); - let nm = a[0].name.clone().expect("has an /NM"); - assert_eq!(nm.len(), 36); - - s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Done".into(), author: "Ada".into() }).unwrap(); - s.apply(id, Edit::SetAnnotationStatus { page: 1, index: 0, state: ReviewState::Completed, author: "Ada".into() }).unwrap(); - let a = &s.get(id).unwrap().info.annotations; - assert_eq!(a.len(), 3); - assert!(a.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).count() == 2); - assert!(a.iter().any(|r| r.state.as_deref() == Some("Completed"))); - assert_ne!(a[1].name, a[2].name, "every comment gets its own id"); - - s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 50.0, dy: 0.0 }).unwrap(); - assert_eq!(pixel(&s, id, 1, 100, 100), [255, 255, 255, 255]); - let px = pixel(&s, id, 1, 150, 100); - assert!(px[0] > 200 && px[1] < 40, "moved: {px:?}"); - - s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).unwrap(); - assert!(s.get(id).unwrap().info.annotations.is_empty(), "replies go with their parent"); - for _ in 0..4 { - s.undo(id).unwrap(); - } - assert_eq!(s.get(id).unwrap().info.annotations.len(), 1); - let px = pixel(&s, id, 1, 100, 100); - assert!(px[0] > 200 && px[1] < 40, "back where it was: {px:?}"); -} - -#[test] -fn highlight_multiplies_over_text() { - let (mut s, id) = session_with(1); - // "Page 1" is drawn at 20,150 in 24 pt Helvetica. - let quad = [18.0, 172.0, 100.0, 172.0, 18.0, 145.0, 100.0, 145.0]; - s.apply( - id, - Edit::AddAnnotation(NewAnnotation { - page: 0, - shape: Shape::TextMarkup { kind: Markup::Highlight, quads: vec![quad] }, - style: Style { color: [1.0, 1.0, 0.0], ..Style::default() }, - contents: String::new(), - author: String::new(), - }), - ) - .unwrap(); - let a = &s.get(id).unwrap().info.annotations[0]; - assert_eq!(a.subtype, "Highlight"); - assert_eq!(a.quads, vec![quad.map(|v| v as f32)]); - // Background inside the quad turns yellow; text stays dark (multiply). - let bg = pixel(&s, id, 0, 19, 170); - assert!(bg[0] > 240 && bg[1] > 240 && bg[2] < 30, "{bg:?}"); -} - -#[test] -fn comment_permission_is_enforced() { - let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); - // Owner "own", empty user password, everything allowed except commenting (bit 6). - let params = pdfcraft_cos::NewEncryption { - algorithm: pdfcraft_cos::Algorithm::Aes256, - user_password: "", - owner_password: "own", - permissions: !(1 << 5), - encrypt_metadata: true, - seed: [7; 32], - }; - cos.set_encryption(¶ms).unwrap(); - let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap(); - let mut s = Session::new(); - let id = s.open("locked.pdf", None, Arc::new(bytes), None).unwrap(); - let err = s.apply(id, rect_comment(0, [10.0, 10.0, 50.0, 50.0])).unwrap_err(); - assert_eq!(err, EditError::NotPermitted("comments")); -} - -#[test] -fn saving_a_password_protected_document_rebases_on_it() { - let bytes = protected("pw", "owner", -1); - let mut s = Session::new(); - let id = s.open("x.pdf", None, bytes, Some("pw")).unwrap(); - s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); - let saved = s.save_bytes(id).unwrap(); - s.mark_saved(id, saved.clone(), None).expect("rebases on the encrypted file"); - assert!(!s.get(id).unwrap().dirty); - s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap(); - let again = s.save_bytes(id).unwrap(); - assert_eq!(&again[..saved.len()], &saved[..], "the next save appends"); -} - -fn protection(open: Option<&str>, perms: Option<&str>) -> Protection { - Protection { open_password: open.map(Into::into), permissions_password: perms.map(Into::into), ..Protection::default() } -} - -#[test] -fn protect_with_an_open_password_then_save_reopen_and_undo() { - let (mut s, id) = session_with(2); - s.apply(id, Edit::Protect(protection(Some("secret"), None))).unwrap(); - let d = s.get(id).unwrap(); - assert!(d.info.encrypted, "the working file is encrypted: {:?}", d.info.warnings); - assert_eq!(d.security_summary().unwrap().method, "AES, 256-bit"); - assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"], "still viewable in this session"); - let saved = s.save_bytes(id).unwrap(); - assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "needs the password"); - s.mark_saved(id, saved.clone(), None).unwrap(); - // Further edits keep working and saving stays encrypted. - s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); - let again = s.save_bytes(id).unwrap(); - let mut s2 = Session::new(); - assert!(s2.open("p.pdf", None, again.clone(), None).is_err()); - let id2 = s2.open("p.pdf", None, again, Some("secret")).unwrap(); - assert_eq!(s2.get(id2).unwrap().info.pages[0].rotation, 90); - // Nothing is restricted without a permissions password: the opener may remove security. - assert!(s2.get(id2).unwrap().allows_security_change()); - s2.apply(id2, Edit::RemoveProtection).unwrap(); - let plain = s2.save_bytes(id2).unwrap(); - assert!(pdfcraft_cos::Document::open(plain).is_ok()); -} - -#[test] -fn permissions_password_restricts_others_but_not_this_session() { - let (mut s, id) = session_with(1); - let p = Protection { printing: Printing::Low, changes: Changes::CommentFillSign, copy: false, ..protection(None, Some("boss")) }; - s.apply(id, Edit::Protect(p.clone())).unwrap(); - assert!(s.get(id).unwrap().allows_modification(), "the author keeps full rights"); - let saved = s.save_bytes(id).unwrap(); - s.mark_saved(id, saved.clone(), None).unwrap(); - assert!(s.get(id).unwrap().allows_modification(), "…also after saving (re-opened as owner)"); - // Someone else opens it without a password: restricted as chosen. - let mut s2 = Session::new(); - let id2 = s2.open("r.pdf", None, saved.clone(), None).unwrap(); - let perm = s2.get(id2).unwrap().permissions().unwrap(); - assert!(perm.print() && !perm.print_high_quality() && perm.annotate() && perm.fill_forms() && !perm.copy() && !perm.modify()); - assert!(perm.extract_for_accessibility()); - assert!(!s2.get(id2).unwrap().allows_security_change()); - assert_eq!(s2.apply(id2, Edit::RemoveProtection), Err(EditError::NotPermitted("changing security"))); - assert!(s2.get(id2).unwrap().allows_annotation(), "commenting was allowed"); - // The owner password lifts everything. - let id3 = s2.open("r.pdf", None, saved, Some("boss")).unwrap(); - assert!(s2.get(id3).unwrap().allows_security_change()); -} - -#[test] -fn combine_source_check_says_why_a_file_cannot_be_combined() { - let (mut s, id) = session_with(1); - let plain = s.get(id).unwrap().bytes.clone(); - assert_eq!(crate::combine_source_check(&plain, None), Ok(())); - // Page assembly withheld (Changes::None) without a password to open it. - let p = Protection { changes: Changes::None, ..protection(None, Some("boss")) }; - s.apply(id, Edit::Protect(p)).unwrap(); - let locked = s.save_bytes(id).unwrap(); - assert_eq!(crate::combine_source_check(&locked, None), Err(crate::SourceProblem::NotPermitted)); - let (mut s, id) = session_with(1); - s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap(); - let secret = s.save_bytes(id).unwrap(); - assert_eq!(crate::combine_source_check(&secret, None), Err(crate::SourceProblem::Password)); - assert_eq!(crate::combine_source_check(&secret, Some("nope")), Err(crate::SourceProblem::WrongPassword)); - // Its open password reads it, but its permissions withhold assembling pages: the owner's - // password allows it. - assert_eq!(crate::combine_source_check(&secret, Some("pw")), Err(crate::SourceProblem::NotPermitted)); - assert_eq!(crate::combine_source_check(&secret, Some("owner")), Ok(())); - // The permissions password lifts the restriction on copying pages. - assert_eq!(crate::combine_source_check(&locked, Some("boss")), Ok(())); - // Combining with the passwords: the result opens without any. - let s = Session::new(); - let sources = vec![("secret".to_string(), secret, None), ("locked".to_string(), locked, None)]; - assert!(s.combine_ranges(&sources).is_err(), "no passwords, no combining"); - let out = s.combine_unlocked(&sources, &[Some("owner"), Some("boss")]).unwrap(); - let combined = pdfcraft_cos::Document::open(out).unwrap(); - assert!(combined.permissions().is_none(), "the combined file is not encrypted"); - assert_eq!(pdfcraft_organize::page_count(&combined).unwrap(), 2); - assert!(matches!(crate::combine_source_check(&Arc::new(b"not a pdf".to_vec()), None), Err(crate::SourceProblem::Unreadable(_)))); -} - -#[test] -fn protection_is_validated_undoable_and_never_logged() { - let (mut s, id) = session_with(1); - assert!(matches!(s.apply(id, Edit::Protect(protection(None, None))), Err(EditError::Protection(_)))); - assert!(matches!(s.apply(id, Edit::Protect(protection(Some("same"), Some("same")))), Err(EditError::Protection(_)))); - let rc4 = Protection { algorithm: pdfcraft_cos::Algorithm::Rc4_128, ..protection(Some("pässword"), None) }; - assert!(matches!(s.apply(id, Edit::Protect(rc4)), Err(EditError::Protection(_)))); - assert!(!format!("{:?}", Edit::Protect(protection(Some("hunter2"), Some("x")))).contains("hunter2")); - s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap(); - assert!(s.get(id).unwrap().info.encrypted); - s.undo(id).unwrap(); - assert!(!s.get(id).unwrap().info.encrypted, "undo removes the pending protection"); - assert!(s.get(id).unwrap().security_summary().is_none()); - s.redo(id).unwrap(); - assert!(s.get(id).unwrap().info.encrypted); - assert_eq!(page_texts(&s, id), ["Page 1"]); -} - -/// A one-page form: a text field and a check box (with appearance states). -fn form_fixture() -> Vec { - let objs = [ - "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>", - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", - "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", - "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 250 180 270] /P 3 0 R /MK << /BC [0 0 0] >> >>", - "<< /Type /Annot /Subtype /Widget /FT /Btn /T (ok) /V /Off /AS /Off /Rect [20 200 35 215] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>", - "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", - "<< /Length 0 >>\nstream\n\nendstream", - ]; - 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 -} - -#[test] -fn filling_a_form_shows_saves_and_undoes() { - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); - assert_eq!(s.get(id).unwrap().form.len(), 2); - s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada Lovelace".into()) }).unwrap(); - s.apply(id, Edit::SetFieldValue { name: "ok".into(), value: FieldValue::Check(true) }).unwrap(); - let d = s.get(id).unwrap(); - assert_eq!(d.can_undo(), Some("Fill in ok")); - assert_eq!(d.form[0].value, ["Ada Lovelace"]); - assert_eq!(d.form[1].value, ["Yes"]); - assert_eq!(page_texts(&s, id), ["Ada Lovelace"], "the new appearance shows the text"); - let err = s.apply(id, Edit::SetFieldValue { name: "missing".into(), value: FieldValue::Text("x".into()) }).unwrap_err(); - assert_eq!(err.to_string(), "there is no field named \"missing\""); - s.apply(id, Edit::ResetForm { names: None }).unwrap(); - assert!(s.get(id).unwrap().form.iter().all(|f| f.value.is_empty())); - s.undo(id).unwrap(); - assert_eq!(s.get(id).unwrap().form[0].value, ["Ada Lovelace"]); - let saved = s.save_bytes(id).unwrap(); - let mut s2 = Session::new(); - let id2 = s2.open("f.pdf", None, saved, None).unwrap(); - assert_eq!(s2.get(id2).unwrap().form[1].value, ["Yes"]); -} - -#[test] -fn comment_edits_refresh_the_list_exactly_as_a_full_inspection_would() { - let (mut s, id) = session_with(3); - let add = |page: usize, shape: Shape, contents: &str| { - Edit::AddAnnotation(NewAnnotation { - page, - shape, - style: Style::default_for(&Shape::Rectangle { rect: [0.0; 4] }), - contents: contents.into(), - author: "Ada".into(), - }) - }; - s.apply(id, add(0, Shape::Rectangle { rect: [10.0, 10.0, 60.0, 60.0] }, "box")).unwrap(); - s.apply(id, add(1, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 170.0, 90.0, 170.0, 20.0, 150.0, 90.0, 150.0]] }, "")) - .unwrap(); - s.apply(id, add(1, Shape::TextBox { rect: [10.0, 200.0, 150.0, 240.0], font_size: 11.0 }, "Text box")).unwrap(); - s.apply(id, add(2, Shape::Note { at: [100.0, 280.0], icon: NoteIcon::Comment }, "Sticky")).unwrap(); - s.apply(id, Edit::ReplyToAnnotation { page: 2, index: 0, text: "Reply".into(), author: "Bob".into() }).unwrap(); - s.apply(id, Edit::SetAnnotationStatus { page: 2, index: 0, state: ReviewState::Accepted, author: "Bob".into() }).unwrap(); - s.apply(id, Edit::MarkAnnotation { page: 2, index: 0, marked: true, author: "Bob".into() }).unwrap(); - s.apply(id, Edit::LockAnnotation { page: 1, index: 1, locked: true }).unwrap(); - s.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: 5.0, dy: 5.0 }).unwrap(); - s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).unwrap(); - s.undo(id).unwrap(); - let d = s.get(id).unwrap(); - let full = pdfcraft_render::inspect(d.bytes.clone(), None).unwrap(); - assert_eq!(format!("{:?}", d.info.annotations), format!("{:?}", full.annotations)); - assert_eq!(d.info.file_size, full.file_size); - assert_eq!(d.info.annotations.len(), 7); - assert!(d.info.annotations.iter().any(|a| a.locked)); -} - -#[test] -fn form_edits_refresh_field_values_without_a_full_inspection() { - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); - s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada".into()) }).unwrap(); - s.apply(id, Edit::SetFieldValue { name: "ok".into(), value: FieldValue::Check(true) }).unwrap(); - let d = s.get(id).unwrap(); - let full = pdfcraft_render::inspect(d.bytes.clone(), None).unwrap(); - let values = |fs: &[pdfcraft_render::Field]| fs.iter().map(|f| (f.name.clone(), f.value.clone())).collect::>(); - assert_eq!(values(&d.info.fields), values(&full.fields)); - assert_eq!(values(&d.info.fields), [("name".to_string(), Some("Ada".to_string())), ("ok".to_string(), Some("Yes".to_string()))]); -} - -#[test] -fn preparing_a_form_adds_renames_and_deletes_fields() { - let (mut s, id) = session_with(1); - assert!(s.get(id).unwrap().form.is_empty()); - let add = |kind: NewField, y: f64| Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: None }; - s.apply(id, add(NewField::Text { multiline: false }, 250.0)).unwrap(); - s.apply(id, add(NewField::CheckBox, 200.0)).unwrap(); - s.apply(id, add(NewField::Text { multiline: false }, 150.0)).unwrap(); - let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); - assert_eq!(names(&s), ["Text1", "Check Box1", "Text2"]); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Add field")); - let props = FieldProps { name: Some("email".into()), tooltip: Some("Your e-mail".into()), required: Some(true), ..Default::default() }; - s.apply(id, Edit::SetFieldProps { name: "Text2".into(), props: Box::new(props) }).unwrap(); - s.apply(id, Edit::DeleteField { name: "Check Box1".into() }).unwrap(); - assert_eq!(names(&s), ["Text1", "email"]); - s.apply(id, Edit::SetFieldValue { name: "email".into(), value: FieldValue::Text("ada@example.org".into()) }).unwrap(); - let saved = s.save_bytes(id).unwrap(); - let mut s2 = Session::new(); - let id2 = s2.open("f.pdf", None, saved, None).unwrap(); - let d = s2.get(id2).unwrap(); - assert_eq!(d.form.iter().map(|f| f.name.as_str()).collect::>(), ["Text1", "email"]); - assert_eq!(d.form[1].value, ["ada@example.org"]); - assert!(page_texts(&s2, id2).iter().any(|t| t.contains("ada@example.org"))); - s.undo(id).unwrap(); - s.undo(id).unwrap(); - assert_eq!(names(&s), ["Text1", "Check Box1", "email"]); -} - -#[test] -fn redaction_marks_apply_for_good_and_undo() { - let (mut s, id) = session_with(2); - // "Page 1" at 24 pt from x 20: the "1" starts near x 82.7 (Helvetica widths). - let shape = - Shape::Redact { quads: vec![pdfcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])], overlay: String::new(), look: Default::default() }; - let mark = - Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() }); - s.apply(id, mark).unwrap(); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Add redaction mark")); - assert_eq!(s.get(id).unwrap().redaction_marks(), 1); - assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"], "marking alone changes nothing"); - s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); - let d = s.get(id).unwrap(); - assert_eq!((d.can_undo(), d.redaction_marks()), (Some("Apply redactions"), 0)); - // An independent extractor (the renderer's) no longer finds the "1". - assert_eq!(page_texts(&s, id), ["Page", "Page 2"]); - let saved = s.save_bytes(id).unwrap(); - assert!(!saved.windows(8).any(|w| w == b"(Page 1)"), "a full rewrite: the old revision is gone"); - assert!(saved.windows(8).any(|w| w == b"(Page 2)")); - let mut s2 = Session::new(); - let id2 = s2.open("r.pdf", None, saved, None).unwrap(); - assert_eq!(page_texts(&s2, id2), ["Page", "Page 2"]); - s.undo(id).unwrap(); - assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"]); - assert_eq!(s.apply(id, Edit::ClearRedactions).map(|_| s.get(id).unwrap().redaction_marks()), Ok(0)); - assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_)))); -} - -#[test] -fn printing_lays_out_sheets_that_render() { - let (s, id) = session_with(5); - let settings = print::Settings { - pages: print::select_pages(5, Some("2-5"), &[], print::Subset::All, false).unwrap(), - layout: print::Layout::multiple(2), - ..Default::default() - }; - let bytes = s.print_pdf(id, &settings).unwrap(); - let mut s2 = Session::new(); - let id2 = s2.open("print.pdf", None, Arc::new(bytes), None).unwrap(); - assert_eq!(s2.get(id2).unwrap().info.pages.len(), 2, "four pages, two per sheet"); - let texts = page_texts(&s2, id2); - assert!(texts[0].contains("Page 2") && texts[0].contains("Page 3") && texts[1].contains("Page 5"), "{texts:?}"); - // Printing honours the permissions. - let p = - Protection { open_password: Some("pw".into()), permissions_password: Some("owner".into()), printing: Printing::None, ..Default::default() }; - let (mut s3, id3) = session_with(1); - s3.apply(id3, Edit::Protect(p)).unwrap(); - let saved = s3.save_bytes(id3).unwrap(); - let mut s4 = Session::new(); - let id4 = s4.open("locked.pdf", None, saved, Some("pw")).unwrap(); - assert_eq!(s4.print_pdf(id4, &print::Settings { pages: vec![0], ..Default::default() }), Err(EditError::NotPermitted("printing"))); -} - -#[test] -fn crop_and_duplicate_pages_show_in_the_viewer() { - let (mut s, id) = session_with(2); - s.apply(id, Edit::SetPageBox { pages: vec![0], which: PageBox::Crop, spec: BoxSpec::Margins([10.0, 20.0, 30.0, 40.0]) }).unwrap(); - let d = s.get(id).unwrap(); - assert_eq!(d.can_undo(), Some("Crop page")); - assert_eq!(d.info.pages[0].crop, [10.0, 20.0, 170.0, 260.0]); - assert_eq!((d.info.pages[0].width, d.info.pages[0].height), (160.0, 240.0), "the viewer shows the cropped size"); - assert_eq!(d.page_boxes()[1][1], [0.0, 0.0, 200.0, 300.0]); - s.apply(id, Edit::DuplicatePages { pages: vec![1] }).unwrap(); - assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page")); -} - -#[test] -fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { - let (mut s, id) = session_with(2); - let mut hf = HeaderFooter::default(); - hf.text[4] = "Page <<1>> of <> · <>".into(); - s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf.clone(), replace: false }).unwrap(); - // The session clock is 2023-11-14. - assert_eq!(page_texts(&s, id)[1], "Page 2\nPage 2 of 2 · 2023-11-14"); - assert_eq!(s.get(id).unwrap().marks, [MarkKind::HeaderFooter]); - hf.text[4] = "Draft".into(); - s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf, replace: true }).unwrap(); - assert_eq!(page_texts(&s, id)[0], "Page 1\nDraft"); - s.apply( - id, - Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "SECRET".into(), ..Watermark::default() }, file: None, replace: false }, - ) - .unwrap(); - s.apply( - id, - Edit::AddBackground { - pages: vec![1], - settings: Background { color: [1.0, 0.9, 0.9], opacity: 1.0, ..Background::default() }, - replace: false, - file: None, - }, - ) - .unwrap(); - assert_eq!(s.get(id).unwrap().marks.len(), 3); - assert!(page_texts(&s, id)[0].contains("SECRET")); - s.apply(id, Edit::RemoveMarks { kind: MarkKind::HeaderFooter }).unwrap(); - s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }).unwrap(); - s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); - assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"]); - assert!(s.get(id).unwrap().marks.is_empty()); - assert!(matches!(s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }), Err(EditError::Edit(_)))); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background")); -} - -#[test] -fn export_images_and_text_follow_the_working_file() { - let (mut s, id) = session_with(2); - s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap(); - let doc = s.get(id).unwrap(); - let mut ex = export::Exporter::new(doc); - let png = ex.png(1, 144.0).unwrap(); - assert_eq!(&png[1..4], b"PNG"); - // 200×300 pt rotated → 300×200 pt at 2 px/pt. - let w = u32::from_be_bytes(png[16..20].try_into().unwrap()); - let h = u32::from_be_bytes(png[20..24].try_into().unwrap()); - assert_eq!((w, h), (600, 400)); - assert_eq!(ex.text_of(&[0, 1]).unwrap(), "Page 1\n\u{c}Page 2\n"); - assert!(ex.png(5, 72.0).is_err()); -} - -#[test] -fn create_and_reduce() { - let mut s = Session::new(); - let blank = s.create_blank(612.0, 792.0, 2).unwrap(); - let id = s.open_new("Untitled.pdf", blank).unwrap(); - assert_eq!(s.get(id).unwrap().info.pages.len(), 2); - let text = s.create_from_text("notes", "hello\nworld").unwrap(); - let t = s.open_new("notes.pdf", text).unwrap(); - assert_eq!(page_texts(&s, t), ["hello\nworld"]); - // A file with the same page imported twice shares nothing until reduced. - let (mut s2, id2) = session_with(1); - let src = Arc::new(fixture(1)); - s2.apply(id2, Edit::InsertPagesFrom { name: "x".into(), bytes: src.clone(), pages: None, at: 1 }).unwrap(); - let full = s2.save_full_bytes(id2).unwrap(); - let (reduced, _) = s2.reduced_bytes(id2).unwrap(); - assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len()); - let mut s3 = Session::new(); - let r = s3.open("r.pdf", None, reduced, None).unwrap(); - assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]); -} - -#[test] -fn reduce_saves_nothing_for_a_compact_file_whatever_the_time() { - use std::sync::atomic::{AtomicI64, Ordering}; - // The copy gets a new /ModDate inside a compressed object stream. Made a few seconds after - // the document, it was sometimes a byte or two smaller and saved (#490). - static NOW: AtomicI64 = AtomicI64::new(1_760_000_000); - let mut s = Session::new().with_clock(|| NOW.load(Ordering::Relaxed)); - let text = s.create_from_text("notes", &"lorem ipsum ".repeat(500)).unwrap(); - let id = s.open_new("notes.pdf", text.clone()).unwrap(); - for t in (1..86_400).step_by(863) { - NOW.store(1_760_000_000 + t, Ordering::Relaxed); - let (reduced, _) = s.reduced_bytes(id).unwrap(); - assert!(!optimizer::reduce_saves(text.len(), reduced.len()), "{t}: {} → {}", text.len(), reduced.len()); - } - assert!(optimizer::reduce_saves(10_000, 9_000)); -} - -#[test] -fn an_optimize_job_reports_its_stages_and_can_be_cancelled() { - use crate::optimizer::OptimizeStage; - let (s, id) = session_with(2); - let settings = optimize::Settings::default(); - let mut stages = Vec::new(); - let (bytes, _) = s - .optimize_job(id, &settings, &[]) - .unwrap() - .run(|st| { - stages.push(st); - true - }) - .unwrap(); - assert!(bytes.starts_with(b"%PDF-")); - assert_eq!(stages.first(), Some(&OptimizeStage::Discarding)); - assert_eq!(&stages[stages.len() - 3..], [OptimizeStage::CleaningUp, OptimizeStage::Merging, OptimizeStage::Writing]); - let fractions: Vec = stages.iter().map(|st| st.fraction()).collect(); - assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "the bar never goes back: {fractions:?}"); - assert!(fractions.iter().all(|f| (0.0..=1.0).contains(f))); - assert_eq!( - OptimizeStage::Images { done: 0, total: 0 }.fraction(), - OptimizeStage::Images { done: 5, total: 5 }.fraction(), - "no images: no division by zero" - ); - - // Cancelled at the merge: nothing is written, the open document is untouched. - let before = s.get(id).unwrap().bytes.clone(); - let r = s.optimize_job(id, &settings, &[]).unwrap().run(|st| st != OptimizeStage::Merging); - assert!(matches!(r, Err(EditError::Cancelled)), "{r:?}"); - assert_eq!(s.get(id).unwrap().bytes, before); -} - -#[test] -fn flattening_keeps_the_look_and_drops_the_objects() { - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); - s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada".into()) }).unwrap(); - s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])).unwrap(); - let red_before = pixel(&s, id, 0, 100, 100); - s.apply(id, Edit::Flatten { comments: true, fields: true }).unwrap(); - let d = s.get(id).unwrap(); - assert!(d.info.annotations.is_empty() && d.form.is_empty(), "no comments or fields remain"); - assert_eq!(pixel(&s, id, 0, 100, 100), red_before, "the rectangle is now page content"); - assert_eq!(page_texts(&s, id), ["Ada"], "the field's text is now page content"); - s.undo(id).unwrap(); - assert_eq!(s.get(id).unwrap().form.len(), 2); -} - -fn fill_item(shape: Shape, contents: &str) -> Edit { - let style = Style::default_for(&shape); - Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: contents.into(), author: "Ada".into() }) -} - -fn comment_text(s: &Session, id: DocId) -> Vec { - let mut t: Vec = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone().unwrap_or_default()).collect(); - t.sort(); - t -} - -#[test] -fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() { - let (mut s, id) = session_with(1); - s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); - s.apply(id, fill_item(Shape::Mark { rect: [140.0, 200.0, 160.0, 220.0], mark: FillMark::Check }, "")).unwrap(); - s.apply(id, fill_item(Shape::Signature { strokes: vec![vec![[10.0, 40.0], [40.0, 70.0], [70.0, 40.0]]] }, "")).unwrap(); - s.apply(id, fill_item(Shape::TypedSignature { rect: [90.0, 40.0, 150.0, 70.0], contours: vec![vec![[0.1, 0.2], [0.5, 0.9], [0.9, 0.2]]] }, "")) - .unwrap(); - let mut png = Vec::new(); - { - let mut enc = png::Encoder::new(&mut png, 8, 8); - enc.set_color(png::ColorType::Rgba); - let pixel = [40u8, 60, 80, 200]; - enc.write_header().unwrap().write_image_data(&pixel.repeat(64)).unwrap(); - } - let image = SignatureImage::from_bytes(&png).unwrap(); - let info = s.get(id).unwrap().info.pages[0].clone(); - s.apply(id, image.edit(0, &info, [20.0, 120.0], false, "Ada").unwrap()).unwrap(); - s.apply(id, fill_item(Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 280.0, 80.0, 280.0, 20.0, 270.0, 80.0, 270.0]] }, "keep")) - .unwrap(); - s.apply(id, fill_item(Shape::TextBox { rect: [20.0, 150.0, 120.0, 180.0], font_size: 12.0 }, "Note")).unwrap(); - s.apply(id, Edit::AddField { page: 0, rect: [20.0, 230.0, 120.0, 250.0], kind: NewField::Text { multiline: false }, name: Some("email".into()) }) - .unwrap(); - - s.apply(id, Edit::FlattenFillSign).unwrap(); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Flatten Fill & Sign")); - assert_eq!(comment_text(&s, id), ["Note", "keep"]); - assert_eq!(s.get(id).unwrap().form.iter().map(|f| f.name.as_str()).collect::>(), ["email"]); - s.undo(id).unwrap(); - assert!(comment_text(&s, id).contains(&"Hello".to_string()), "undo restores the typewriter"); - - s.apply(id, Edit::FlattenFillSign).unwrap(); - let saved = s.save_bytes(id).unwrap(); - s.mark_saved(id, saved.clone(), None).unwrap(); - let mut again = Session::new(); - let id2 = again.open("saved.pdf", None, saved, None).unwrap(); - assert_eq!(comment_text(&again, id2), ["Note", "keep"]); - assert_eq!(again.get(id2).unwrap().form.iter().map(|f| f.name.as_str()).collect::>(), ["email"]); -} - -#[test] -fn flattening_fill_and_sign_does_nothing_when_there_are_none() { - let (mut s, id) = session_with(1); - s.apply(id, fill_item(Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 200.0, 80.0, 200.0, 20.0, 190.0, 80.0, 190.0]] }, "keep")) - .unwrap(); - let saved = s.save_bytes(id).unwrap(); - s.mark_saved(id, saved, None).unwrap(); - assert!(!s.get(id).unwrap().dirty); - s.apply(id, Edit::FlattenFillSign).unwrap(); - assert!(!s.get(id).unwrap().dirty, "nothing was baked in, so the document stays saved"); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Add highlight"), "no undo step for a flatten that changed nothing"); - assert_eq!(comment_text(&s, id), ["keep"]); -} - -#[test] -fn flattening_fill_and_sign_is_refused_without_permission_to_change() { - let (mut s, id) = session_with(1); - s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); - let p = Protection { printing: Printing::Low, changes: Changes::CommentFillSign, copy: false, ..protection(None, Some("boss")) }; - s.apply(id, Edit::Protect(p)).unwrap(); - let saved = s.save_bytes(id).unwrap(); - let mut other = Session::new(); - let id2 = other.open("locked.pdf", None, saved, None).unwrap(); - let before = other.get(id2).unwrap().bytes.clone(); - assert_eq!(other.apply(id2, Edit::FlattenFillSign), Err(EditError::NotPermitted("changes to the document"))); - assert_eq!(other.get(id2).unwrap().bytes, before); - assert!(!other.get(id2).unwrap().dirty); - assert!(comment_text(&other, id2).contains(&"Hello".to_string())); -} - -#[test] -fn flattening_fill_and_sign_on_a_signed_file_stays_incremental() { - 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(1)), None).unwrap(); - let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..SignOptions::default() }; - let signed = s.sign(id, &digital_id, opts).unwrap(); - s.mark_signed(id, signed.clone(), None).unwrap(); - s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); - s.apply(id, Edit::FlattenFillSign).unwrap(); - let saved = s.save_bytes(id).unwrap(); - assert!(saved.starts_with(signed.as_ref()), "the signature's bytes stay an incremental update"); - assert!(!comment_text(&s, id).contains(&"Hello".to_string())); -} - -#[test] -fn flattening_fill_and_sign_reopens_an_encrypted_file() { - let (mut s, id) = session_with(1); - s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); - s.apply(id, Edit::Protect(protection(Some("secret"), None))).unwrap(); - s.apply(id, Edit::FlattenFillSign).unwrap(); - let saved = s.save_bytes(id).unwrap(); - let mut again = Session::new(); - assert!(again.open("p.pdf", None, saved.clone(), None).is_err()); - let id2 = again.open("p.pdf", None, saved, Some("secret")).unwrap(); - assert!(!comment_text(&again, id2).contains(&"Hello".to_string())); -} - -#[test] -fn pages_export_as_png_jpeg_and_tiff() { - use crate::export::{Exporter, ImageFormat}; - let (s, id) = session_with(1); - let mut ex = Exporter::new(s.get(id).unwrap()); - let png = ex.image(0, 72.0, ImageFormat::Png).unwrap(); - assert!(png.starts_with(b"\x89PNG")); - let jpg = ex.image(0, 72.0, ImageFormat::Jpeg { quality: 85 }).unwrap(); - let decoded = image::load_from_memory_with_format(&jpg, image::ImageFormat::Jpeg).unwrap(); - assert_eq!((decoded.width(), decoded.height()), (200, 300), "the page at 72 dpi"); - let corner = decoded.to_rgb8().get_pixel(2, 2).0; - assert!(corner.iter().all(|c| *c > 240), "white paper, not black: {corner:?}"); - let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap(); - let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap(); - assert_eq!((decoded.width(), decoded.height()), (400, 600)); -} - -#[test] -fn added_text_and_images_render_and_stay_editable() { - let (mut s, id) = session_with(1); - let text = AddedText { rect: [20.0, 250.0, 180.0, 280.0], text: "Added note".into(), size: 12.0, ..AddedText::default() }; - s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap(); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Add text")); - assert!(page_texts(&s, id)[0].contains("Added note"), "the renderer's extractor sees page content"); - // A PNG, centred at its natural size. - let mut png = Vec::new(); - { - let mut enc = png::Encoder::new(&mut png, 20, 10); - enc.set_color(png::ColorType::Rgb); - let mut w = enc.write_header().unwrap(); - w.write_image_data(&[0u8; 600]).unwrap(); - } - s.apply(id, Edit::AddImage { page: 0, rect: None, name: "dot.png".into(), bytes: Arc::new(png) }).unwrap(); - let d = s.get(id).unwrap(); - assert_eq!(d.added.len(), 2); - assert_eq!(d.added[1].content.rect(), [90.0, 145.0, 110.0, 155.0], "20×10 px at 72 dpi, centred on 200×300"); - let moved = d.added[0].content.with_rect([30.0, 100.0, 190.0, 130.0]); - s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: moved }).unwrap(); - assert_eq!(s.get(id).unwrap().added[0].content.rect()[3], 130.0); - s.apply(id, Edit::DeleteContent { page: 0, index: 1 }).unwrap(); - assert_eq!(s.get(id).unwrap().added.len(), 1); - s.undo(id).unwrap(); - assert_eq!(s.get(id).unwrap().added.len(), 2); -} - -#[test] -fn added_images_rotate_flip_and_crop_as_drawn() { - // A 20×10 image: left half red, right half blue. - let mut px = Vec::new(); - for _y in 0..10 { - for x in 0..20 { - px.extend_from_slice(if x < 10 { &[255, 0, 0] } else { &[0, 0, 255] }); - } - } - let mut png = Vec::new(); - { - let mut enc = png::Encoder::new(&mut png, 20, 10); - enc.set_color(png::ColorType::Rgb); - enc.write_header().unwrap().write_image_data(&px).unwrap(); - } - let (mut s, id) = session_with(1); - // A 100×100 box at (50, 100) on the 200×300 page. - s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "rb.png".into(), bytes: Arc::new(png) }).unwrap(); - let colour_at = |s: &Session, x: u32, y_from_top: u32| -> [u8; 3] { - let doc = s.get(id).unwrap(); - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); - let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); - let i = ((y_from_top * out.width + x) * 4) as usize; - [out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]] - }; - let red = |c: [u8; 3]| c[0] > 200 && c[2] < 60; - let blue = |c: [u8; 3]| c[2] > 200 && c[0] < 60; - // Display y 100–200 is rows 100–200 from the top on a 300 pt page. - assert!(red(colour_at(&s, 70, 150)) && blue(colour_at(&s, 130, 150)), "as placed: red left, blue right"); - let item = |s: &Session| s.get(id).unwrap().added[0].content.clone(); - let AddedContent::Image(mut img) = item(&s) else { panic!() }; - img.rotation = 1; - s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); - assert!(red(colour_at(&s, 100, 180)) && blue(colour_at(&s, 100, 120)), "a quarter turn left: red at the bottom"); - img.rotation = 0; - img.flip_h = true; - s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); - 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(); - assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box"); -} - -#[test] -fn revert_goes_back_to_the_saved_version() { - let (mut s, id) = session_with(2); - s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); - let saved = s.save_bytes(id).unwrap(); - s.mark_saved(id, saved, None).unwrap(); - s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); - s.apply(id, Edit::InsertBlankPage { at: 0, width: 200.0, height: 300.0 }).unwrap(); - assert!(s.get(id).unwrap().dirty); - s.revert(id).unwrap(); - let d = s.get(id).unwrap(); - assert!(!d.dirty && d.can_undo().is_none() && d.can_redo().is_none()); - assert_eq!(page_texts(&s, id), ["Page 2"], "the saved state, not the opened one"); - assert_eq!(d.info.pages[0].rotation, 0); -} - -#[test] -fn split_by_size_and_bookmarks_and_page_filters() { - let (mut s, id) = session_with(6); - // Every page alone is a few hundred bytes: a limit of about two pages gives three parts. - let one = s.split(id, &pdfcraft_organize::SplitBy::PageCount(1)).unwrap()[0].2.len(); - let parts = s.split_by_size(id, one * 2 + one / 2).unwrap(); - assert!(parts.len() >= 2 && parts.len() <= 6, "{}", parts.len()); - 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(); - 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; - let all: Vec = (0..6).collect(); - assert_eq!(filter_pages(info, &all, PageParity::Even, PageOrientation::Both), [1, 3, 5]); - assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Landscape), Vec::::new()); - assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Portrait), [0, 2, 4]); -} - -#[test] -fn links_from_urls_and_link_edits() { - // One page whose text contains a web address. - let mut objs: Vec> = - vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec()]; - objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); - objs.push(b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec()); - let body = b"BT /F1 12 Tf 20 100 Td (Visit www.example.org today) Tj ET"; - objs.push([format!("<< /Length {} >>\nstream\n", body.len()).into_bytes(), body.to_vec(), b"\nendstream".to_vec()].concat()); - 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", i + 1).as_bytes()); - out.extend_from_slice(o); - out.extend_from_slice(b"\nendobj\n"); - } - 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()); - let mut s = Session::new(); - let id = s.open("u.pdf", None, Arc::new(out), None).unwrap(); - let found = s.find_urls(id); - assert_eq!(found.len(), 1); - assert_eq!(found[0].2, "http://www.example.org"); - s.apply(id, Edit::AddLinks { links: found, style: LinkStyle::default() }).unwrap(); - let d = s.get(id).unwrap(); - assert_eq!(d.links.len(), 1); - assert_eq!(d.info.links.len(), 1, "the viewer follows it"); - assert!(s.find_urls(id).is_empty(), "already linked"); - // Link Properties and delete. - let l = s.get(id).unwrap().links[0].clone(); - s.apply(id, Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page(0)), style: None }).unwrap(); - assert_eq!(s.get(id).unwrap().links[0].action, LinkAction::Page(0)); - s.apply(id, Edit::DeleteLink { page: 0, index: l.index }).unwrap(); - assert!(s.get(id).unwrap().links.is_empty()); - assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove"); -} - -#[test] -fn comments_lock_take_checkmarks_hide_and_summarize() { - let (mut s, id) = session_with(2); - s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap(); - s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Agreed".into(), author: "Ada".into() }).unwrap(); - s.apply(id, Edit::LockAnnotation { page: 1, index: 0, locked: true }).unwrap(); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Lock comment")); - assert!(s.get(id).unwrap().info.annotations[0].locked); - assert!(s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 5.0, dy: 0.0 }).is_err(), "locked comments stay put"); - assert!(s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).is_err(), "and can't be deleted"); - s.apply(id, Edit::SetAnnotationContents { page: 1, index: 0, text: "Still editable".into() }).unwrap(); - s.apply(id, Edit::LockAnnotation { page: 1, index: 0, locked: false }).unwrap(); - s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 5.0, dy: 0.0 }).unwrap(); - - s.apply(id, Edit::MarkAnnotation { page: 1, index: 0, marked: true, author: "Ada".into() }).unwrap(); - let a = &s.get(id).unwrap().info.annotations; - let mark = a.iter().find(|r| r.is_mark()).expect("a checkmark reply"); - assert_eq!(mark.state.as_deref(), Some("Marked")); - - // Hiding comments drops the rectangle but keeps the page. - let px = pixel(&s, id, 1, 105, 100); - assert!(px[0] > 200 && px[1] < 40, "drawn: {px:?}"); - assert!(s.set_hide_comments(id, true)); - assert!(!s.set_hide_comments(id, true)); - assert_eq!(pixel(&s, id, 1, 105, 100), [255, 255, 255, 255]); - assert!(s.set_hide_comments(id, false)); - assert!(pixel(&s, id, 1, 105, 100)[1] < 40); - - let text = comment_summary("doc.pdf", &s.get(id).unwrap().info.annotations, SummarySort::Page); - assert!(text.starts_with("Summary of Comments on doc.pdf")); - assert!(text.contains("Page: 2\nNumber: 1 Author: Reviewer Subject: Rectangle"), "{text}"); - assert!(text.contains("Still editable") && text.contains(" Agreed"), "{text}"); - assert!(!text.contains("Marked"), "checkmarks aren't listed: {text}"); - let pdf = s.summarize_comments(id, SummarySort::Author).unwrap(); - let n = s.open_new("summary.pdf", pdf).unwrap(); - assert!(!s.get(n).unwrap().info.pages.is_empty()); - assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n"); -} - -/// #820: the generated summary includes replies to replies, indented one level deeper. -#[test] -fn comment_summary_includes_replies_to_replies() { - let (mut s, id) = session_with(1); - s.apply(id, rect_comment(0, [40.0, 40.0, 90.0, 90.0])).unwrap(); - s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "FIRST_REPLY".into(), author: "A".into() }).unwrap(); - s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 1, text: "NESTED_REPLY".into(), author: "B".into() }).unwrap(); - let text = comment_summary("doc.pdf", &s.get(id).unwrap().info.annotations, SummarySort::Page); - assert!(text.contains("FIRST_REPLY"), "{text}"); - assert!(text.contains("NESTED_REPLY"), "replies to replies are summarized: {text}"); - assert!(text.contains(" NESTED_REPLY"), "the nested reply is indented under its parent: {text}"); -} - -#[test] -fn signing_saving_trusting_and_commenting_afterwards() { - 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(2)), None).unwrap(); - assert!(s.get(id).unwrap().signatures.is_empty()); - let opts = SignOptions { page: 1, rect: Some([20.0, 20.0, 180.0, 60.0]), reason: Some("Approved".into()), ..SignOptions::default() }; - let signed = s.sign(id, &digital_id, opts).unwrap(); - s.mark_signed(id, signed, Some("/tmp/signed.pdf".into())).unwrap(); - let doc = s.get(id).unwrap(); - assert_eq!(doc.name, "signed.pdf"); - assert!(doc.is_signed() && !doc.dirty && doc.can_undo().is_none(), "signing can't be undone"); - let sig = &doc.signatures[0]; - assert_eq!((sig.status, sig.page, sig.date.as_deref()), (SignatureStatus::Unknown, Some(1), Some("D:20270115080000Z"))); - assert_eq!(s.save_full_bytes(id).unwrap_err(), EditError::Signed); - // Trusting the signer makes it valid; a later comment is permitted. - s.set_trusted_certificates(vec![digital_id.certificate.clone()]); - assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); - s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])).unwrap(); - let sig = &s.get(id).unwrap().signatures[0]; - assert_eq!((sig.status, sig.modification.clone()), (SignatureStatus::Valid, sign::Modification::Allowed(vec!["comments".into()]))); - // Saving keeps the signature (incremental). - let saved = s.save_bytes(id).unwrap(); - s.mark_saved(id, saved, None).unwrap(); - assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); - // Edits that would need a full rewrite are refused, and change nothing. - let before = s.get(id).unwrap().can_undo().map(str::to_owned); - assert!(matches!(s.apply(id, Edit::Sanitize), Err(EditError::SignedRewrite(_)))); - assert!(!s.get(id).unwrap().dirty && s.get(id).unwrap().can_undo().map(str::to_owned) == before); -} - -#[test] -#[ignore = "timing probe"] -fn probe_edit_latency_on_a_large_signed_document() { - // ~120 MB: one page whose content stream is large. - let big: Vec = (0..120_000_000u32).map(|i| b"0123456789 "[(i % 11) as usize]).collect(); - let mut pdf = b"%PDF-1.7\n".to_vec(); - let mut offs = vec![]; - let objs: Vec> = vec![ - b"<< /Type /Catalog /Pages 2 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 300] /Contents 4 0 R >>".to_vec(), - [format!("<< /Length {} >>\nstream\n", big.len() + 3).into_bytes(), b"% ".to_vec(), big, b"\nendstream".to_vec()].concat(), - ]; - for (i, o) in objs.iter().enumerate() { - offs.push(pdf.len()); - pdf.extend(format!("{} 0 obj\n", i + 1).bytes()); - pdf.extend(o); - pdf.extend(b"\nendobj\n"); - } - let x = pdf.len(); - pdf.extend(b"xref\n0 5\n0000000000 65535 f \n"); - for o in offs { - pdf.extend(format!("{o:010} 00000 n \n").bytes()); - } - pdf.extend(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").bytes()); - 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 t = std::time::Instant::now(); - let id = s.open("big.pdf", None, Arc::new(pdf), None).unwrap(); - eprintln!("open: {:?}", t.elapsed()); - let t = std::time::Instant::now(); - s.apply(id, rect_comment(0, [5.0, 10.0, 50.0, 50.0])).unwrap(); - eprintln!("comment edit before signing: {:?}", t.elapsed()); - s.undo(id).unwrap(); - let t = std::time::Instant::now(); - let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); - eprintln!("sign: {:?}", t.elapsed()); - let t = std::time::Instant::now(); - s.mark_signed(id, signed, None).unwrap(); - eprintln!("mark_signed (reopen + validate): {:?}", t.elapsed()); - for k in 0..3 { - let t = std::time::Instant::now(); - s.apply(id, rect_comment(0, [10.0 + k as f64, 10.0, 50.0, 50.0])).unwrap(); - eprintln!("comment edit {k}: {:?}", t.elapsed()); - } -} - -/// A tiny uncompressed BMP of one colour. -fn bmp(w: u32, h: u32, bgr: [u8; 3]) -> Vec { - let row = (w * 3).div_ceil(4) * 4; - let size = 54 + row * h; - let mut b = b"BM".to_vec(); - b.extend(size.to_le_bytes()); - b.extend([0u8; 4]); - b.extend(54u32.to_le_bytes()); - b.extend(40u32.to_le_bytes()); - b.extend((w as i32).to_le_bytes()); - b.extend((h as i32).to_le_bytes()); - b.extend(1u16.to_le_bytes()); - b.extend(24u16.to_le_bytes()); - b.extend([0u8; 24]); - for _ in 0..h { - for _ in 0..w { - b.extend(bgr); - } - b.extend(vec![0u8; (row - w * 3) as usize]); - } - b -} - -#[test] -fn backgrounds_and_watermarks_from_files() { - let (mut s, id) = session_with(1); - // A source PDF whose second page is solid blue. - let blue = { - let mut src = Session::new(); - let sid = src.open("src.pdf", None, Arc::new(fixture(2)), None).unwrap(); - src.apply( - sid, - Edit::AddBackground { - pages: vec![1], - settings: Background { color: [0.0, 0.0, 1.0], ..Background::default() }, - replace: false, - file: None, - }, - ) - .unwrap(); - src.save_bytes(sid).unwrap() - }; - let file = MarkFile { name: "src.pdf".into(), bytes: blue, page: 1 }; - s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: false, file: Some(file) }).unwrap(); - let corner = pixel(&s, id, 0, 3, 3); - assert!(corner[2] > 200 && corner[0] < 40, "the blue page fills the background: {corner:?}"); - // The page's own text still draws on top (at "Page 1", 20,150). - assert_eq!(s.get(id).unwrap().can_undo(), Some("Add background")); - // A red image watermark at half the page, on top, unrotated. - let red = MarkFile { name: "stamp.bmp".into(), bytes: Arc::new(bmp(4, 4, [0, 0, 255])), page: 0 }; - let wm = Watermark { rotation: 0.0, opacity: 1.0, scale: 0.5, ..Watermark::default() }; - s.apply(id, Edit::AddWatermark { pages: vec![0], settings: wm, replace: false, file: Some(red) }).unwrap(); - let centre = pixel(&s, id, 0, 100, 150); - assert!(centre[0] > 200 && centre[2] < 40, "the red picture sits in the middle: {centre:?}"); - let edge = pixel(&s, id, 0, 10, 150); - assert!(edge[2] > 200, "outside it the background shows: {edge:?}"); - // A bad file is refused clearly. - let junk = MarkFile { name: "x.png".into(), bytes: Arc::new(b"not an image".to_vec()), page: 0 }; - assert!(s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: true, file: Some(junk) }).is_err()); -} - -/// Scan & OCR ▸ Recognize text on a page that is only a picture of text (needs the models: -/// `cargo xtask models`; skipped without them). -#[test] -fn recognize_text_reads_the_euro_sign() { - 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", "Total amount due: €250 by Friday").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 id = s.open("scan.pdf", None, scan, None).unwrap(); - s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); - let text = page_texts(&s, id)[0].clone(); - // The model was trained with € where the ocrs crate's alphabet has a second E. - assert!(text.contains("€250"), "{text}"); -} - -#[test] -fn recognize_text_makes_a_scanned_page_searchable() { - 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(); - // A typical 300 dpi scan: at 150 dpi the ONNX models read the small leading q of "quick" as a. - // Imported at the same resolution, so the page keeps its size in points. - let png = export::Exporter::new(s.get(id).unwrap()).png(0, 300.0).unwrap(); - let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(300.0)).unwrap(); - let id = s.open("scan.pdf", None, scan, None).unwrap(); - assert_eq!(page_texts(&s, id), [""], "a picture has no text"); - - let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); - assert_eq!(found.len(), 1); - let text = page_texts(&s, id)[0].to_lowercase(); - for w in ["quick", "brown", "fox", "lazy"] { - assert!(text.contains(w), "{text}"); - } - assert_eq!(s.get(id).unwrap().can_undo(), Some("Recognize text")); - // The text sits over the words: "quick" is left of "lazy" and on the same line. - let words = &found[0].words; - let q = words.iter().find(|w| w.text.to_lowercase().contains("quick")).unwrap(); - let l = words.iter().find(|w| w.text.to_lowercase().contains("lazy")).unwrap(); - let top = |w: &ocr::PlacedWord| w.origin[1] + w.up[1]; - assert!(q.origin[0] < l.origin[0] && (top(q) - top(l)).abs() < 3.0, "{q:?} {l:?}"); - - // A second pass skips the page: it has text now. - let again = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); - assert!(again[0].skipped.is_some()); -} - -/// 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 { - 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) - }; - let objs = [ - "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript << /Names [(helpers) 10 0 R] >> >> /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R] /CO [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), - "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(), - text("price", 250, "/V (2.5)"), - text("qty", 220, "/AA << /V << /S /JavaScript /JS (if (event.value > 100) { app.alert('At most 100'); event.rc = false; }) >> >>"), - text("total", 190, "/AA << /C << /S /JavaScript /JS (event.value = times\\(getField('price').value, getField('qty').value\\);) >> /F << /S /JavaScript /JS (event.value = 'USD ' + util.printf('%.2f', event.value);) >> >>"), - "<< /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(), - ]; - 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 -} - -#[test] -fn form_javascript_validates_calculates_and_formats() { - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); - let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); - s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); - assert_eq!(value(&s, "total"), ["10"], "calculated by the script"); - assert!(page_texts(&s, id)[0].contains("USD 10.00"), "{:?}", page_texts(&s, id)); - let out = s.take_js_output(id); - assert!(out.console.contains(&"times".to_string()), "{out:?}"); - - let err = s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap_err(); - assert_eq!(err.to_string(), "At most 100"); - assert_eq!(value(&s, "qty"), ["4"]); - - // 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.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()); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); - - // JavaScript off: scripts don't run. - s.set_javascript(false); - s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); - assert_eq!(value(&s, "total"), ["12"]); - assert!(s.run_javascript(id, "1", None).is_err()); -} - -#[test] -fn edit_history_does_not_replay_field_script_side_effects() { - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); - let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); - let initial_qty = value(&s, "qty"); - let initial_total = value(&s, "total"); - assert!(s.take_js_output(id).is_empty()); - - s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); - assert_eq!(value(&s, "qty"), ["4"]); - assert_eq!(value(&s, "total"), ["10"]); - let output = s.take_js_output(id); - assert!(output.console.contains(&"times".to_string()), "{output:?}"); - assert_eq!(output.console.iter().filter(|line| *line == "times").count(), 1, "{output:?}"); - - s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap(); - assert!(s.take_js_output(id).is_empty(), "a metadata edit does not rerun field scripts"); - - s.undo(id).unwrap(); - assert_eq!(s.get(id).unwrap().info.title, None); - assert_eq!(value(&s, "qty"), ["4"]); - assert!(s.take_js_output(id).is_empty(), "undoing metadata does not rerun field scripts"); - - s.redo(id).unwrap(); - assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Report")); - assert_eq!(value(&s, "qty"), ["4"]); - assert!(s.take_js_output(id).is_empty(), "redoing metadata does not rerun field scripts"); - - s.undo(id).unwrap(); - s.undo(id).unwrap(); - assert_eq!(value(&s, "qty"), initial_qty); - assert_eq!(value(&s, "total"), initial_total); - assert!(s.take_js_output(id).is_empty(), "undoing the scripted edit does not replay its side effect"); - - s.redo(id).unwrap(); - assert_eq!(value(&s, "qty"), ["4"]); - assert_eq!(value(&s, "total"), ["10"]); - assert!(s.take_js_output(id).is_empty(), "redoing the scripted edit restores its snapshot without replay"); -} - -#[test] -fn detecting_fields_on_a_printed_form() { - let mut s = Session::new().with_clock(|| 1_700_000_000); - let text = s.create_from_text("t", "Name: ______________________\n\nEmail address: ____________________\n\nPlain text without blanks.").unwrap(); - let id = s.open("paper.pdf", None, text, None).unwrap(); - let found = s.detect_fields(id, &[]); - let names: Vec<&str> = found.iter().map(|(_, c)| c.name.as_str()).collect(); - assert_eq!(names, ["Name", "Email address"], "{found:?}"); - let added = s.auto_detect_fields(id, &[]).unwrap(); - assert_eq!(added, ["Name", "Email address"]); - let d = s.get(id).unwrap(); - assert_eq!(d.form.len(), 2); - assert_eq!(d.can_undo(), Some("Detect form fields")); - // The field sits on the blank: right of the label, at its height. - let f = &d.form[0]; - assert!(f.widgets[0].rect[0] > 72.0 + 20.0, "{:?}", f.widgets[0].rect); - assert!(s.detect_fields(id, &[]).is_empty(), "nothing left to detect"); -} - -#[test] -fn comparing_two_versions_of_a_document() { - let mut s = Session::new().with_clock(|| 1_700_000_000); - let v1 = s.create_from_text("t", "The contract starts on Monday.\n\nPayment is due within thirty days.\n\nSigned by both parties.").unwrap(); - let v2 = s - .create_from_text("t", "The contract starts on Tuesday.\n\nPayment is due within thirty days of invoice.\n\nSigned by both parties.") - .unwrap(); - let old = s.open("v1.pdf", None, v1, None).unwrap(); - let new = s.open("v2.pdf", None, v2, None).unwrap(); - let c = s.compare(old, new).unwrap(); - let got: Vec<(compare::Kind, &str, &str)> = c.changes.iter().map(|x| (x.kind, x.old.text.as_str(), x.new.text.as_str())).collect(); - assert_eq!(got, [(compare::Kind::Replaced, "Monday.", "Tuesday."), (compare::Kind::Replaced, "days.", "days of invoice.")], "{got:?}"); - assert!(s.compare(old, old).unwrap().identical()); - - let report = s.compare_report(old, new).unwrap(); - let rid = s.open("report.pdf", None, report, None).unwrap(); - let text = page_texts(&s, rid).join(" "); - assert!(text.contains("2 changes: 2 replaced") && text.contains("Tuesday."), "{text}"); - - // Visually, only the changed words' places differ (and the page is otherwise the same). - let vis = s.compare_visual(old, new, 72.0).unwrap(); - assert!(!vis.is_empty() && vis.iter().all(|(p, r)| *p == 0 && r[3] > 600.0), "{vis:?}"); - assert!(s.compare_visual(old, old, 72.0).unwrap().is_empty()); - - assert_eq!(s.mark_differences(old, new).unwrap(), 2); - let d = s.get(new).unwrap(); - assert_eq!(d.can_undo(), Some("Mark differences")); - let marks: Vec<_> = d.info.annotations.iter().filter(|a| a.author.as_deref() == Some("Compare")).collect(); - assert_eq!(marks.len(), 2); - assert!(marks[0].contents.as_deref().unwrap().starts_with("Replaced: \"Monday.\"")); -} - -#[test] -fn actions_run_their_steps_on_files() { - let s = Session::new(); - let src = s.create_from_text("t", "Quarterly numbers").unwrap(); - let built = actions::builtin(); - let numbers = built.iter().find(|a| a.name == "Add Page Numbers").unwrap(); - let r = actions::run_on(numbers, "q.pdf", src.clone(), |_, _| {}).unwrap(); - let mut s2 = Session::new(); - let id = s2.open("out.pdf", None, r.bytes, None).unwrap(); - assert!(page_texts(&s2, id)[0].contains("Page 1 of 1"), "{:?}", page_texts(&s2, id)); - assert_eq!(r.log, ["Add footer"]); - - let custom = actions::Action { - name: "Mine".into(), - description: String::new(), - steps: vec![ - actions::Step::from_id("set_title", "Q3").unwrap(), - actions::Step::AddWatermark("DRAFT".into()), - actions::Step::ReduceFileSize, - actions::Step::FlattenComments, - ], - builtin: false, - }; - let mut steps = Vec::new(); - let r = actions::run_on(&custom, "q.pdf", src, |i, n| steps.push((i, n))).unwrap(); - assert_eq!(steps, [(0, 4), (1, 4), (2, 4), (3, 4)]); - assert_eq!(r.log.len(), 4, "{:?}", r.log); - let id = s2.open("out2.pdf", None, r.bytes, None).unwrap(); - let d = s2.get(id).unwrap(); - assert_eq!(d.info.title.as_deref(), Some("Q3")); - assert!(page_texts(&s2, id)[0].contains("DRAFT")); - assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err()); -} - -#[test] -fn exporting_office_files_keeps_images() { - let mut s = Session::new(); - let text = s.create_from_text("t", "Picture page").unwrap(); - let id = s.open("t.pdf", None, text, None).unwrap(); - let png = export::Exporter::new(s.get(id).unwrap()).png(0, 20.0).unwrap(); - let pic = s.create_from_images(&[("p.png".into(), png)]).unwrap(); - let pid = s.open("p.pdf", None, pic, None).unwrap(); - let d = s.get(pid).unwrap(); - assert_eq!(d.export_pages()[0].images.len(), 1); - let docx = d.export_office(compare::OfficeFormat::Docx); - assert!(docx.windows(16).any(|w| w == b"word/media/image")); - if let Ok(dir) = std::env::var("PDFCRAFT_EXPORT_DIR") { - std::fs::write(format!("{dir}/pic.docx"), &docx).unwrap(); - } - assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("data:image/png;base64,")); -} - -/// #314: a page whose content is drawn through a form XObject exports its text (the file from -/// the report: no xref table, wrong stream lengths). -#[test] -fn exporting_office_files_reads_form_xobjects() { - let pdf = b"%PDF-1.4 -1 0 obj <> endobj -2 0 obj <> endobj -3 0 obj <>>>/Contents 5 0 R>> endobj -4 0 obj <> endobj -5 0 obj <> stream -q 1 0 0 1 0 0 cm /Fm1 Do Q -endstream endobj -7 0 obj <>>>/Length 60>> stream -BT /F1 18 Tf 72 760 Td (Hello from a test invoice) Tj ET -endstream endobj -trailer <> -%%EOF -"; - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf.to_vec()), None).unwrap(); - let d = s.get(id).unwrap(); - let blocks: Vec = d.export_pages()[0].blocks.iter().map(|b| b.text.clone()).collect(); - assert_eq!(blocks, ["Hello from a test invoice"]); - assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("Hello from a test invoice")); - // Edit text still offers only what the page's own streams show. - assert!(d.text_blocks(0).is_empty()); -} - -#[test] -fn exporting_office_files_keeps_text_colour() { - // #526: a dark green heading came out black in Word. - let content = "BT /F1 24 Tf 0.05 0.23 0.18 rg 72 700 Td (Annual Report) Tj ET \ -BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body size.) 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 >> >> >>".into(), - format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), - "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".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()); - let mut s = Session::new(); - let id = s.open("c.pdf", None, Arc::new(pdf), None).unwrap(); - let d = s.get(id).unwrap(); - let html = String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap(); - assert!(html.contains("style=\"color:#0D3B2E\">Annual Report"), "{html}"); - let blocks = &d.export_pages()[0].blocks; - assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}"); -} - -#[test] -fn guard_turns_a_panic_into_an_error() { - assert_eq!(guard(|| 7), Ok(7)); - assert_eq!(guard(|| -> u8 { panic!("boom") }), Err("boom".to_string())); - let n = 3; - assert_eq!(guard(|| -> u8 { panic!("page {n} is bad") }), Err("page 3 is bad".to_string())); -} - -#[test] -fn measurement_edits_are_transactional_and_honour_annotation_permissions() { - let mut session = Session::default(); - let id = session.open("plan.pdf", None, Arc::new(fixture(1)), None).unwrap(); - let m = measure::NewMeasurement { - page: 0, - kind: measure::Kind::Distance, - points: vec![[10.0, 20.0], [40.0, 60.0]], - scale: measure::Scale::new(0.1, "m", 2).unwrap(), - style: Style::default(), - label: "Wall".into(), - author: "Tester".into(), - }; - session.apply(id, Edit::AddMeasurement(m.clone())).unwrap(); - let before = session.get(id).unwrap().bytes.clone(); - assert_eq!(session.get(id).unwrap().measurements().unwrap().measurements[0].reading.label, "5.00 m"); - let mut invalid = m.clone(); - invalid.points[1][0] = f64::INFINITY; - assert!(session.apply(id, Edit::AddMeasurement(invalid)).is_err()); - assert_eq!(session.get(id).unwrap().bytes, before); - session.undo(id).unwrap(); - assert!(session.get(id).unwrap().measurements().unwrap().measurements.is_empty()); - session.redo(id).unwrap(); - assert_eq!(session.get(id).unwrap().bytes, before); - let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; - assert_eq!(check_permission(&Edit::AddMeasurement(m), &p), Err(EditError::NotPermitted("comments"))); - assert_eq!( - check_permission( - &Edit::SetMeasurementScale { page: 0, bbox: [0.0, 0.0, 100.0, 100.0], name: "Detail".into(), scale: measure::Scale::default() }, - &p - ), - Err(EditError::NotPermitted("comments")) - ); -} - -#[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))); - 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 x = doc.xfa.as_ref().expect("laid out from the template"); - assert_eq!((x.pages, x.fields), (2, 14), "{x:?}"); - assert!(x.warnings.is_empty(), "{:?}", x.warnings); - assert_eq!(doc.info.pages.len(), 2, "the placeholder page is gone"); - assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Dynamic), "still an XFA form for Adobe's viewers"); - assert!(!doc.dirty, "laying out is not an edit"); - assert!(doc.bytes.starts_with(&original[..]), "the layout is an appended revision; the original bytes stay"); - let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect(); - assert!(names.contains(&"familyName") && names.contains(&"answer") && names.contains(&"what2"), "{names:?}"); - let family = doc.form.iter().find(|f| f.name == "familyName").unwrap(); - assert_eq!((family.tooltip.as_deref(), family.max_len), (Some("Your family name"), Some(30))); - let answer = doc.form.iter().find(|f| f.name == "answer").unwrap(); - assert_eq!(answer.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Y", "N"]); - assert!(page_texts(&s, id)[0].contains("Sample Form"), "{:?}", page_texts(&s, id)); - // Fill it in and save: one more incremental revision on top. - s.apply(id, Edit::SetFieldValue { name: "familyName".into(), value: FieldValue::Text("Singh".into()) }).unwrap(); - s.apply(id, Edit::SetFieldValue { name: "answer".into(), value: FieldValue::Radio(Some("N".into())) }).unwrap(); - assert!(s.get(id).unwrap().dirty); - let saved = s.save_bytes(id).unwrap(); - assert!(saved.starts_with(&original[..])); - // The datasets packet carries the values too, for Adobe's viewers. - let cos = pdfcraft_cos::Document::open(saved.clone()).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].head[0].familyName[0]")), Some("Singh")); - assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].head[0].answer[0]")), Some("N")); - let id2 = s.open("saved.pdf", None, saved, None).unwrap(); - let d2 = s.get(id2).unwrap(); - assert_eq!(d2.info.pages.len(), 2, "a saved form is not laid out a second time"); - assert_eq!(d2.xfa.as_ref().map(|x| (x.pages, x.fields)), Some((2, 14)), "the layout is remembered"); - assert_eq!(d2.form.iter().find(|f| f.name == "familyName").unwrap().value, vec!["Singh".to_string()]); - assert_eq!(d2.form.iter().find(|f| f.name == "answer").unwrap().value, vec!["N".to_string()]); -} - -#[test] -fn an_xfa_form_without_a_template_opens_with_its_placeholder_and_a_warning() { - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell("")); - let mut s = Session::new(); - let id = s.open("form.pdf", None, bytes, None).expect("opens"); - let doc = s.get(id).unwrap(); - assert!(doc.xfa.is_none()); - assert_eq!(doc.info.pages.len(), 1); - assert!(doc.info.warnings.iter().any(|w| w.contains("could not be laid out") && w.contains("no template")), "{:?}", doc.info.warnings); -} - -#[test] -fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() { - let data = "
Kaur2001-02-03Y
The find bar showing match 10 of 16 for the word 'type', highlighted in the Expressive Type chapter heading
abc
"; - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template_with_data(1, data))); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("form.pdf", None, bytes, None).expect("opens"); - let doc = s.get(id).unwrap(); - let value = |n: &str| doc.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone(); - assert_eq!(value("familyName"), vec!["Kaur".to_string()]); - assert_eq!(value("born"), vec!["2001-02-03".to_string()]); - assert_eq!(value("answer"), vec!["Y".to_string()]); - assert_eq!(value("what0_3"), vec!["c".to_string()], "three rows from the data: {:?}", doc.form.iter().map(|f| &f.name).collect::>()); - assert!(!doc.dirty); -} - -#[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")); - let mut s = Session::new().with_clock(|| 1_700_000_000); - 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.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Ada".to_string()]); - assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].agree[0]").unwrap().value, vec!["1".to_string()]); - assert!(!doc.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision"); - // Rewriting the fields from the XFA data is recorded, not silent. - let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning"); - assert!(note.starts_with("2 form field values") && note.contains("form1[0].page1[0].name[0]"), "{note}"); - s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("Grace".into()) }).unwrap(); - s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].agree[0]".into(), value: FieldValue::Check(false) }).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("form1[0].page1[0].name[0]")), Some("Grace")); - assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")), Some(""), "unchecked, with no off value"); - // Reopening agrees with itself: nothing to apply, nothing dirty. - let id2 = s.open("again.pdf", None, saved, None).unwrap(); - let d2 = s.get(id2).unwrap(); - assert_eq!(d2.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Grace".to_string()]); - assert!(!d2.dirty); - assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten"); -} - -#[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. - let data = concat!( - "Ada1keep", - "2020unbound" - ); - let original = Arc::new(pdfcraft_xfa::fixtures::static_shell(data)); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("static.pdf", None, original, None).expect("opens"); - s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("Grace".into()) }).unwrap(); - let saved = s.save_bytes(id).unwrap(); - let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); - let xdp = pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp; - for kept in [ - "keep", - "2020", - "unbound", - ] { - assert!(xdp.contains(kept), "lost {kept:?}: {xdp}"); - } - let data = pdfcraft_xfa::parse_datasets(&xdp).unwrap(); - assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Grace")); - assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")), Some("1")); - // A value that can't be written is reported on the document. - let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("rich")); - let id = s.open("rich.pdf", None, original, None).expect("opens"); - s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("plain".into()) }).unwrap(); - let doc = s.get(id).unwrap(); - assert!(doc.info.warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.info.warnings); - assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content"))); -} - -#[test] -fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { - let data = "
JapanEnglish\nSpanish
"; - 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 field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}")); - use pdfcraft_forms::FieldKind as K; - // The forms layer sees a combo box with its options and the data's saved value, a - // multi-select list with both picks, an editable combo, a password field and a signature. - let country = field(&s, "country"); - assert_eq!((country.kind, country.value.clone()), (K::Combo, vec!["JP".to_string()])); - assert_eq!(country.options, vec![("CA".into(), "Canada".into()), ("FR".into(), "France".into()), ("JP".into(), "Japan".into())]); - let langs = field(&s, "langs"); - assert_eq!(langs.kind, K::List); - assert!(langs.has(pdfcraft_forms::flags::MULTI_SELECT)); - 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); - // 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 - // in the editable list and refused in the fixed one. - s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap(); - assert_eq!(field(&s, "summary").value, vec!["Country: CA".to_string()]); - s.apply(id, Edit::SetFieldValue { name: "langs".into(), value: FieldValue::Choice(vec!["French".into(), "Spanish".into()]) }).unwrap(); - s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed".into()) }).unwrap(); - assert!(s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["Mars".into()]) }).is_err()); - // Saved: the datasets hold the saved values, several on separate lines; reopened, the - // fields and the calculation are what they were. - let saved = s.save_bytes(id).unwrap(); - let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); - let d = pdfcraft_xfa::data_of(&cos).unwrap(); - assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].country[0]")), Some("CA")); - assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].langs[0]")), Some("French\nSpanish")); - assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].other[0]")), Some("typed")); - let id2 = s.open("again.pdf", None, saved.clone(), None).unwrap(); - let d2 = s.get(id2).unwrap(); - let f2 = |n: &str| d2.form.iter().find(|f| f.name == n).unwrap().value.clone(); - assert_eq!(f2("country"), vec!["CA".to_string()]); - assert_eq!(f2("langs"), vec!["French".to_string(), "Spanish".to_string()]); - assert_eq!(f2("summary"), vec!["Country: CA".to_string()]); - // The PNG of the image field and the GIF of the draw are pictures on the page. - assert_eq!(s.get(id).unwrap().page_images(0).len(), 2); - // Filled in by another viewer since: saved values, several on separate lines, and shown - // text (an older viewer's), are taken on reopen; shown text that means the current value - // is not a change. - let mut cos = pdfcraft_cos::Document::open(saved).unwrap(); - pdfcraft_xfa::write_data_value(&mut cos, &pdfcraft_xfa::som_to_path("form[0].page1[0].langs[0]"), "English\nSpanish").unwrap(); - pdfcraft_xfa::write_data_value(&mut cos, &pdfcraft_xfa::som_to_path("form[0].page1[0].country[0]"), "Canada").unwrap(); - let edited = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap()); - let id3 = s.open("elsewhere.pdf", None, edited, None).unwrap(); - let d3 = s.get(id3).unwrap(); - let f3 = |n: &str| d3.form.iter().find(|f| f.name == n).unwrap().value.clone(); - assert_eq!(f3("langs"), vec!["English".to_string(), "Spanish".to_string()]); - assert_eq!(f3("country"), vec!["CA".to_string()]); - let taken: Vec<&String> = d3.info.warnings.iter().filter(|w| w.contains("another viewer")).collect(); - assert_eq!(taken.len(), 1, "{:?}", d3.info.warnings); - assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]); -} - -#[test] -fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("scripted.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_else(|| panic!("no field {n}")) - }; - let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); - // On open: the initialize script set qty, the calculations ran, nothing is dirty. - assert_eq!(value(&s, "qty"), vec!["2".to_string()]); - assert_eq!(value(&s, "total"), vec!["10".to_string()], "{:?}", s.take_js_output(id)); - assert_eq!(value(&s, "grand"), vec!["0".to_string()]); - assert!(!s.get(id).unwrap().dirty); - assert!(!names(&s).iter().any(|n| n == "note"), "details is hidden"); - // A change recalculates. - s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap(); - assert_eq!(value(&s, "total"), vec!["15".to_string()], "{:?}", s.take_js_output(id)); - // 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_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(); - let o = s.run_javascript(id, "", Some("addRow")).unwrap(); - assert_eq!(o.error, None); - let after = names(&s); - assert_eq!(after.len(), before + 2, "{after:?}"); - assert!(after.iter().any(|n| n == "amount_2")); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Run form script")); - s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("10".into()) }).unwrap(); - 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)); - // Message boxes reach the viewer. - let o = s.run_javascript(id, "", Some("hello")).unwrap(); - assert_eq!(o.alerts, vec!["Hello 500".to_string()]); - // 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)); - s.undo(id).unwrap(); - assert!(!names(&s).iter().any(|n| n == "note")); - s.redo(id).unwrap(); - assert!(names(&s).iter().any(|n| n == "note")); - // Removing the row takes its amount out of the sum. - s.run_javascript(id, "", Some("removeRow")).unwrap(); - assert!(!names(&s).iter().any(|n| n == "amount_2")); - assert_eq!(value(&s, "grand"), vec!["10".to_string()]); - // 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()]); - // 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(); - let d2 = s.get(id2).unwrap(); - let n2: Vec<&str> = d2.form.iter().map(|f| f.name.as_str()).collect(); - assert!(n2.contains(&"note") && !n2.contains(&"amount_2"), "{n2:?}"); - assert_eq!(d2.form.iter().find(|f| f.name == "grand").unwrap().value, vec!["10".to_string()]); - assert_eq!(d2.form.iter().find(|f| f.name == "qty").unwrap().value, vec!["500".to_string()]); - assert!(!d2.dirty); -} - -/// A repeating table inside an area of a positioned page (as in real Designer forms): every -/// row is laid out, the values Adobe wrote (under the area's parent: areas are -/// not data scopes) are read, a FormCalc total over all rows computes, and what is filled here -/// is written back where Adobe's viewers read it, with no element for the area. -#[test] -fn xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them() { - let data = "
Regular4
5
"; - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::leave_template(data))); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("leave.pdf", None, bytes, None).expect("opens"); - let value = |s: &Session, n: &str| { - s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.first().cloned().unwrap_or_default() - }; - let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); - // Three rows (occur min="3"), not one. - assert_eq!(names(&s).iter().filter(|n| n.starts_with("reason")).count(), 3, "{:?}", names(&s)); - // Adobe's data, bound past the area. - assert_eq!((value(&s, "reason"), value(&s, "days"), value(&s, "carried")), ("Regular".into(), "4".into(), "5".into())); - // The total walks rows 0 to 2: 5 + 30 - 4. - assert_eq!(value(&s, "rest"), "31", "{:?}", s.take_js_output(id)); - // A second regular row recalculates it. - s.apply(id, Edit::SetFieldValue { name: "reason_2".into(), value: FieldValue::Text("Regular".into()) }).unwrap(); - s.apply(id, Edit::SetFieldValue { name: "days_2".into(), value: FieldValue::Text("2".into()) }).unwrap(); - assert_eq!(value(&s, "rest"), "29", "{:?}", s.take_js_output(id)); - assert!(s.take_js_output(id).errors.is_empty()); - // Written where Adobe reads it: no element for the area, the rows under the table. - let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); - let d = pdfcraft_xfa::data_of(&cos).unwrap(); - let path = |som: &str| pdfcraft_xfa::som_to_path(som); - assert_eq!(d.count(&path("form[0].page[0]"), "box"), 0, "an element for the area was written"); - assert_eq!(d.count(&path("form[0].page[0].table[0]"), "row"), 3); - assert_eq!(d.text_at(&path("form[0].page[0].table[0].row[1].days[0]")), Some("2")); - assert_eq!(d.text_at(&path("form[0].page[0].rest[0]")), Some("29")); - // Reopened, the values come back from that data. - let mut s2 = Session::new().with_clock(|| 1_700_000_000); - let id2 = s2.open("again.pdf", None, s.get(id).unwrap().bytes.clone(), None).expect("reopens"); - let v2 = |n: &str| s2.get(id2).unwrap().form.iter().find(|f| f.name == n).unwrap().value.first().cloned().unwrap_or_default(); - assert_eq!((v2("days_2"), v2("rest")), ("2".into(), "29".into())); -} - -#[test] -fn dynamic_xfa_forms_run_formcalc_scripts() { - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template())); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("formcalc.pdf", None, bytes, None).expect("opens"); - let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); - let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); - // Initialize set qty; the calculates ran from it, including the English words. - assert_eq!(value(&s, "qty"), vec!["2".to_string()], "{:?}", s.take_js_output(id)); - assert_eq!(value(&s, "total"), vec!["10".to_string()]); - assert_eq!(value(&s, "words"), vec!["Ten".to_string()]); - assert!(s.take_js_output(id).is_empty()); - // Editing recalculates and validates. - s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("7".into()) }).unwrap(); - assert_eq!(value(&s, "total"), vec!["35".to_string()]); - 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_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(); - let o = s.run_javascript(id, "", Some("addRow")).unwrap(); - assert_eq!(o.error, None, "{o:?}"); - assert_eq!(names(&s).len(), before + 2, "{:?}", names(&s)); - s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("10".into()) }).unwrap(); - 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()]); - 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(); - assert!(!names(&s).iter().any(|n| n == "amount_2")); - assert_eq!(value(&s, "grand"), vec!["10".to_string()]); - // Saved and reopened, the form is what it was. - let saved = s.save_bytes(id).unwrap(); - let id2 = s.open("again.pdf", None, saved, None).unwrap(); - let d2 = s.get(id2).unwrap(); - assert_eq!(d2.form.iter().find(|f| f.name == "words").unwrap().value, vec!["Two Thousand Five Hundred".to_string()]); - assert!(d2.form.iter().any(|f| f.name == "note")); -} - -#[test] -fn hostile_formcalc_scripts_cannot_hang_or_bloat_the_document() { - let tpl = pdfcraft_xfa::fixtures::formcalc_template() - .replace( - "", - "", - ) - .replace("", "") - .replace( - "", - "", - ); - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let started = std::time::Instant::now(); - let id = s.open("hostile.pdf", None, bytes, None).expect("opens"); - let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count(); - assert!(rows(&s) <= 50, "{} rows", rows(&s)); - let out = s.take_js_output(id); - assert!(out.errors.iter().any(|l| l.contains("nested")), "{out:?}"); - let o = s.run_javascript(id, "", Some("addRow")).unwrap(); - assert!(o.error.is_some() || rows(&s) <= 50, "{o:?}"); - assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed()); -} - -#[test] -fn a_formcalc_calculate_result_is_cut_before_it_is_saved() { - let tpl = pdfcraft_xfa::fixtures::formcalc_template() - .replace("", ""); - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("long.pdf", None, bytes, None).expect("opens"); - let words = |s: &Session, id| s.get(id).unwrap().form.iter().find(|f| f.name == "words").unwrap().value.concat(); - assert_eq!(words(&s, id).chars().count(), 65_536); - let out = s.take_js_output(id); - assert!(out.errors.iter().any(|e| e.contains("cut")), "{out:?}"); - let saved = s.save_bytes(id).unwrap(); - let id2 = s.open("again.pdf", None, saved, None).unwrap(); - assert!(words(&s, id2).chars().count() <= 65_536); -} - -#[test] -fn xfa_scripts_stay_off_with_javascript_off() { - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); - let mut s = Session::new().with_clock(|| 1_700_000_000); - s.set_javascript(false); - let id = s.open("scripted.pdf", None, bytes, None).expect("opens"); - let qty = s.get(id).unwrap().form.iter().find(|f| f.name == "qty").unwrap().value.clone(); - assert!(qty.is_empty(), "no initialize script ran"); - assert!(s.run_javascript(id, "", Some("addRow")).is_err()); -} - -#[test] -fn hostile_xfa_scripts_cannot_hang_or_bloat_the_document() { - // A calculate script that adds a row every time it runs, a click script that loops on - // addInstance, and one that nests setInstances: each ends quickly with a bounded form. - let tpl = pdfcraft_xfa::fixtures::scripted_template() - .replace( - r#""#, - r#""#, - ) - .replace( - r#"table._row.addInstance(1);"#, - r#"for (var i = 0; i < 100000; i++) { table._row.addInstance(1); table._row.setInstances(0); }"#, - ); - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let started = std::time::Instant::now(); - let id = s.open("hostile.pdf", None, bytes, None).expect("opens"); - let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count(); - assert!(rows(&s) <= 50, "{} rows", rows(&s)); - let o = s.run_javascript(id, "", Some("addRow")).unwrap(); - assert!(o.error.is_some() || rows(&s) <= 50, "{o:?}"); - assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed()); -} - -#[test] -fn xfa_scripts_can_remove_rows_twice_and_reset_fields_by_name() { - let tpl = pdfcraft_xfa::fixtures::scripted_template() - .replace(r#"if (table._row.count > 1) { table._row.removeInstance(table._row.count - 1); }"#, r#"table._row.setInstances(1);"#) - .replace(r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, r#"xfa.host.resetData("qty, table");"#); - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("rows.pdf", None, bytes, None).expect("opens"); - let rows = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("amount")).count(); - s.run_javascript(id, "", Some("addRow")).unwrap(); - s.run_javascript(id, "", Some("addRow")).unwrap(); - assert_eq!(rows(&s), 3); - s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("7".into()) }).unwrap(); - // setInstances(1) removes two rows in one script: both go, nothing comes back. - s.run_javascript(id, "", Some("removeRow")).unwrap(); - assert_eq!(rows(&s), 1, "{:?}", s.get(id).unwrap().form.iter().map(|f| &f.name).collect::>()); - let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); - let data = pdfcraft_xfa::data_of(&cos).unwrap(); - assert_eq!(data.count(&pdfcraft_xfa::som_to_path("form[0].page1[0].table[0]"), "row"), 1); - // resetData with names clears those fields (the row's amount too) and nothing else. - s.apply(id, Edit::SetFieldValue { name: "price".into(), value: FieldValue::Text("9".into()) }).unwrap(); - let o = s.run_javascript(id, "", Some("hello")).unwrap(); - let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); - assert!(value(&s, "qty").is_empty(), "{:?} {o:?}", value(&s, "qty")); - assert!(value(&s, "amount").is_empty()); - assert_eq!(value(&s, "price"), vec!["9".to_string()]); -} - -/// Streams in `bytes` (every revision) that hold an XFA datasets packet. -fn datasets_streams(bytes: &Arc>) -> usize { - let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); - cos.object_numbers() - .into_iter() - .filter(|&n| { - cos.try_get(n).ok().is_some_and(|o| match &*o { - pdfcraft_cos::Object::Stream(s) => s.decoded_within(1 << 24).is_ok_and(|b| b.windows(9).any(|w| w == b"datasets>")), - _ => false, - }) - }) - .count() -} - -#[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 - // thousand messages and asking to print, open a link and save. - let tpl = pdfcraft_xfa::fixtures::scripted_template() - .replace( - r#""#, - r#""#, - ) - .replace( - r#"if (qty.rawValue === null) qty.rawValue = 2;"#, - r#"for (var i = 0; i < 1000; i++) xfa.host.messageBox("m" + i); xfa.host.print(); xfa.host.gotoURL("https://example.com/"); app.execMenuItem("SaveAs"); if (qty.rawValue === null) qty.rawValue = 2;"#, - ); - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let started = std::time::Instant::now(); - let id = s.open("hostile.pdf", None, bytes, None).expect("opens"); - assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); - let doc = s.get(id).unwrap(); - assert_eq!(doc.form.iter().find(|f| f.name == "total").unwrap().value, vec!["49999".to_string()]); - // One datasets stream for the scripts' revision, not one per value set. - assert!(datasets_streams(&doc.bytes) <= 3, "{} datasets streams", datasets_streams(&doc.bytes)); - // What opening changed is noted with the form's other XFA notes. - assert!(doc.xfa_warnings.iter().any(|w| w.contains("scripts changed it on opening")), "{:?}", doc.xfa_warnings); - let out = s.take_js_output(id); - assert_eq!(out.alerts.len(), 100, "message boxes are capped"); - assert!(out.errors.iter().any(|e| e.contains("more than 100 messages")), "{:?}", out.errors); - assert!(out.requests.is_empty(), "only a click may print, save or open links: {:?}", out.requests); - assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console); -} - -#[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 - // call) are abandoned at the time limit and turn the form's scripts off. - let tpl = pdfcraft_xfa::fixtures::scripted_template() - .replace( - r#""#, - r#""#, - ) - .replace( - r#"var t = 0; var rows"#, - r#"function f() { for (var j = 0; j < 99999; j++) {} } for (var k = 0; k < 5000; k++) f(); var t = 0; var rows"#, - ); - let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let started = std::time::Instant::now(); - let id = s.open("runaway.pdf", None, bytes, None).expect("opens"); - assert!(started.elapsed() < std::time::Duration::from_secs(4), "{:?}", started.elapsed()); - let out = s.take_js_output(id); - assert!(out.errors.iter().any(|e| e.contains("total") && e.contains("maximum number of iteration")), "{:?}", out.errors); - assert!(out.errors.iter().any(|e| e.contains("abandoned")), "{:?}", out.errors); - assert!(out.errors.iter().any(|e| e.contains("scripts are off")), "{:?}", out.errors); - // The scripts stay off: a change runs nothing more. - let started = std::time::Instant::now(); - s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap(); - assert!(started.elapsed() < std::time::Duration::from_secs(2)); - assert!(s.take_js_output(id).errors.is_empty()); -} - -/// The page view's raster of `page` at 1 px/pt (the document's own render pool). -fn shown(s: &Session, id: DocId, page: usize) -> pdfcraft_render::RenderedPage { - let doc = s.get(id).unwrap(); - doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }]); - let start = std::time::Instant::now(); - loop { - if let Some(p) = doc.renderer.try_recv() { - assert!(p.error.is_none(), "{:?}", p.error); - return p; - } - assert!(start.elapsed() < std::time::Duration::from_secs(20), "no render"); - std::thread::sleep(std::time::Duration::from_millis(5)); - } -} - -/// #260 (page 7): comments without an appearance stream, here a FreeText box (`/C` background, -/// blue `/DA` text) and an Ink stroke, weren't drawn. The page view now draws them from -/// appearances made for display only; the working file and what Save writes stay as they were. -#[test] -fn comments_without_appearances_are_drawn_but_not_saved() { - 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 5 0 R] >>", - b"<< /Type /Annot /Subtype /FreeText /Rect [20 200 180 260] /Contents (Box) /DA (/Helv 10 Tf 0 0 1 rg) /Border [0 0 1] /C [.85 .47 .02] >>", - b"<< /Type /Annot /Subtype /Ink /Rect [20 20 180 120] /InkList [[30 30 100 110 170 30]] /Border [0 0 4] /C [0 .6 0] >>", - ]; - 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 original = Arc::new(bytes); - let mut s = Session::new().with_clock(|| 1_700_000_000); - let id = s.open("comments.pdf", None, original.clone(), None).expect("opens"); - let check = |s: &Session| { - 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 FreeText box spans rows 40..100, the Ink apex is at (100, 190). - assert_eq!(px(100, 90), vec![217, 120, 5, 255], "the FreeText background"); - // On the apex row: Ink is drawn as a curve through its points (#374), level at the apex. - assert_eq!(px(100, 190), vec![0, 153, 0, 255], "the Ink stroke"); - }; - check(&s); - let doc = s.get(id).unwrap(); - assert!(Arc::ptr_eq(&doc.bytes, &original), "the working file is the file as opened"); - assert!(!Arc::ptr_eq(&doc.display, &doc.bytes) && Arc::ptr_eq(&doc.export_source().bytes, &doc.display), "page images show it too"); - // After an edit (a scoped refresh) they are still drawn, and Save writes no appearances. - s.apply(id, rect_comment(0, [150.0, 270.0, 190.0, 290.0])).unwrap(); - check(&s); - let saved = s.save_bytes(id).unwrap(); - let reopened = pdfcraft_cos::Document::open(saved).unwrap(); - for r in [pdfcraft_cos::ObjRef::new(4, 0), pdfcraft_cos::ObjRef::new(5, 0)] { - assert!(reopened.get(r).as_dict().is_some_and(|d| !d.contains(b"AP")), "{r:?} is saved without /AP"); - } -} - -/// Titles a browser writes for a blank page or a pop-up name no document; real titles stay. -#[test] -fn placeholder_titles_are_recognised() { - for t in ["about:blank", "About:Blank", " about:srcdoc ", "blob:https://bank.example/3f2a", "data:text/html,x", "Untitled", "untitled"] { - assert!(is_placeholder_title(t), "{t:?}"); - } - for t in ["Quarterly report", "Untitled report", "About: our company", "Statement", "blank"] { - assert!(!is_placeholder_title(t), "{t:?}"); - } -} - -/// A form whose fields are only page widgets (the `/Fields` list is empty): they are adopted, and -/// the leniency is recorded in the repair log rather than applied silently. -#[test] -fn adopting_page_only_fields_is_noted_as_a_repair() { - 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 200 300] >>", // 2 - "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>", // 3 - "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", // 4 - "<< /Type /Annot /Subtype /Widget /FT /Tx /T (alpha) /Rect [10 200 90 220] /P 3 0 R >>", // 5 - "<< /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 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().with_clock(|| 1_700_000_000); - let id = s.open("orphan.pdf", None, Arc::new(bytes), None).expect("opens"); - let doc = s.get(id).unwrap(); - let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect(); - assert_eq!(names, ["alpha", "beta"]); - assert!(doc.repair_log().iter().any(|l| l.contains("2 fields") && l.contains("page annotations")), "{:?}", doc.repair_log()); -} - -/// A page too large for the renderer at the asked resolution exports at the most it allows (and -/// says at what), as File ▸ Export does; a source that asks for strictness refuses it instead. -#[test] -fn oversized_exports_clamp_and_report_their_dpi_unless_strict() { - let mut s = Session::new().with_clock(|| 1_700_000_000); - // 14,400 pt (200 in) wide: 600 dpi would be 120,000 px. - let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap(); - let id = s.open("wide.pdf", None, bytes, None).unwrap(); - let src = s.get(id).unwrap().export_source(); - let mut ex = export::Exporter::from_source(src.clone()); - let used = ex.dpi_used(0, 600.0); - assert!(used < 600.0 && used > 0.0, "{used}"); - let png = ex.png(0, 600.0).expect("clamped, not refused"); - assert!(png.starts_with(b"\x89PNG")); - assert!((ex.dpi_used(0, 18.0) - 18.0).abs() < 0.01, "a small enough request (3,600 px) is drawn as asked"); - let mut strict = src; - strict.config.reject_oversize = true; - let err = export::Exporter::from_source(strict).png(0, 600.0).unwrap_err(); - assert!(err.contains("exceeds renderer limits"), "{err}"); -} - -#[test] -fn mixed_object_moves_are_one_undo_step_and_survive_save_reopen() { - let (mut s, id) = session_with(1); - let text = AddedText { text: "Added label".into(), rect: [20.0, 240.0, 140.0, 265.0], ..Default::default() }; - s.apply(id, Edit::AddText { page: 0, text }).unwrap(); - let image = image::RgbImage::from_pixel(12, 8, image::Rgb([240, 20, 20])); - let mut png = std::io::Cursor::new(Vec::new()); - image.write_to(&mut png, image::ImageFormat::Png).unwrap(); - s.apply(id, Edit::AddImage { page: 0, rect: Some([40.0, 40.0, 80.0, 70.0]), name: "red.png".into(), bytes: Arc::new(png.into_inner()) }).unwrap(); - let saved = s.save_bytes(id).unwrap(); - s.mark_saved(id, saved.clone(), None).unwrap(); - let before = s.get(id).unwrap().editable_objects(0).unwrap(); - assert_eq!(before.len(), 3, "one native paragraph, one added label and one added image"); - let generation = s.get(id).unwrap().edit_generation(); - let targets = before.iter().map(|o| o.target).collect(); - s.apply(id, Edit::MoveObjects { page: 0, objects: targets, offset: [24.0, -24.0] }).unwrap(); - let doc = s.get(id).unwrap(); - assert!(doc.dirty && doc.edit_generation() > generation); - assert_eq!(doc.can_undo(), Some("Move objects")); - let after = doc.editable_objects(0).unwrap(); - for (a, b) in before.iter().zip(&after) { - assert_eq!(a.text, b.text); - for (i, (x, y)) in a.rect.iter().zip(b.rect).enumerate() { - assert!((y - x - if i % 2 == 0 { 24.0 } else { -24.0 }).abs() < 0.001); - } - } - let moved = s.save_bytes(id).unwrap(); - assert!(moved.starts_with(saved.as_ref()), "an incremental update preserves the original revision"); - let mut again = Session::new(); - let other = again.open("moved.pdf", None, moved.clone(), None).unwrap(); - assert_eq!(again.get(other).unwrap().editable_objects(0).unwrap(), after); - assert_eq!(s.undo(id).unwrap(), "Move objects"); - assert_eq!(s.get(id).unwrap().editable_objects(0).unwrap(), before); - assert_eq!(s.get(id).unwrap().can_undo(), Some("Add image"), "one undo restores all three objects"); - s.redo(id).unwrap(); - assert_eq!(s.get(id).unwrap().editable_objects(0).unwrap(), after); - let mut renderer = pdfcraft_render::PageRenderer::new(moved, Default::default()); - let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); - let pixel = |x: usize, y: usize| &rendered.rgba[(y * rendered.width as usize + x) * 4..][..3]; - assert!(pixel(84, 269)[0] > 200 && pixel(84, 269)[1] < 50, "the added image renders at its new position"); - assert!(pixel(45, 240).iter().all(|v| *v > 240), "the old image position is clear"); -} - -#[test] -fn failed_group_moves_preserve_generation_dirty_state_and_redo_history() { - let (mut s, id) = session_with(1); - let objects: Vec<_> = s.get(id).unwrap().editable_objects(0).unwrap().iter().map(|o| o.target).collect(); - s.apply(id, Edit::MoveObjects { page: 0, objects: objects.clone(), offset: [10.0, 0.0] }).unwrap(); - s.undo(id).unwrap(); - let before = s.save_bytes(id).unwrap(); - let generation = s.get(id).unwrap().edit_generation(); - let dirty = s.get(id).unwrap().dirty; - let mut bad = objects; - bad.push(ObjectTarget { kind: ObjectKind::Image, index: 999 }); - assert!(s.apply(id, Edit::MoveObjects { page: 0, objects: bad, offset: [10.0, 0.0] }).is_err()); - let doc = s.get(id).unwrap(); - assert_eq!(doc.edit_generation(), generation); - assert_eq!(doc.dirty, dirty); - assert!(doc.can_undo().is_none()); - assert_eq!(doc.can_redo(), Some("Move object")); - assert_eq!(s.save_bytes(id).unwrap(), before); - s.redo(id).unwrap(); - assert!(s.get(id).unwrap().dirty); -} - -#[test] -fn group_move_respects_modify_permission_and_display_coordinate_rotation() { - for rotation in [0, 90, 180, 270] { - let (mut s, id) = session_with(1); - s.apply(id, Edit::RotatePages { pages: vec![0], degrees: rotation }).unwrap(); - let doc = s.get(id).unwrap(); - let delta = doc.object_move_offset(0, [17.0, 23.0]).unwrap(); - let info = &doc.info.pages[0]; - let a = info.user_to_view(30.0, 70.0); - let b = info.user_to_view(30.0 + delta[0] as f32, 70.0 + delta[1] as f32); - assert!((b[0] - a[0] - 17.0).abs() < 0.001 && (b[1] - a[1] - 23.0).abs() < 0.001); - } - let (mut s, id) = session_with(1); - let mut p = protection(None, Some("owner")); - p.changes = Changes::None; - s.apply(id, Edit::Protect(p)).unwrap(); - let bytes = s.save_bytes(id).unwrap(); - let mut reader = Session::new(); - let id = reader.open("restricted.pdf", None, bytes, None).unwrap(); - let objects = reader.get(id).unwrap().editable_objects(0).unwrap().iter().map(|o| o.target).collect(); - let generation = reader.get(id).unwrap().edit_generation(); - assert!(reader.apply(id, Edit::MoveObjects { page: 0, objects, offset: [10.0, 0.0] }).is_err()); - let doc = reader.get(id).unwrap(); - assert!(!doc.dirty && doc.can_undo().is_none() && doc.edit_generation() == generation); -} - -#[test] -fn group_moves_preserve_rotated_added_content_raster_geometry() { - let (mut s, id) = session_with(1); - s.apply(id, Edit::AddText { page: 0, text: AddedText { text: "Added label".into(), rect: [20.0, 240.0, 140.0, 265.0], ..Default::default() } }) - .unwrap(); - let image = image::RgbImage::from_fn(20, 10, |x, _| image::Rgb(if x < 10 { [240, 20, 20] } else { [20, 20, 240] })); - let mut png = std::io::Cursor::new(Vec::new()); - image.write_to(&mut png, image::ImageFormat::Png).unwrap(); - s.apply(id, Edit::AddImage { page: 0, rect: Some([40.0, 40.0, 80.0, 70.0]), name: "quadrants.png".into(), bytes: Arc::new(png.into_inner()) }) - .unwrap(); - let AddedContent::Image(mut image) = s.get(id).unwrap().added[1].content.clone() else { panic!() }; - image.flip_h = true; - image.crop = [0.1, 0.0, 0.2, 0.0]; - s.apply(id, Edit::UpdateContent { page: 0, index: 1, content: AddedContent::Image(image) }).unwrap(); - s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); - let render = |bytes| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, Default::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - let before = render(s.get(id).unwrap().bytes.clone()); - let doc = s.get(id).unwrap(); - let offset = doc.object_move_offset(0, [24.0, 36.0]).unwrap(); - let objects = doc.editable_objects(0).unwrap().iter().map(|o| o.target).collect(); - s.apply(id, Edit::MoveObjects { page: 0, objects, offset }).unwrap(); - let after = render(s.get(id).unwrap().bytes.clone()); - assert_eq!((before.width, before.height), (after.width, after.height)); - let mut painted = 0; - for y in 0..before.height as usize { - for x in 0..before.width as usize { - let pos = (y * before.width as usize + x) * 4; - let expected = if x >= 24 && y >= 36 { - let old = ((y - 36) * before.width as usize + x - 24) * 4; - &before.rgba[old..old + 4] - } else { - &[255, 255, 255, 255] - }; - // Raster coverage can round by one intensity level when equivalent placements - // reach the renderer through different text operators. Geometry and image colours - // must still agree across every pixel of the translated page. - assert!(after.rgba[pos..pos + 4].iter().zip(expected).all(|(a, b)| a.abs_diff(*b) <= 2), "pixel {x},{y}"); - painted += usize::from(expected[..3].iter().any(|c| *c < 240)); - } - } - assert!(painted > 500, "the comparison contains real rendered text and image pixels"); -} - -#[test] -fn legacy_passwords_with_turkish_characters_open() { - // RC4-128 (R3): user "ılık€" in PDFDocEncoding (ı = 0x9A, € = 0xA0), owner "Çağlar" - // (PDFDocEncoding has no ğ: written without it, as pdfcraft-crypt writes R2–R4 passwords). - let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); - let params = pdfcraft_cos::NewEncryption { - algorithm: pdfcraft_cos::Algorithm::Rc4_128, - user_password: "ılık€", - owner_password: "Çağlar", - permissions: -1, - encrypt_metadata: true, - seed: [7; 32], - }; - cos.set_encryption(¶ms).unwrap(); - let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap()); - let mut s = Session::new(); - // The renderer used to try only the UTF-8 bytes, and the owner fallback handed it the - // recovered user password as Latin-1: both came back as a wrong password. - for pw in ["ılık€", "Çağlar"] { - let id = s.open("x.pdf", None, bytes.clone(), Some(pw)).unwrap_or_else(|e| panic!("{pw}: {e:?}")); - assert_eq!(page_texts(&s, id), ["Page 1"], "{pw}"); - assert!(s.get(id).unwrap().info.encrypted, "{pw}"); - } - assert!(matches!(s.open("x.pdf", None, bytes, Some("ilik€")), Err(OpenError::WrongPassword))); -} - -#[test] -fn recovered_user_passwords_reach_the_renderer_byte_for_byte() { - assert_eq!(crate::renderer_password(b"pw"), "pw"); - // UTF-8 bytes (qpdf writes "şifre" so; its \xC5\x9F has no PDFDocEncoding reading). - assert_eq!(crate::renderer_password("şifre".as_bytes()), "şifre"); - // PDFDocEncoding bytes: ı = 0x9A, € = 0xA0, Ç = 0xC7. - assert_eq!(crate::renderer_password(&[0x9A, b'l', 0x9A, b'k', 0xA0]), "ılık€"); - assert_eq!(crate::renderer_password(&[0xC7, b'o', b'k']), "Çok"); -} +use std::sync::Arc; + +use super::document::is_placeholder_title; +use super::edit::check_permission; +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 { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + 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(" ")).into_bytes()); + objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); + 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).into_bytes()); + 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()).into_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 +} + +fn session_with(n: usize) -> (Session, DocId) { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens"); + (s, id) +} + +#[test] +fn prepared_open_does_not_mutate_the_session_until_commit() { + fn assert_send() {} + assert_send::(); + + let mut session = Session::new(); + let prepared = session.prepare_open("prepared.pdf", None, Arc::new(fixture(3)), None).expect("prepares"); + assert!(session.get(DocId(1)).is_none()); + + let id = session.commit_open(prepared); + assert_eq!(id, DocId(1)); + assert_eq!(session.get(id).expect("committed document").info.pages.len(), 3); +} + +#[test] +fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { + let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; + let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let bytes = Arc::new(png.into_inner()); + for degrees in [0, 90, 180, 270] { + let (mut session, id) = session_with(1); + session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap(); + let at = session.get(id).unwrap().info.pages[0].view_to_user(100.0, 100.0).map(f64::from); + session + .apply( + id, + Edit::AddCustomStamp { + page: 0, + rect: [at[0], at[1], at[0], at[1]], + name: "Original quadrants".into(), + file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, + author: "Test".into(), + }, + ) + .unwrap(); + let check = |session: &Session, points: [(u32, u32); 4]| { + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + for ((x, y), colour) in points.into_iter().zip(colours) { + let offset = ((y * rendered.width + x) * 4) as usize; + assert_eq!(&rendered.rgba[offset..offset + 4], &colour, "page rotation {degrees}; displayed point ({x}, {y})"); + } + }; + let points = [(80, 90), (120, 90), (80, 110), (120, 110)]; + check(&session, points); + let info = session.get(id).unwrap().info.pages[0].clone(); + let rect = session.get(id).unwrap().info.annotations[0].rect; + let a = info.user_to_view(rect[0], rect[1]); + let b = info.user_to_view(rect[2], rect[3]); + let bounds = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + for (actual, expected) in bounds.into_iter().zip([60.0, 80.0, 140.0, 120.0]) { + assert!((actual - expected).abs() < 0.0001, "displayed bounds {bounds:?}"); + } + session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap(); + check(&session, points); + let resized = info.view_rect_to_user([40.0, 70.0, 160.0, 130.0]).map(f64::from); + session.apply(id, Edit::ResizeAnnotation { page: 0, index: 0, rect: resized }).unwrap(); + let resized_points = [(70, 85), (130, 85), (70, 115), (130, 115)]; + check(&session, resized_points); + session.undo(id).unwrap(); + check(&session, points); + session.redo(id).unwrap(); + check(&session, resized_points); + let from = info.view_to_user(100.0, 100.0); + let to = info.view_to_user(110.0, 115.0); + session.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: f64::from(to[0] - from[0]), dy: f64::from(to[1] - from[1]) }).unwrap(); + check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]); + session.undo(id).unwrap(); + check(&session, resized_points); + session.redo(id).unwrap(); + check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]); + } +} + +/// A blank page turned `degrees`, with one annotation placed from displayed coordinates. +fn rotated_blank(degrees: i64) -> (Session, DocId) { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let blank = session.create_blank(200.0, 300.0, 1).unwrap(); + let id = session.open_new("drawing.pdf", blank).unwrap(); + session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap(); + (session, id) +} + +/// The user-space box of a `w` by `h` point rectangle centred at a displayed point. +fn displayed_rect(info: &pdfcraft_render::PageInfo, vx: f64, vy: f64, w: f64, h: f64) -> [f64; 4] { + let corners = [(vx - w / 2.0, vy - h / 2.0), (vx + w / 2.0, vy + h / 2.0)]; + let mapped: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.view_to_user(*x as f32, *y as f32)).collect(); + [ + f64::from(mapped[0][0].min(mapped[1][0])), + f64::from(mapped[0][1].min(mapped[1][1])), + f64::from(mapped[0][0].max(mapped[1][0])), + f64::from(mapped[0][1].max(mapped[1][1])), + ] +} + +fn render_page(session: &Session, id: DocId) -> pdfcraft_render::RenderedPage { + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 2.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + rendered +} + +fn stamp_pixel(rendered: &pdfcraft_render::RenderedPage, x: u32, y: u32) -> [u8; 4] { + let offset = ((y * rendered.width + x) * 4) as usize; + rendered.rgba[offset..offset + 4].try_into().unwrap() +} + +/// #1398: a Sign Here tag points left as displayed, on every page rotation, including after save. +#[test] +fn rubber_stamps_point_left_as_displayed_on_rotated_pages() { + let (w, h) = StampKind::SignHere.size(); + 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 rect = displayed_rect(&info, 100.0, 40.0, w, h); + let shape = Shape::Stamp { rect, stamp: StampKind::SignHere, by: None }; + 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); + let doc = session.get(id).unwrap(); + let info = doc.info.pages[0].clone(); + let stamp = doc.info.annotations[0].rect; + let a = info.user_to_view(stamp[0], stamp[1]); + let b = info.user_to_view(stamp[2], stamp[3]); + let (left, top) = (a[0].min(b[0]), a[1].min(b[1])); + let (right, bottom) = (a[0].max(b[0]), a[1].max(b[1])); + let scale = 2.0; + let at = |x: f32, y: f32| stamp_pixel(&rendered, (x * scale) as u32, (y * scale) as u32); + // The tag's point is the left edge. The flat end fills the right edge. + let tip = at(left + 4.0, (top + bottom) / 2.0); + let outside = at(left + 4.0, top + 4.0); + let end = at(right - 4.0, top + 4.0); + assert!(tip[0] > 180 && tip[2] < 80, "rotation {degrees}: tip {tip:?} should be the orange point"); + assert!(outside.iter().all(|c| *c > 240), "rotation {degrees}: above the point {outside:?} should be paper"); + assert!(end[0] > 180 && end[2] < 80, "rotation {degrees}: flat end {end:?} should be orange"); + }; + 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); + } +} + +/// #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] +fn typed_text_reads_across_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 top-left of the text at (40, 60): a wide first line, then one letter below + // its start. + let at = info.view_to_user(40.0, 60.0); + let at = [f64::from(at[0]), f64::from(at[1])]; + let rect = text_box_at(degrees, at, 120.0, 2.0 * 12.0 * 1.2 + 6.0).unwrap(); + let shape = Shape::Typewriter { rect, font_size: 12.0 }; + let style = Style::default_for(&shape); + session + .apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style, + shape, + contents: "MMMMMMMM +I" + .into(), + author: "Test".into(), + }), + ) + .unwrap(); + let check = |session: &Session, id: DocId| { + let rendered = render_page(session, id); + let dark = |x: u32, y: u32| stamp_pixel(&rendered, x, y)[..3].iter().all(|c| *c < 128); + let ink: Vec<(u32, u32)> = + (0..rendered.height).flat_map(|y| (0..rendered.width).map(move |x| (x, y))).filter(|&(x, y)| dark(x, y)).collect(); + assert!(!ink.is_empty(), "rotation {degrees}: no text drawn"); + let (x0, x1) = (ink.iter().map(|p| p.0).min().unwrap(), ink.iter().map(|p| p.0).max().unwrap()); + let (y0, y1) = (ink.iter().map(|p| p.1).min().unwrap(), ink.iter().map(|p| p.1).max().unwrap()); + // Rendered at scale 2: the text starts at the clicked point and runs to the right. + assert!((x1 - x0) > 2 * (y1 - y0), "rotation {degrees}: ink {x0}..{x1} x {y0}..{y1} should be wide, not tall"); + assert!(x0.abs_diff(80) < 16 && y0.abs_diff(120) < 16, "rotation {degrees}: text starts at ({x0}, {y0}), not near (80, 120)"); + // The lone "I" is on the second line under the start of the first, not upside down + // (above the end). + let mid = (y0 + y1) / 2; + let lower: Vec = ink.iter().filter(|p| p.1 > mid + 2).map(|p| p.0).collect(); + assert!(!lower.is_empty() && lower.iter().all(|x| *x < x0 + (x1 - x0) / 4), "rotation {degrees}: second line is not under the start"); + }; + 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); + } +} + +/// A one-page PDF whose left half is red and whose right half is white, as displayed. +fn half_red_page() -> Vec { + let content = "1 0 0 rg 0 0 40 40 re f"; + let objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 80 40] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /MediaBox [0 0 80 40] >>".to_vec(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, obj) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(obj); + 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 offset in offsets { + out.extend_from_slice(format!("{offset: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 quadrant_png() -> Arc> { + let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; + let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + Arc::new(png.into_inner()) +} + +fn assert_upright_quadrants(session: &Session, id: DocId, degrees: i64) { + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; + for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip(colours) { + assert_eq!(stamp_pixel(&rendered, x, y), colour, "rotation {degrees}; displayed point ({x}, {y})"); + } +} + +/// #1398: an explicit rectangle keeps the caller's box and turns the picture upright. +#[test] +fn explicit_image_stamps_keep_displayed_orientation_on_rotated_pages() { + let bytes = quadrant_png(); + 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 rect = displayed_rect(&info, 100.0, 100.0, 80.0, 40.0); + session + .apply( + id, + Edit::AddCustomStamp { + page: 0, + rect, + name: "Original quadrants".into(), + file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, + author: "Test".into(), + }, + ) + .unwrap(); + assert_upright_quadrants(&session, id, degrees); + session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap(); + assert_upright_quadrants(&session, id, degrees); + } +} + +/// 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() { + let bytes = Arc::new(half_red_page()); + for (natural, degrees) in [true, false].into_iter().flat_map(|natural| [0, 90, 180, 270].map(|degrees| (natural, degrees))) { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + let at = info.view_to_user(100.0, 100.0).map(f64::from); + let rect = if natural { [at[0], at[1], at[0], at[1]] } else { displayed_rect(&info, 100.0, 100.0, 80.0, 40.0) }; + session + .apply( + id, + Edit::AddCustomStamp { + page: 0, + rect, + name: "Half red".into(), + file: MarkFile { name: "half.pdf".into(), bytes: bytes.clone(), page: 0 }, + author: "Test".into(), + }, + ) + .unwrap(); + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + let left = stamp_pixel(&rendered, 80, 100); + let right = stamp_pixel(&rendered, 120, 100); + assert!(left[0] > 200 && left[1] < 40, "rotation {degrees}, natural {natural}: left {left:?} should be red"); + assert!(right.iter().all(|c| *c > 240), "rotation {degrees}, natural {natural}: right {right:?} should be paper"); + } +} + +/// #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() }; + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + (0..doc.info.pages.len()) + .map(|p| { + let r = r.render(RenderRequestFor::text(p)); + r.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default() + }) + .collect() +} + +/// Shorthand for building text-extraction requests. +struct RenderRequestFor; +impl RenderRequestFor { + fn text(page: usize) -> pdfcraft_render::RenderRequest { + pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() } + } +} + +/// 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); + assert!(s.get(id).unwrap().editable()); + assert!(!s.get(id).unwrap().dirty); + s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.dirty); + assert_eq!(doc.info.pages.len(), 2, "inspection refreshed"); + assert_eq!(doc.can_undo(), Some("Delete page")); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed"); +} + +#[test] +fn metadata_edit_keeps_the_renderer_without_changing_page_content() { + let (mut s, id) = session_with(1); + let request = pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }; + let render = |session: &Session| { + let doc = session.get(id).unwrap(); + doc.renderer.set_queue(vec![request]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if let Some(page) = doc.renderer.try_recv() { + assert!(page.error.is_none(), "{:?}", page.error); + return (doc.renderer.stats(), page.rgba); + } + assert!(std::time::Instant::now() < deadline, "page render did not finish"); + std::thread::yield_now(); + } + }; + + let (before, before_pixels) = render(&s); + assert_eq!(before.page_interpretations, 1); + s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap(); + assert_eq!(s.get(id).unwrap().info_value("Title").as_deref(), Some("Report")); + assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1, "a metadata edit keeps the renderer and its page interpretation"); + let (after, after_pixels) = render(&s); + assert_eq!(after.page_interpretations, 2, "the same renderer serves the second request, so its count continues"); + assert_eq!(before_pixels, after_pixels, "document properties do not change page pixels"); +} + +#[test] +fn metadata_edits_and_history_reuse_the_render_pool() { + let (mut s, id) = session_with(1); + let pixels = shown(&s, id, 0).rgba; + let doc = s.get(id).unwrap(); + let stats = doc.renderer.stats(); + let display = doc.display.clone(); + assert_eq!(stats.page_interpretations, 1); + + s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.title.as_deref(), Some("Report")); + assert_eq!(doc.renderer.stats(), stats); + assert!(Arc::ptr_eq(&doc.display, &display)); + assert_eq!(pixels.as_ref().len(), 200 * 300 * 4); + + s.undo(id).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.title, None); + assert_eq!(doc.renderer.stats(), stats); + assert!(Arc::ptr_eq(&doc.display, &display)); + + s.redo(id).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.title.as_deref(), Some("Report")); + assert_eq!(doc.renderer.stats(), stats); + assert!(Arc::ptr_eq(&doc.display, &display)); +} + +#[test] +fn field_and_comment_edits_still_rebuild_the_render_pool() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let _ = shown(&s, id, 0); + assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1); + + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 0); + let _ = shown(&s, id, 0); + assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 1); + + let shape = Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] }; + s.apply( + id, + Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Test".into() }), + ) + .unwrap(); + assert_eq!(s.get(id).unwrap().renderer.stats().page_interpretations, 0); +} + +#[test] +fn undo_and_redo_restore_exact_states() { + let (mut s, id) = session_with(3); + let original = s.get(id).unwrap().bytes.clone(); + s.apply(id, Edit::MovePages { pages: vec![2], to: 0 }).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); + assert_eq!(s.undo(id).unwrap().as_str(), "Rotate page"); + assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0); + assert_eq!(s.undo(id).unwrap().as_str(), "Move page"); + assert_eq!(s.get(id).unwrap().bytes, original, "undoing everything returns the original bytes"); + assert_eq!(s.undo(id), Err(EditError::NothingToUndo)); + assert_eq!(s.redo(id).unwrap().as_str(), "Move page"); + assert_eq!(page_texts(&s, id), ["Page 3", "Page 1", "Page 2"]); + // A new edit clears the redo stack. + s.apply(id, Edit::InsertBlankPage { at: 0, width: 612.0, height: 792.0 }).unwrap(); + assert_eq!(s.redo(id), Err(EditError::NothingToRedo)); + assert_eq!(s.get(id).unwrap().info.pages.len(), 4); +} + +#[test] +fn failed_edit_changes_nothing() { + let (mut s, id) = session_with(2); + let before = s.get(id).unwrap().bytes.clone(); + let err = s.apply(id, Edit::DeletePages { pages: vec![0, 1] }).unwrap_err(); + assert_eq!(err, EditError::Organize(pdfcraft_organize::OrganizeError::WouldRemoveAllPages)); + let doc = s.get(id).unwrap(); + assert!(!doc.dirty); + assert_eq!(doc.can_undo(), None); + assert_eq!(doc.bytes, before); +} + +#[test] +fn save_is_incremental_stamps_mod_date_and_rebases() { + let (mut s, id) = session_with(2); + let original = s.get(id).unwrap().bytes.clone(); + s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Quarterly report".into() }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(&saved[..original.len()], &original[..], "incremental save keeps the original bytes"); + let text = String::from_utf8_lossy(&saved[original.len()..]); + assert!(text.contains("/ModDate (D:20231114221320Z)"), "mod date stamped: {text}"); + s.mark_saved(id, saved.clone(), Some("/tmp/out/report.pdf".into())).unwrap(); + let doc = s.get(id).unwrap(); + assert!(!doc.dirty); + assert_eq!(doc.name, "report.pdf"); + assert_eq!(doc.info_value("Title").as_deref(), Some("Quarterly report")); + assert_eq!(doc.info.title.as_deref(), Some("Quarterly report")); + // Saving again without edits writes the same bytes; a later edit appends just one revision. + assert_eq!(s.save_bytes(id).unwrap(), saved); + s.apply(id, Edit::RotatePages { pages: vec![1], degrees: -90 }).unwrap(); + let second = s.save_bytes(id).unwrap(); + assert_eq!(&second[..saved.len()], &saved[..]); + let reopened = pdfcraft_cos::Document::open(second).unwrap(); + assert_eq!(reopened.revisions().len(), 3); +} + +/// #1937: a file whose cross-reference data had to be rebuilt saved as its original bytes when +/// nothing was edited, so the damage Document Properties reported as repaired was still in the +/// file. Save writes the rebuilt file in full; reopened, it needs no repair. +#[test] +fn a_repaired_file_is_rewritten_by_save_even_without_edits() { + let good = fixture(2); + // Every offset in the table is off by the inserted line: the chain reads but leads nowhere. + let mut shifted = b"%PDF-1.7\n%shifted\n".to_vec(); + shifted.extend_from_slice(&good[b"%PDF-1.7\n".len()..]); + // No cross-reference table at all. + let mut lost = good.clone(); + lost.truncate(good.windows(9).rposition(|w| w == b"startxref").unwrap()); + lost.extend_from_slice(b"startxref\n999999\n%%EOF\n"); + for (name, damaged) in [("shifted", shifted), ("lost", lost)] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("broken.pdf", Some("/tmp/broken.pdf".into()), Arc::new(damaged.clone()), None).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.info.pages.len(), 2, "{name}"); + assert!(!doc.dirty, "{name}: a repair is not an edit"); + let saved = s.save_bytes(id).unwrap(); + assert_ne!(&saved[..], &damaged[..], "{name}: the damaged bytes are not what Save writes"); + let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(reopened.repair_log().is_empty(), "{name}: {:?}", reopened.repair_log()); + assert_eq!(reopened.revisions().len(), 1, "{name}"); + s.mark_saved(id, saved.clone(), None).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.repair_log().is_empty(), "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.info.pages.len(), 2, "{name}"); + // Sound now: saving again without edits writes the same bytes. + assert_eq!(s.save_bytes(id).unwrap(), saved, "{name}"); + } +} + +/// A damaged file that is signed keeps its bytes on a Save without edits: rewriting it in full +/// to put the repair on disk would invalidate its signatures without a word. +#[test] +fn a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits() { + 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(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + // A trailing `startxref` that points nowhere: the signed revision's bytes are untouched, but + // the file's cross-reference data has to be rebuilt. + let mut damaged = signed.to_vec(); + damaged.extend_from_slice(b"\nstartxref\n999999\n%%EOF\n"); + let mut t = Session::new().with_clock(|| 1_800_000_000); + let tid = t.open("damaged.pdf", Some("/tmp/damaged.pdf".into()), Arc::new(damaged.clone()), None).unwrap(); + let doc = t.get(tid).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{:?}", doc.repair_log()); + assert!(doc.is_signed()); + assert_eq!(&t.save_bytes(tid).unwrap()[..], &damaged[..], "a signed file is not rewritten without edits"); +} + +#[test] +fn undo_still_works_after_save() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(id).unwrap(); + let doc = s.get(id).unwrap(); + assert!(!doc.dirty, "undo back to the saved file is clean"); + assert_eq!(doc.info.pages.len(), 1); + assert_eq!(doc.info.pages[0].rotation, 0); + s.undo(id).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.dirty, "undoing past a save makes the document dirty again"); + assert_eq!(doc.info.pages.len(), 2); +} + +#[test] +fn undo_to_the_opened_file_is_not_dirty() { + let mut session = Session::new().with_clock(|| 1_700_000_000); + let opened = Arc::new(fixture(2)); + let id = session.open("existing.pdf", None, opened.clone(), None).unwrap(); + assert!(!session.get(id).unwrap().dirty); + assert!(session.autosave_snapshots().is_empty()); + + session.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); + assert_eq!(session.get(id).unwrap().info.pages.len(), 1); + session.undo(id).unwrap(); + let document = session.get(id).unwrap(); + assert!(!document.dirty, "the opened bytes are the saved version"); + assert_eq!(document.bytes, opened); + assert_eq!(document.info.pages.len(), 2); + assert!(session.autosave_snapshots().is_empty(), "nothing unsaved to recover"); + + session.redo(id).unwrap(); + assert!(session.get(id).unwrap().dirty); + session.undo(id).unwrap(); + assert!(!session.get(id).unwrap().dirty); +} + +#[test] +fn save_after_undo_retains_the_latest_saved_revision() { + let (mut session, document) = session_with(3); + session.apply(document, Edit::DeletePages { pages: vec![1] }).unwrap(); + let deleted = session.save_bytes(document).unwrap(); + session.mark_saved(document, deleted, None).unwrap(); + session.apply(document, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let rotated = session.save_bytes(document).unwrap(); + session.mark_saved(document, rotated.clone(), None).unwrap(); + let rotated_revisions = pdfcraft_cos::Document::open(rotated.clone()).unwrap().revisions().len(); + + session.undo(document).unwrap(); + session.apply(document, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap(); + let saved = session.save_bytes(document).unwrap(); + assert!(saved.starts_with(rotated.as_slice()), "the saved rotation stays in the file"); + let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(reopened.revisions().len() > rotated_revisions, "the title is a newer revision, not a rewrite of the earlier one"); + assert_eq!(pdfcraft_organize::info(&reopened, "Title").as_deref(), Some("Report")); + + session.mark_saved(document, saved, None).unwrap(); + let doc = session.get(document).unwrap(); + assert_eq!(page_texts(&session, document), ["Page 1", "Page 3"]); + assert_eq!(doc.info.pages[0].rotation, 0, "the undone rotation is the current page"); + assert_eq!(doc.info_value("Title").as_deref(), Some("Report")); +} + +#[test] +fn revert_preserves_the_saved_pages_after_undo() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let saved_bytes = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved_bytes, None).unwrap(); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().dirty); + assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0); + + s.revert(id).unwrap(); + let d = s.get(id).unwrap(); + assert!(!d.dirty && d.can_undo().is_none() && d.can_redo().is_none()); + assert_eq!(page_texts(&s, id), ["Page 2"], "the last saved pages, not the file as opened"); + assert_eq!(d.info.pages[0].rotation, 90); +} + +#[test] +fn full_save_is_readable_and_smaller_after_deletes() { + let (mut s, id) = session_with(5); + s.apply(id, Edit::DeletePages { pages: vec![0, 1, 2] }).unwrap(); + let full = s.save_full_bytes(id).unwrap(); + assert!(full.len() < s.save_bytes(id).unwrap().len()); + let mut s2 = Session::new(); + let id2 = s2.open("full.pdf", None, full, None).unwrap(); + assert_eq!(page_texts(&s2, id2), ["Page 4", "Page 5"]); +} + +#[test] +fn layer_choices_survive_edits() { + // No layers in the fixture: just make sure refresh keeps working with an empty list and + // that unknown layers are rejected. + let (mut s, id) = session_with(1); + assert!(!s.set_layer_visible(id, 0, false)); + s.apply(id, Edit::InsertBlankPage { at: 1, width: 100.0, height: 100.0 }).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 2); +} + +#[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, 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)]; + let changes: Vec<_> = changes.iter().map(|&(op, n)| (op, (n, 0))).collect(); + let changed = apply_layer_state(&mut layers, &[vec![(1, 0), (2, 0)]], &changes, preserve_rb); + (changed, layers.iter().map(|l| l.visible).collect::>()) + }; + // Turning a layer on turns off the rest of its group, unless the action says not to. + assert_eq!(run(&[(On, 2)], true), (true, vec![false, true, true])); + assert_eq!(run(&[(On, 2)], false), (true, vec![true, true, true])); + // A toggle that turns a layer on does the same. + assert_eq!(run(&[(Toggle, 2)], true), (true, vec![false, true, true])); + // Turning a layer off leaves its group alone. + assert_eq!(run(&[(Off, 1)], true), (true, vec![false, false, true])); + // Each toggle sees the state the changes before it left: twice is no change. + assert_eq!(run(&[(Toggle, 3), (Toggle, 3)], true), (false, vec![true, false, true])); + // Later changes win, and groups that aren't layers are skipped. + assert_eq!(run(&[(On, 1), (On, 2), (Off, 99)], true), (true, vec![false, true, true])); + // What counts is the end state: on and back off again is no change. + assert_eq!(run(&[(On, 2), (On, 1), (Off, 99)], true), (false, vec![true, false, true])); +} + +#[test] +fn set_layer_actions_change_what_renders() { + // A red left half on layer Red (on) and a green right half on layer Green (off); the two + // 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 objs = [ + "<< /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]] >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /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 >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /OCG /Name (Red) >>".into(), + "<< /Type /OCG /Name (Green) >>".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()); + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); + // The colours at the middle of the left and right halves, as the document draws now. + let halves = |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 at = |x: usize| { + let i = (50 * p.width as usize + x) * 4; + p.rgba[i..i + 3].to_vec() + }; + (at(25), at(75)) + }; + let (red, green, white) = (vec![255, 0, 0], vec![0, 255, 0], vec![255, 255, 255]); + assert_eq!(halves(&s), (red.clone(), white.clone())); + // Showing Green hides Red, its radio-button partner. + assert!(s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true)); + let visible: Vec<_> = s.get(id).unwrap().info.layers.iter().map(|l| (l.name.as_str(), l.visible)).collect(); + assert_eq!(visible, [("Red", false), ("Green", true)]); + assert_eq!(halves(&s), (white.clone(), green)); + // Running it again changes nothing. + assert!(!s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true)); + // Turning Green off brings nothing back on. + assert!(s.set_layer_state(id, &[(LayerOp::Off, (6, 0))], true)); + assert_eq!(halves(&s), (white.clone(), white)); + 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); + assert_eq!(s.apply(DocId(999), Edit::DeletePages { pages: vec![0] }), Err(EditError::NoDocument)); + assert_eq!(s.undo(DocId(999)), Err(EditError::NoDocument)); + assert!(s.save_bytes(DocId(999)).is_err()); +} + +#[test] +fn batch_is_one_undo_step_and_all_or_nothing() { + let (mut s, id) = session_with(3); + let batch = Edit::Batch { + label: "Change properties".into(), + edits: vec![Edit::SetInfo { key: "Title".into(), value: "T".into() }, Edit::SetInfo { key: "Author".into(), value: "A".into() }], + }; + s.apply(id, batch).unwrap(); + assert_eq!(s.get(id).unwrap().info_value("Author").as_deref(), Some("A")); + assert_eq!(s.undo(id).unwrap().as_str(), "Change properties"); + assert_eq!(s.get(id).unwrap().info_value("Title"), None); + assert_eq!(s.get(id).unwrap().can_undo(), None, "one step"); + // A failing member rolls back the whole batch. + let bad = Edit::Batch { label: "x".into(), edits: vec![Edit::RotatePages { pages: vec![0], degrees: 90 }, Edit::DeletePages { pages: vec![9] }] }; + assert!(s.apply(id, bad).is_err()); + assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 0); +} + +#[test] +fn insert_blank_between_pages_keeps_both_neighbours() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::InsertBlankPage { at: 1, width: 200.0, height: 300.0 }).unwrap(); + assert_eq!(page_texts(&s, id), ["Page 1", "", "Page 2"]); +} + +#[test] +fn combine_extract_split_and_insert_from_file() { + let (mut s, id) = session_with(3); + let other = Arc::new(fixture(2)); + // Combine: the first file then the second. + let combined = s.combine(&[("a.pdf".into(), Arc::new(fixture(3))), ("b.pdf".into(), other.clone())]).unwrap(); + let cid = s.open_new("Combined.pdf", combined).unwrap(); + assert_eq!(page_texts(&s, cid), ["Page 1", "Page 2", "Page 3", "Page 1", "Page 2"]); + assert!(s.get(cid).unwrap().dirty, "a new document starts unsaved"); + assert_eq!(s.get(cid).unwrap().info.outline.len(), 2, "one bookmark per file"); + // Extract pages 3 and 1 into a new document. + let ex = s.extract(id, &[2, 0]).unwrap(); + let eid = s.open_new("Extract.pdf", ex).unwrap(); + assert_eq!(page_texts(&s, eid), ["Page 3", "Page 1"]); + // Split every 2 pages. + let parts = s.split(id, &pdfcraft_organize::SplitBy::PageCount(2)).unwrap(); + assert_eq!(parts.iter().map(|(a, b, _)| (*a, *b)).collect::>(), [(1, 2), (3, 3)]); + // Insert pages from a file, undoably, into the open document. + s.apply( + id, + Edit::InsertPagesFrom { + name: "b.pdf".into(), + bytes: other, + pages: Some(vec![1]), + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }, + ) + .unwrap(); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2", "Page 3"]); + assert_eq!(s.undo(id).unwrap().as_str(), "Insert pages from b.pdf"); + assert_eq!(page_texts(&s, id).len(), 3); + // Garbage sources fail cleanly. + let bad = s.apply( + id, + Edit::InsertPagesFrom { + name: "junk.pdf".into(), + bytes: Arc::new(b"nope".to_vec()), + pages: None, + at: 0, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }, + ); + assert!(matches!(bad, Err(EditError::Source(_)))); +} + +/// A one-page PDF whose form holds one text field named `name` (a widget that is its own field). +fn form_with_field(name: &str) -> Vec { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), // 3 + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V (x) /Rect [60 60 200 80] /P 3 0 R >>"), // 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{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 +} + +/// Inserting pages of a file whose form names a field as the open document's: the incoming +/// Duplicating a page copies the document's own field: the copy keeps its name (Duplicate is +/// not Combine), and nothing about renaming lands in the repair log. +#[test] +fn duplicating_a_page_with_a_field_keeps_the_field_name() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap(); + s.apply(id, Edit::DuplicatePages { pages: vec![0], at: None }).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::>()); + assert!(!doc.repair_log().iter().any(|l| l.contains("Text1-1")), "{:?}", doc.repair_log()); +} + +/// field is renamed to keep the two separate, the repair log says so (warning that scripts +/// were not rewritten), the undo step's label counts the renames, and undo puts the names back. +#[test] +fn insert_from_file_renames_colliding_fields_and_notes_it() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap(); + s.apply( + id, + Edit::InsertPagesFrom { + name: "b.pdf".into(), + bytes: Arc::new(form_with_field("Text1")), + pages: None, + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }, + ) + .unwrap(); + let doc = s.get(id).unwrap(); + let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["Text1", "Text1-1"]); + assert!( + doc.repair_log().iter().any(|l| l.contains("Text1 → Text1-1") && l.contains("were not rewritten")), + "the note names the rename and the scripts caveat: {:?}", + doc.repair_log() + ); + let label = s.undo(id).unwrap(); + assert_eq!(label.as_str(), "Insert pages from b.pdf — 1 field renamed"); + let doc = s.get(id).unwrap(); + let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["Text1"], "undo restores the single original field"); +} + +/// A combine reports the fields it renamed — the tool result tells callers what their scripts +/// must call the field now — and honours a caller's choice to merge instead. +#[test] +fn combine_reports_renamed_fields_and_can_merge() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let sources: Vec = + vec![("a.pdf".into(), Arc::new(form_with_field("Text1")), None), ("b.pdf".into(), Arc::new(form_with_field("Text1")), None)]; + let out = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Rename).unwrap(); + assert_eq!(out.renamed_fields, [("Text1".to_string(), "Text1-1".to_string())]); + let cid = s.open_new("Combined.pdf", out.bytes).unwrap(); + let doc = s.get(cid).unwrap(); + let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["Text1", "Text1-1"]); + let merged = s.combine_unlocked_reporting(&sources, &[], pdfcraft_organize::FieldConflicts::Merge).unwrap(); + assert!(merged.renamed_fields.is_empty(), "merge renames nothing"); +} + +/// The Combine tab's pre-flight over byte sources: what combining them, in order, would rename +/// in each. A file that cannot be opened previews as nothing (its row already shows the problem). +#[test] +fn combine_field_conflicts_previews_over_bytes() { + let s = Session::new().with_clock(|| 1_700_000_000); + let sources: Vec = vec![ + ("a.pdf".into(), Arc::new(form_with_field("Text1")), None), + ("b.pdf".into(), Arc::new(form_with_field("Text1")), None), + ("junk.pdf".into(), Arc::new(b"nope".to_vec()), None), + ]; + let preview = s.combine_field_conflicts(&sources, &[]); + assert_eq!(preview, vec![vec![], vec![("Text1".to_string(), "Text1-1".to_string())], vec![]]); +} + +/// A minimal baseline JPEG (headers only; the data is embedded as is). +fn jpeg_bytes() -> Vec { + let mut v = vec![0xFF, 0xD8]; + v.extend_from_slice(&[0xFF, 0xE0, 0, 16, b'J', b'F', b'I', b'F', 0, 1, 1, 1, 0x01, 0x2C, 0x01, 0x2C, 0, 0]); + v.extend_from_slice(&[0xFF, 0xC0, 0, 17, 8, 0, 2, 0, 3, 3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1]); + v.extend_from_slice(&[0xFF, 0xD9]); + v +} + +#[test] +fn mixed_files_convert_and_combine_in_order() { + let mut s = Session::new(); + let pdf = Arc::new(fixture(2)); + let (kind, same) = s.convert_to_pdf("a.pdf", &pdf).unwrap(); + assert_eq!(kind, SourceKind::Pdf); + assert!(Arc::ptr_eq(&same, &pdf), "a PDF is passed through untouched"); + let (kind, image) = s.convert_to_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap(); + assert_eq!(kind, SourceKind::Image); + let (kind, text) = s.convert_to_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap(); + assert_eq!(kind, SourceKind::Text); + let combined = s.combine_ranges(&[("notes".into(), text, None), ("a".into(), pdf, Some("2".into())), ("scan".into(), image, None)]).unwrap(); + let id = s.open_new("Combined.pdf", combined).unwrap(); + assert_eq!(page_texts(&s, id), ["hello", "Page 2", ""]); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["notes→1", "a→2", "scan→3"]); +} + +#[test] +fn utf16_and_code_page_text_files_convert_to_their_text() { + let mut s = Session::new(); + // Notepad's "Unicode": UTF-16 little endian with a byte order mark. + let mut utf16 = vec![0xFF, 0xFE]; + utf16.extend("Grüße aus Köln".encode_utf16().flat_map(u16::to_le_bytes)); + let (_, pdf) = s.convert_to_pdf("notes.txt", &Arc::new(utf16)).unwrap(); + let id = s.open_new("notes.pdf", pdf).unwrap(); + assert_eq!(page_texts(&s, id), ["Grüße aus Köln"]); + // A UTF-8 byte order mark is not text; Windows-1252 bytes are not replacement characters. + let (_, pdf) = s.convert_to_pdf("menu.txt", &Arc::new(b"\xEF\xBB\xBFCaf\xC3\xA9".to_vec())).unwrap(); + let id = s.open_new("menu.pdf", pdf).unwrap(); + assert_eq!(page_texts(&s, id), ["Café"]); + let (_, pdf) = s.convert_to_pdf("old.txt", &Arc::new(b"Cr\xE8me br\xFBl\xE9e".to_vec())).unwrap(); + let id = s.open_new("old.pdf", pdf).unwrap(); + assert_eq!(page_texts(&s, id), ["Crème brûlée"]); +} + +#[test] +fn a_file_split_around_another_combines_in_order_with_one_bookmark() { + let mut s = Session::new(); + let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2))); + // a's pages 1–2, then b, then a's page 3: one file (group 7) split around another. + let sources: Vec = vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a, Some("3".into()))]; + let combined = s.combine_grouped(&sources, &[7, 9, 7], &[]).unwrap(); + let id = s.open_new("Combined.pdf", combined).unwrap(); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["a→1", "b→3"], "one bookmark for a, at its first page"); + // Without groups each source is a file of its own, as combine_ranges. + let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2))); + let sources: Vec = vec![("a".into(), a.clone(), Some("1".into())), ("b".into(), b, None), ("a".into(), a, Some("3".into()))]; + let combined = s.combine_grouped(&sources, &[], &[]).unwrap(); + let id = s.open_new("Combined 2.pdf", combined).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["a→1", "b→2", "a→4"]); + // A bad range is refused with the file's name. + let bad: Vec = vec![("a".into(), Arc::new(fixture(1)), Some("9".into()))]; + assert!(matches!(s.combine_grouped(&bad, &[1], &[]), Err(EditError::Print(e)) if e.starts_with("a:"))); +} + +#[test] +fn create_multiple_reads_each_source_only_after_the_previous_one_is_released() { + let mut s = Session::new(); + let mut earlier: Vec>> = Vec::new(); + let texts = ["Alpha", "Beta", "Gamma"]; + let sources = texts.iter().map(|text| { + // The combine has finished with every earlier source, so their bytes are gone by now. + assert!(earlier.iter().all(|w| w.upgrade().is_none()), "a source is released before the next is read"); + let (_, pdf) = s.convert_to_pdf(&format!("{text}.txt"), &Arc::new(text.as_bytes().to_vec())).unwrap(); + earlier.push(Arc::downgrade(&pdf)); + Ok::((text.to_string(), pdf, None)) + }); + let combined = s.combine_each(sources).unwrap().unwrap(); + assert!(earlier.iter().all(|w| w.upgrade().is_none())); + let id = s.open_new("Combined.pdf", combined).unwrap(); + assert_eq!(page_texts(&s, id), ["Alpha", "Beta", "Gamma"]); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Alpha→1", "Beta→2", "Gamma→3"]); +} + +#[test] +fn create_multiple_gives_the_bytes_of_combine_ranges_and_refuses_a_bad_source() { + // The two combines are compared byte for byte, and each stamps the clock: pin it. + let s = Session::new().with_clock(|| 1_700_000_000); + let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2))); + let sources: Vec = + vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a.clone(), Some("3".into()))]; + let by_ranges = s.combine_ranges(&sources).unwrap(); + let streamed = s.combine_each(sources.into_iter().map(Ok::<_, EditError>)).unwrap().unwrap(); + assert_eq!(*streamed, *by_ranges); + // A bad range, or a file that isn't a PDF, stops the combine with an error naming that source. + let bad_range: Vec> = vec![Ok(("a".into(), a.clone(), None)), Ok(("c".into(), a.clone(), Some("9".into())))]; + assert!(matches!(s.combine_each(bad_range), Err(EditError::Print(e)) if e.starts_with("c:"))); + let junk: Vec> = vec![Ok(("junk".into(), Arc::new(b"not a pdf".to_vec()), None))]; + assert!(matches!(s.combine_each(junk), Err(EditError::Source(m)) if m.starts_with("junk:"))); + // Nothing to combine is `None`, not an empty document. + assert!(matches!(s.combine_each(Vec::>::new()), Ok(None))); +} + +#[test] +fn a_page_with_a_form_field_shown_twice_keeps_one_field() { + // Regression (found by review): the repeated page copied the whole field again, so the + // result had two fields named "shared", filled in separately. + use pdfcraft_cos::{Dict, Document, Object, PdfString}; + let mut doc = Document::open(Arc::new(fixture(2))).unwrap(); + let pages: Vec<_> = pdfcraft_organize::pages(&doc).unwrap().into_iter().map(|p| p.obj).collect(); + let field = doc.add(Object::Null); + let widgets: Vec<_> = pages + .iter() + .map(|p| { + let mut w = Dict::new(); + w.set(b"Type".to_vec(), Object::name("Annot")); + w.set(b"Subtype".to_vec(), Object::name("Widget")); + w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(100), Object::Int(20)])); + w.set(b"Parent".to_vec(), Object::Ref(field)); + w.set(b"P".to_vec(), Object::Ref(*p)); + doc.add(w) + }) + .collect(); + let mut f = Dict::new(); + f.set(b"FT".to_vec(), Object::name("Tx")); + f.set(b"T".to_vec(), Object::String(PdfString::text("shared"))); + f.set(b"V".to_vec(), Object::String(PdfString::text("Ada"))); + f.set(b"Kids".to_vec(), Object::Array(widgets.iter().copied().map(Object::Ref).collect())); + doc.set(field, Object::Dict(f)); + for (p, w) in pages.iter().zip(&widgets) { + doc.update_dict(*p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(*w)]))).unwrap(); + } + let mut form = Dict::new(); + form.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(field)])); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + // Pages 1, 2, then page 1 again: one file split around nothing but itself. + let s = Session::new(); + let sources: Vec = vec![("form".into(), bytes.clone(), Some("1-2".into())), ("form".into(), bytes, Some("1".into()))]; + let out = s.combine_grouped(&sources, &[1, 1], &[]).unwrap(); + let mut out = Document::open(out).unwrap(); + pdfcraft_forms::set_value(&mut out, "shared", &pdfcraft_forms::FieldValue::Text("Grace".into())).unwrap(); + let fields = pdfcraft_forms::fields(&out); + assert!( + !fields.is_empty() && fields.iter().all(|f| f.name == "shared" && f.value == ["Grace"]), + "one field, one value: {:?}", + fields.iter().map(|f| (&f.name, &f.value)).collect::>() + ); + let catalog = out.get(out.root().unwrap()).as_dict().cloned().unwrap(); + let form = out.resolve(catalog.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + let listed = form.get(b"Fields").and_then(|f| f.as_array()).cloned().unwrap(); + assert_eq!(listed.len(), 1, "the field isn't copied again for the repeated page"); + // Its widgets: one on each of the three pages, each belonging to its own page. + let out_pages: Vec<_> = pdfcraft_organize::pages(&out).unwrap().into_iter().map(|p| p.obj).collect(); + let kids = out.resolve(&listed[0]).as_dict().and_then(|f| f.get(b"Kids").and_then(|k| k.as_array()).cloned()).unwrap(); + assert_eq!(kids.len(), 3); + let mut on: Vec<_> = kids.iter().map(|k| out.resolve(k).as_dict().and_then(|w| w.reference(b"P")).unwrap()).collect(); + on.sort(); + let mut expected = out_pages.clone(); + expected.sort(); + assert_eq!(on, expected, "a widget per page, on its own page"); +} + +#[test] +fn files_that_cannot_be_converted_are_refused_clearly() { + let s = Session::new(); + let err = s.convert_to_pdf("report.zip", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert_eq!( + err, + EditError::Source("report.zip: this file type can't be converted; use a PDF, an image, or a .txt, .csv, .md or .eml file".into()) + ); + // Office and HTML files need the external converters, which are off unless the user turns them on. + for name in ["report.docx", "page.html"] { + let err = s.convert_to_pdf(name, &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert!( + matches!(&err, EditError::Source(m) if m.starts_with(name) && m.contains("can't be converted") && m.contains("External converters")), + "{err}" + ); + } + assert!(matches!(s.convert_to_pdf("empty", &Arc::new(Vec::new())), Err(EditError::Source(_)))); + // Damaged inputs of a known type fail with an error, not a panic. + assert!(s.convert_to_pdf("bad.png", &Arc::new(b"\x89PNG\r\n\x1a\nnope".to_vec())).is_err()); + assert!(s.convert_to_pdf("bad.jpg", &Arc::new(vec![0xFF, 0xD8, 0xFF])).is_err()); + assert!(s.convert_to_pdf("bad.pdf", &Arc::new(b"%PDF-1.7 nope".to_vec())).is_err()); + let err = s.convert_to_pdf("locked.pdf", &protected("pw", "o", -1)).unwrap_err(); + assert_eq!(err, EditError::Source("locked.pdf: it is password-protected".into())); +} + +/// Combine files (#569): images and text become PDFs; PDFs pass through unread, even protected +/// ones (Combine asks for their password) and ones named .pdf without a header. +#[test] +fn files_for_combine_are_made_into_pdfs_and_pdfs_pass_through() { + let s = Session::new(); + let locked = protected("pw", "o", -1); + assert!(Arc::ptr_eq(&s.as_pdf("locked.pdf", &locked).unwrap(), &locked)); + let headless = Arc::new(b"1 0 obj << >> endobj".to_vec()); + assert!(Arc::ptr_eq(&s.as_pdf("old.PDF", &headless).unwrap(), &headless)); + assert!(s.as_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap().starts_with(b"%PDF-")); + assert!(s.as_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap().starts_with(b"%PDF-")); + let err = s.as_pdf("report.bin", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err(); + assert!(matches!(err, EditError::Source(m) if m.starts_with("report.bin: this file type can't be converted"))); +} + +fn protected(user: &str, owner: &str, permissions: i32) -> Arc> { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: user, + owner_password: owner, + permissions, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) +} + +#[test] +fn encrypted_documents_open_edit_and_save_encrypted() { + let bytes = protected("pw", "owner", -1); + let mut s = Session::new(); + assert!(s.open("x.pdf", None, bytes.clone(), None).is_err(), "needs a password"); + let id = s.open("x.pdf", None, bytes.clone(), Some("pw")).unwrap(); + assert!(s.get(id).unwrap().editable(), "{:?}", s.get(id).unwrap().read_only_reason); + assert_eq!(s.get(id).unwrap().security_summary().unwrap().method, "AES, 256-bit"); + assert!(s.get(id).unwrap().info.encrypted, "reported as encrypted"); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(&saved[..bytes.len()], &bytes[..], "incremental"); + assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "still protected after saving"); + let mut s2 = Session::new(); + let id2 = s2.open("x.pdf", None, saved, Some("pw")).unwrap(); + assert_eq!(s2.get(id2).unwrap().info.pages[0].rotation, 90); +} + +#[test] +fn restricted_documents_refuse_changes_unless_opened_by_the_owner() { + let bytes = protected("", "owner", 0b0100); // print only, no password to open + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); + let d = s.get(id).unwrap(); + assert!(!d.allows_assembly() && !d.allows_modification()); + // Print-only permissions clear the copy/extract bit (#568): copying text is refused. + assert!(!d.allows_copying(), "copy must be blocked when the extract bit is clear"); + let summary = d.security_summary().unwrap(); + assert!(!summary.owner && summary.permissions.print() && !summary.permissions.copy()); + assert_eq!(s.apply(id, Edit::DeletePages { pages: vec![0] }), Err(EditError::NotPermitted("page changes"))); + assert_eq!(s.apply(id, Edit::SetInfo { key: "Title".into(), value: "x".into() }), Err(EditError::NotPermitted("changes to the document"))); + assert!(matches!(s.extract(id, &[0]), Err(EditError::NotPermitted(_)))); + assert!(!s.get(id).unwrap().dirty); + // The owner password lifts the restrictions. + let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); + assert!(s.get(oid).unwrap().allows_assembly()); + assert!(s.get(oid).unwrap().allows_copying(), "the owner password lifts the copy restriction"); + // An unencrypted document imposes no copy restriction. + let plain = s.open("plain.pdf", None, Arc::new(fixture(1)), None).unwrap(); + assert!(s.get(plain).unwrap().allows_copying()); + s.apply(oid, Edit::DeletePages { pages: vec![0] }).unwrap(); +} + +/// The Document Open password matrix (#603) for every algorithm: no password and an explicit +/// empty password behave the same, a non-empty user password always prompts even when the owner +/// password is empty, and an empty password that is both user and owner opens as the user, so +/// the document's restrictions still apply. +#[test] +fn empty_passwords_never_lift_restrictions_or_skip_the_open_prompt() { + use pdfcraft_cos::Algorithm::{Aes128, Aes256, Rc4_40, Rc4_128}; + let make = |algorithm, user, owner| { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm, + user_password: user, + owner_password: owner, + permissions: 0b0100, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()) + }; + for alg in [Rc4_40, Rc4_128, Aes128, Aes256] { + let mut s = Session::new(); + // Both empty: opens without a prompt, as the user (print only, no copying). + let both = make(alg, "", ""); + for pw in [None, Some("")] { + let id = s.open("both.pdf", None, both.clone(), pw).unwrap_or_else(|e| panic!("{alg:?} {pw:?}: {e}")); + let d = s.get(id).unwrap(); + assert!(!d.allows_copying() && !d.allows_assembly(), "{alg:?} {pw:?}: the empty password must not open as owner"); + } + // A Document Open password with an empty owner password: always prompts. + let open_pw = make(alg, "secret", ""); + for pw in [None, Some("")] { + assert!(s.open("open.pdf", None, open_pw.clone(), pw).is_err(), "{alg:?} {pw:?}: must ask for the Document Open password"); + } + assert!(s.open("open.pdf", None, open_pw, Some("secret")).is_ok(), "{alg:?}"); + // An ordinary restricted file: opens as the user without a prompt. + let id = s.open("r.pdf", None, make(alg, "", "owner"), None).unwrap(); + assert!(!s.get(id).unwrap().allows_copying(), "{alg:?}"); + } +} + +/// Table 22, bit 5: "Copy or otherwise extract text and graphics" (#568). Snapshots, exports and +/// Save Image As are refused, other permissions are untouched, and the owner password lifts it. +#[test] +fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { + let bytes = protected("", "owner", -1 ^ 0b1_0000); // everything but copying, no password to open + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); + let d = s.get(id).unwrap(); + assert!(!d.allows_copying()); + assert!(d.allows_printing() && d.allows_modification() && d.allows_assembly(), "only copying is withheld"); + assert_eq!(d.check_copying(), Err(EditError::NotPermitted("copying text and images"))); + assert!(d.page_image_file(0, 0).unwrap_err().contains("don't allow copying")); + let extracting = ["edit.snapshot", "export.image", "export.text", "export.docx", "export.pptx", "export.html", "export.rtf", "export.all_images"]; + let enabled = |s: &Session, id, c| commands::is_enabled(commands::command(c).unwrap(), s, Some(id)); + for c in extracting { + assert!(!enabled(&s, id, c), "{c} is disabled"); + } + assert!(enabled(&s, id, "edit.find") && enabled(&s, id, "a11y.check"), "searching and accessibility don't copy"); + assert!(s.get(id).unwrap().autotag_preview(0).is_ok(), "like a11y.check, the auto-tag preview is allowed"); + 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, + // security changes aren't. + let bytes = protected("", "owner", 0b1100); + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes, None).unwrap(); + s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Before".into() }).unwrap(); + assert!(!s.get(id).unwrap().allows_security_change()); + assert!(matches!(s.apply(id, Edit::RemoveProtection), Err(EditError::NotPermitted(_)))); + for wrong in ["", "nope"] { + assert_eq!(s.unlock(id, wrong), Err(EditError::WrongPassword), "{wrong:?}"); + } + assert!(!s.get(id).unwrap().allows_security_change()); + s.unlock(id, "owner").unwrap(); + let d = s.get(id).unwrap(); + assert!(d.allows_security_change() && d.security_summary().unwrap().owner); + assert!(d.dirty, "unlocking leaves the unsaved edit alone"); + assert_eq!(d.can_undo(), Some("Change Title"), "and adds no undo step"); + // Undo and redo don't bring the restrictions back. + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().allows_security_change(), "after undo"); + s.redo(id).unwrap(); + assert!(s.get(id).unwrap().allows_security_change(), "after redo"); + // Neither does saving. + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + assert!(s.get(id).unwrap().allows_security_change(), "after saving"); + s.apply(id, Edit::RemoveProtection).unwrap(); + let plain = s.save_bytes(id).unwrap(); + assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed"); +} + +#[test] +fn remove_security_from_a_file_with_its_encrypt_dictionary_in_the_trailer() { + // MuPDF writes /Encrypt inline in the trailer rather than as an object (#2661). + let mut doc = pdfcraft_cos::Document::open_with_password(protected("user", "owner", -1), Some("owner")).unwrap(); + let r = doc.trailer().reference(b"Encrypt").unwrap(); + let dict = doc.get(r).as_dict().cloned().unwrap(); + doc.trailer_mut().set(b"Encrypt".to_vec(), pdfcraft_cos::Object::Dict(dict)); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("m.pdf", None, bytes, Some("owner")).unwrap(); + s.apply(id, Edit::RemoveProtection).unwrap(); + assert!(!s.get(id).unwrap().info.encrypted); + let plain = s.save_bytes(id).unwrap(); + assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed"); +} + +#[test] +fn unlocking_needs_the_permissions_password_not_the_open_password() { + let mut s = Session::new(); + let id = s.open("r.pdf", None, protected("open", "perms", 0b0100), Some("open")).unwrap(); + assert_eq!(s.unlock(id, "open"), Err(EditError::WrongPassword)); + s.unlock(id, "perms").unwrap(); + assert!(s.get(id).unwrap().allows_security_change()); + // Nothing to unlock: opened by the owner already, or not encrypted. + s.unlock(id, "anything").unwrap(); + let plain = s.open("p.pdf", None, Arc::new(fixture(1)), None).unwrap(); + s.unlock(plain, "anything").unwrap(); +} + +#[test] +fn combining_protected_files_is_refused_clearly() { + let s = Session::new(); + let err = s.combine(&[("a.pdf".into(), protected("pw", "o", -1)), ("b.pdf".into(), Arc::new(fixture(1)))]).unwrap_err(); + assert_eq!(err, EditError::Source("a.pdf: it is password-protected".into())); + let err = s.combine(&[("c.pdf".into(), protected("", "o", 0b0100))]).unwrap_err(); + assert!(matches!(err, EditError::Source(m) if m.contains("don't allow copying pages"))); +} + +#[test] +fn owner_password_of_older_revisions_opens_the_viewer_too() { + for alg in [pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128] { + 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 mut s = Session::new(); + let id = s.open("x.pdf", None, bytes, Some("o")).unwrap_or_else(|e| panic!("{alg:?}: {e}")); + let d = s.get(id).unwrap(); + assert!(d.security_summary().unwrap().owner && d.allows_modification(), "{alg:?}"); + assert_eq!(page_texts(&s, id), ["Page 1"], "{alg:?}: the renderer reads it"); + } +} + +/// 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"); + s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + assert_eq!(s.autosave_snapshots().len(), 1); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().dirty, "the rotation is still unsaved"); + assert_eq!(s.autosave_snapshots().len(), 1, "undo that leaves unsaved work is a change"); + s.undo(id).unwrap(); + assert!(!s.get(id).unwrap().dirty, "undo back to the opened file is saved"); + assert!(s.autosave_snapshots().is_empty(), "a clean document needs no recovery"); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + assert!(s.autosave_snapshots().is_empty(), "saved documents need no recovery"); +} + +#[test] +fn autosave_snapshots_a_new_unsaved_document_once() { + let (mut s, clean) = session_with(1); + let id = s.open_new("Untitled.pdf", Arc::new(fixture(1))).unwrap(); + let snaps = s.autosave_snapshots(); + assert_eq!(snaps.len(), 1, "the new document is unsaved work"); + assert_eq!(snaps[0].doc, id); + assert!(snaps.iter().all(|x| x.doc != clean), "the clean document stays out"); + assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot"); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert_eq!(s.autosave_snapshots().len(), 1, "a later edit is snapshotted again"); +} + +#[test] +fn recovered_documents_reopen_unsaved_at_their_original_path() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::DeletePages { pages: vec![1] }).unwrap(); + let snap = s.autosave_snapshots().remove(0); + // A new session after a crash. + let mut s2 = Session::new(); + let rid = s2.open(snap.name.clone(), None, snap.bytes.clone(), None).unwrap(); + s2.mark_recovered(rid, Some("/docs/report.pdf".into())); + let d = s2.get(rid).unwrap(); + assert!(d.dirty); + assert_eq!(d.path.as_deref(), Some("/docs/report.pdf")); + assert_eq!(d.info.pages.len(), 1, "the edit survived"); + assert!(s2.autosave_snapshots().is_empty(), "already in the recovery store"); +} + +fn outline_titles(items: &[pdfcraft_render::OutlineItem]) -> Vec { + items + .iter() + .map(|o| { + if o.children.is_empty() { + format!("{}→{}", o.title, o.page.map_or(0, |p| p + 1)) + } else { + format!("{}→{}[{}]", o.title, o.page.map_or(0, |p| p + 1), outline_titles(&o.children).join(",")) + } + }) + .collect() +} + +#[test] +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, 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(); + s.apply(id, Edit::RenameBookmark { path: vec![2], title: "Finish".into() }).unwrap(); + s.apply(id, Edit::SetBookmarkPage { path: vec![1], page: 1 }).unwrap(); + assert_eq!(titles(&s), ["Détail→2", "Start→2", "Finish→3"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Set bookmark destination")); + s.undo(id).unwrap(); + assert_eq!(titles(&s), ["Détail→2", "Start→1", "Finish→3"]); + s.apply(id, Edit::DeleteBookmark { path: vec![0] }).unwrap(); + assert!(matches!(s.apply(id, Edit::DeleteBookmark { path: vec![7] }), Err(EditError::Bookmark(_)))); + + let saved = s.save_bytes(id).unwrap(); + let mut again = Session::new(); + let id2 = again.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(outline_titles(&again.get(id2).unwrap().info.outline), ["Start→1", "Finish→3"]); +} + +/// Two pages tagged with an H1 on the first and an H2 on the second. +fn tagged_headings_fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 7 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 200] /Resources << /Font << /F1 6 0 R >> >> >>", + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Length 52 >>\nstream\n/H1 << /MCID 0 >> BDC BT /F1 12 Tf (Intro) Tj ET EMC\nendstream", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /StructTreeRoot /K [8 0 R 9 0 R] >>", + "<< /S /H1 /Pg 3 0 R /K 0 >>", + "<< /S /H2 /Pg 4 0 R /ActualText (Details) >>", + ]; + 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 +} + +#[test] +fn bookmarks_from_structure_nest_the_headings_and_undo() { + let mut s = Session::new(); + let id = s.open("tagged.pdf", None, Arc::new(tagged_headings_fixture()), None).unwrap(); + s.apply(id, Edit::BookmarksFromStructure).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Intro→1[Details→2]]"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from structure")); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().info.outline.is_empty()); + + // Untagged: a clear error and nothing changes. + let (mut s, id) = session_with(2); + let err = s.apply(id, Edit::BookmarksFromStructure).unwrap_err(); + assert_eq!(err.to_string(), "this document has no tagged headings to make bookmarks from"); + 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); + let labels = |s: &Session| s.get(id).unwrap().info.pages.iter().map(|p| p.label.clone()).collect::>(); + use pdfcraft_organize::LabelStyle; + s.apply(id, Edit::NumberPages { from: 0, to: 1, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 }).unwrap(); + s.apply(id, Edit::NumberPages { from: 2, to: 4, style: LabelStyle::Decimal, prefix: "§".into(), first: 10 }).unwrap(); + // The inspector (lopdf-based, independent) formats them the same way. + assert_eq!(labels(&s), ["i", "ii", "§10", "§11", "§12"]); + s.undo(id).unwrap(); + assert_eq!(labels(&s), ["i", "ii", "3", "4", "5"]); + assert!(s.apply(id, Edit::NumberPages { from: 3, to: 9, style: LabelStyle::Decimal, prefix: String::new(), first: 1 }).is_err()); +} + +/// RGBA of the pixel at PDF point (x, y) on `page`, rendered at 1 px/pt (page height 300). +fn pixel(s: &Session, id: DocId, page: usize, x: u32, y: u32) -> [u8; 4] { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let out = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let i = (((300 - y) * out.width + x) * 4) as usize; + out.rgba[i..i + 4].try_into().unwrap() +} + +fn rect_comment(page: usize, rect: [f64; 4]) -> Edit { + Edit::AddAnnotation(NewAnnotation { + page, + shape: Shape::Rectangle { rect }, + style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) }, + contents: "Look here".into(), + author: "Reviewer".into(), + }) +} + +#[test] +fn comments_are_added_drawn_threaded_and_undone() { + let (mut s, id) = session_with(2); + assert_eq!(pixel(&s, id, 1, 100, 100), [255, 255, 255, 255]); + s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add rectangle")); + let px = pixel(&s, id, 1, 100, 100); + assert!(px[0] > 200 && px[1] < 40 && px[2] < 40, "the rectangle is drawn: {px:?}"); + let a = &s.get(id).unwrap().info.annotations; + assert_eq!(a.len(), 1); + assert_eq!((a[0].page, a[0].index, a[0].author.as_deref(), a[0].contents.as_deref()), (1, 0, Some("Reviewer"), Some("Look here"))); + assert_eq!(a[0].modified.as_deref().map(|m| m.contains("2023")), Some(true), "dated by the session clock"); + let nm = a[0].name.clone().expect("has an /NM"); + assert_eq!(nm.len(), 36); + + s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Done".into(), author: "Ada".into() }).unwrap(); + s.apply(id, Edit::SetAnnotationStatus { page: 1, index: 0, state: ReviewState::Completed, author: "Ada".into() }).unwrap(); + let a = &s.get(id).unwrap().info.annotations; + assert_eq!(a.len(), 3); + assert!(a.iter().filter(|r| r.in_reply_to.as_deref() == Some(nm.as_str())).count() == 2); + assert!(a.iter().any(|r| r.state.as_deref() == Some("Completed"))); + assert_ne!(a[1].name, a[2].name, "every comment gets its own id"); + + s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 50.0, dy: 0.0 }).unwrap(); + assert_eq!(pixel(&s, id, 1, 100, 100), [255, 255, 255, 255]); + let px = pixel(&s, id, 1, 150, 100); + assert!(px[0] > 200 && px[1] < 40, "moved: {px:?}"); + + s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).unwrap(); + assert!(s.get(id).unwrap().info.annotations.is_empty(), "replies go with their parent"); + for _ in 0..4 { + s.undo(id).unwrap(); + } + assert_eq!(s.get(id).unwrap().info.annotations.len(), 1); + let px = pixel(&s, id, 1, 100, 100); + assert!(px[0] > 200 && px[1] < 40, "back where it was: {px:?}"); +} + +#[test] +fn highlight_multiplies_over_text() { + let (mut s, id) = session_with(1); + // "Page 1" is drawn at 20,150 in 24 pt Helvetica. + let quad = [18.0, 172.0, 100.0, 172.0, 18.0, 145.0, 100.0, 145.0]; + s.apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + shape: Shape::TextMarkup { kind: Markup::Highlight, quads: vec![quad] }, + style: Style { color: [1.0, 1.0, 0.0], ..Style::default() }, + contents: String::new(), + author: String::new(), + }), + ) + .unwrap(); + let a = &s.get(id).unwrap().info.annotations[0]; + assert_eq!(a.subtype, "Highlight"); + assert_eq!(a.quads, vec![quad.map(|v| v as f32)]); + // Background inside the quad turns yellow; text stays dark (multiply). + let bg = pixel(&s, id, 0, 19, 170); + assert!(bg[0] > 240 && bg[1] > 240 && bg[2] < 30, "{bg:?}"); +} + +#[test] +fn comment_permission_is_enforced() { + let mut cos = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + // Owner "own", empty user password, everything allowed except commenting (bit 6). + let params = pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "own", + permissions: !(1 << 5), + encrypt_metadata: true, + seed: [7; 32], + }; + cos.set_encryption(¶ms).unwrap(); + let bytes = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("locked.pdf", None, Arc::new(bytes), None).unwrap(); + let err = s.apply(id, rect_comment(0, [10.0, 10.0, 50.0, 50.0])).unwrap_err(); + assert_eq!(err, EditError::NotPermitted("comments")); +} + +#[test] +fn saving_a_password_protected_document_rebases_on_it() { + let bytes = protected("pw", "owner", -1); + let mut s = Session::new(); + let id = s.open("x.pdf", None, bytes, Some("pw")).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved.clone(), None).expect("rebases on the encrypted file"); + assert!(!s.get(id).unwrap().dirty); + s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap(); + let again = s.save_bytes(id).unwrap(); + assert_eq!(&again[..saved.len()], &saved[..], "the next save appends"); +} + +fn protection(open: Option<&str>, perms: Option<&str>) -> Protection { + Protection { open_password: open.map(Into::into), permissions_password: perms.map(Into::into), ..Protection::default() } +} + +#[test] +fn protect_with_an_open_password_then_save_reopen_and_undo() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::Protect(protection(Some("secret"), None))).unwrap(); + let d = s.get(id).unwrap(); + assert!(d.info.encrypted, "the working file is encrypted: {:?}", d.info.warnings); + assert_eq!(d.security_summary().unwrap().method, "AES, 256-bit"); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"], "still viewable in this session"); + let saved = s.save_bytes(id).unwrap(); + assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "needs the password"); + s.mark_saved(id, saved.clone(), None).unwrap(); + // Further edits keep working and saving stays encrypted. + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let again = s.save_bytes(id).unwrap(); + let mut s2 = Session::new(); + assert!(s2.open("p.pdf", None, again.clone(), None).is_err()); + let id2 = s2.open("p.pdf", None, again, Some("secret")).unwrap(); + assert_eq!(s2.get(id2).unwrap().info.pages[0].rotation, 90); + // Nothing is restricted without a permissions password: the opener may remove security. + assert!(s2.get(id2).unwrap().allows_security_change()); + s2.apply(id2, Edit::RemoveProtection).unwrap(); + let plain = s2.save_bytes(id2).unwrap(); + assert!(pdfcraft_cos::Document::open(plain).is_ok()); +} + +#[test] +fn permissions_password_restricts_others_but_not_this_session() { + let (mut s, id) = session_with(1); + let p = Protection { printing: Printing::Low, changes: Changes::CommentFillSign, copy: false, ..protection(None, Some("boss")) }; + s.apply(id, Edit::Protect(p.clone())).unwrap(); + assert!(s.get(id).unwrap().allows_modification(), "the author keeps full rights"); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved.clone(), None).unwrap(); + assert!(s.get(id).unwrap().allows_modification(), "…also after saving (re-opened as owner)"); + // Someone else opens it without a password: restricted as chosen. + let mut s2 = Session::new(); + let id2 = s2.open("r.pdf", None, saved.clone(), None).unwrap(); + let perm = s2.get(id2).unwrap().permissions().unwrap(); + assert!(perm.print() && !perm.print_high_quality() && perm.annotate() && perm.fill_forms() && !perm.copy() && !perm.modify()); + assert!(perm.extract_for_accessibility()); + assert!(!s2.get(id2).unwrap().allows_security_change()); + assert_eq!(s2.apply(id2, Edit::RemoveProtection), Err(EditError::NotPermitted("changing security"))); + assert!(s2.get(id2).unwrap().allows_annotation(), "commenting was allowed"); + // The owner password lifts everything. + let id3 = s2.open("r.pdf", None, saved, Some("boss")).unwrap(); + 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); + let plain = s.get(id).unwrap().bytes.clone(); + assert_eq!(crate::combine_source_check(&plain, None), Ok(())); + // Page assembly withheld (Changes::None) without a password to open it. + let p = Protection { changes: Changes::None, ..protection(None, Some("boss")) }; + s.apply(id, Edit::Protect(p)).unwrap(); + let locked = s.save_bytes(id).unwrap(); + assert_eq!(crate::combine_source_check(&locked, None), Err(crate::SourceProblem::NotPermitted)); + let (mut s, id) = session_with(1); + s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap(); + let secret = s.save_bytes(id).unwrap(); + assert_eq!(crate::combine_source_check(&secret, None), Err(crate::SourceProblem::Password)); + assert_eq!(crate::combine_source_check(&secret, Some("nope")), Err(crate::SourceProblem::WrongPassword)); + // Its open password reads it, but its permissions withhold assembling pages: the owner's + // password allows it. + assert_eq!(crate::combine_source_check(&secret, Some("pw")), Err(crate::SourceProblem::NotPermitted)); + assert_eq!(crate::combine_source_check(&secret, Some("owner")), Ok(())); + // The permissions password lifts the restriction on copying pages. + assert_eq!(crate::combine_source_check(&locked, Some("boss")), Ok(())); + // Combining with the passwords: the result opens without any. + let s = Session::new(); + let sources = vec![("secret".to_string(), secret, None), ("locked".to_string(), locked, None)]; + assert!(s.combine_ranges(&sources).is_err(), "no passwords, no combining"); + let out = s.combine_unlocked(&sources, &[Some("owner"), Some("boss")]).unwrap(); + let combined = pdfcraft_cos::Document::open(out).unwrap(); + assert!(combined.permissions().is_none(), "the combined file is not encrypted"); + assert_eq!(pdfcraft_organize::page_count(&combined).unwrap(), 2); + assert!(matches!(crate::combine_source_check(&Arc::new(b"not a pdf".to_vec()), None), Err(crate::SourceProblem::Unreadable(_)))); +} + +#[test] +fn protection_is_validated_undoable_and_never_logged() { + let (mut s, id) = session_with(1); + assert!(matches!(s.apply(id, Edit::Protect(protection(None, None))), Err(EditError::Protection(_)))); + assert!(matches!(s.apply(id, Edit::Protect(protection(Some("same"), Some("same")))), Err(EditError::Protection(_)))); + let rc4 = Protection { algorithm: pdfcraft_cos::Algorithm::Rc4_128, ..protection(Some("pässword"), None) }; + assert!(matches!(s.apply(id, Edit::Protect(rc4)), Err(EditError::Protection(_)))); + assert!(!format!("{:?}", Edit::Protect(protection(Some("hunter2"), Some("x")))).contains("hunter2")); + s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap(); + assert!(s.get(id).unwrap().info.encrypted); + s.undo(id).unwrap(); + assert!(!s.get(id).unwrap().info.encrypted, "undo removes the pending protection"); + assert!(s.get(id).unwrap().security_summary().is_none()); + s.redo(id).unwrap(); + assert!(s.get(id).unwrap().info.encrypted); + assert_eq!(page_texts(&s, id), ["Page 1"]); +} + +/// Protect a fresh document with `p` as the app does: the edit's result and, when it is +/// accepted, how the saved file authenticates with no password, the open password and the +/// permissions password (`None`: it doesn't open). +fn protect_and_reopen(p: &Protection) -> (Result<(), EditError>, Option<[Option; 3]>) { + let (mut s, id) = session_with(1); + let result = s.apply(id, Edit::Protect(p.clone())); + let reopened = result.is_ok().then(|| { + let saved = s.save_bytes(id).unwrap(); + let auth = |pw: Option<&str>| pdfcraft_cos::Document::open_with_password(saved.clone(), pw).ok()?.security().map(|h| h.auth()); + [auth(None), auth(p.open_password.as_deref()), auth(p.permissions_password.as_deref())] + }); + (result, reopened) +} + +#[test] +fn passwords_that_encryption_would_make_empty_or_equal_are_refused() { + use pdfcraft_cos::Algorithm::{Aes128, Rc4_128}; + use pdfcraft_cos::Auth::{Owner, User}; + let empty = "passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"; + let same = "the open password and the permissions password must be different (PDF readers treat these two as the same password)"; + // AES-256 keys on the SASLprep form of a password (soft hyphens dropped, then NFKC), RC4 and + // AES-128 on its first 32 bytes. Written as typed, these would make a file that opens + // without a password, or whose open password lifts the restrictions. + let (a31, a32) = ("a".repeat(31), "a".repeat(32)); + let (long1, long2) = (format!("{a32}1"), format!("{a32}2")); + // The 32-byte padding starts with "(": 31 characters and the same followed by "(" are one key. + let padded = format!("{a31}("); + let cases = [ + ("soft hyphen open password", protection(Some("\u{AD}"), None), empty), + ("soft hyphen permissions password", protection(None, Some("\u{AD}")), empty), + ("soft hyphen permissions password beside an open one", protection(Some("0"), Some("\u{AD}")), empty), + ("fullwidth A, then A", protection(Some("\u{FF21}"), Some("A")), same), + ("A, then fullwidth A", protection(Some("A"), Some("\u{FF21}")), same), + ("fi ligature, then fi", protection(Some("\u{FB01}"), Some("fi")), same), + ("fi, then fi ligature", protection(Some("fi"), Some("\u{FB01}")), same), + ("RC4-128, same first 32 bytes", Protection { algorithm: Rc4_128, ..protection(Some(&long1), Some(&long2)) }, same), + ("AES-128, same first 32 bytes", Protection { algorithm: Aes128, ..protection(Some(&long1), Some(&long2)) }, same), + ("RC4-128, same once padded", Protection { algorithm: Rc4_128, ..protection(Some(&a31), Some(&padded)) }, same), + ("AES-128, same once padded", Protection { algorithm: Aes128, ..protection(Some(&a31), Some(&padded)) }, same), + ]; + let got: Vec<_> = cases.iter().map(|(case, p, _)| (*case, protect_and_reopen(p))).collect(); + let want: Vec<_> = cases.iter().map(|(case, _, message)| (*case, (Err(EditError::Protection(message.to_string())), None))).collect(); + assert_eq!(got, want, "each is refused, so no file is written"); + // Passwords that are empty or equal as typed keep their messages. + let (mut s, id) = session_with(1); + assert_eq!(s.apply(id, Edit::Protect(protection(Some(""), None))), Err(EditError::Protection("passwords can't be empty".into()))); + assert_eq!( + s.apply(id, Edit::Protect(protection(Some("same"), Some("same")))), + Err(EditError::Protection("the open password and the permissions password must be different".into())) + ); + // Passwords that stay different are fine: only the permissions password gives owner rights. + let distinct = [ + protection(Some("A"), Some("\u{FF21}\u{FF22}")), + Protection { algorithm: Rc4_128, ..protection(Some(&format!("1{a32}")), Some(&format!("2{a32}"))) }, + Protection { algorithm: Aes128, ..protection(Some(&format!("1{a32}")), Some(&format!("2{a32}"))) }, + ]; + for p in &distinct { + assert_eq!(protect_and_reopen(p), (Ok(()), Some([None, Some(User), Some(Owner)])), "{:?}", p.algorithm); + } +} + +/// A one-page form: a text field and a check box (with appearance states). +fn form_fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 250 180 270] /P 3 0 R /MK << /BC [0 0 0] >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (ok) /V /Off /AS /Off /Rect [20 200 35 215] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Length 0 >>\nstream\n\nendstream", + ]; + 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 +} + +/// 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 +/// viewers to redraw it. +#[test] +fn a_value_its_font_cannot_draw_is_reported_by_the_edit_that_drew_it() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); + let fill = |s: &mut Session, name: &str, v: FieldValue| s.apply(id, Edit::SetFieldValue { name: name.into(), value: v }); + 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]); + 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(); + assert_eq!(s.get(id).unwrap().edit_notices.len(), 1, "a new value is reported again"); + assert!(fill(&mut s, "missing", FieldValue::Text("x".into())).is_err()); + assert!(s.get(id).unwrap().edit_notices.is_empty(), "no stale notice after a failed edit"); + fill(&mut s, "name", FieldValue::Text("Ada".into())).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + assert!(pdfcraft_forms::need_appearances(&saved)); +} + +#[test] +fn filling_a_form_shows_saves_and_undoes() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); + assert_eq!(s.get(id).unwrap().form.len(), 2); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada Lovelace".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "ok".into(), value: FieldValue::Check(true) }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.can_undo(), Some("Fill in ok")); + assert_eq!(d.form[0].value, ["Ada Lovelace"]); + assert_eq!(d.form[1].value, ["Yes"]); + assert_eq!(page_texts(&s, id), ["Ada Lovelace"], "the new appearance shows the text"); + let err = s.apply(id, Edit::SetFieldValue { name: "missing".into(), value: FieldValue::Text("x".into()) }).unwrap_err(); + assert_eq!(err.to_string(), "there is no field named \"missing\""); + s.apply(id, Edit::ResetForm { names: None }).unwrap(); + assert!(s.get(id).unwrap().form.iter().all(|f| f.value.is_empty())); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form[0].value, ["Ada Lovelace"]); + let saved = s.save_bytes(id).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("f.pdf", None, saved, None).unwrap(); + assert_eq!(s2.get(id2).unwrap().form[1].value, ["Yes"]); +} + +#[test] +fn comment_edits_refresh_the_list_exactly_as_a_full_inspection_would() { + let (mut s, id) = session_with(3); + let add = |page: usize, shape: Shape, contents: &str| { + Edit::AddAnnotation(NewAnnotation { + page, + shape, + style: Style::default_for(&Shape::Rectangle { rect: [0.0; 4] }), + contents: contents.into(), + author: "Ada".into(), + }) + }; + s.apply(id, add(0, Shape::Rectangle { rect: [10.0, 10.0, 60.0, 60.0] }, "box")).unwrap(); + s.apply(id, add(1, Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 170.0, 90.0, 170.0, 20.0, 150.0, 90.0, 150.0]] }, "")) + .unwrap(); + s.apply(id, add(1, Shape::TextBox { rect: [10.0, 200.0, 150.0, 240.0], font_size: 11.0 }, "Text box")).unwrap(); + s.apply(id, add(2, Shape::Note { at: [100.0, 280.0], icon: NoteIcon::Comment }, "Sticky")).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 2, index: 0, text: "Reply".into(), author: "Bob".into() }).unwrap(); + s.apply(id, Edit::SetAnnotationStatus { page: 2, index: 0, state: ReviewState::Accepted, author: "Bob".into() }).unwrap(); + s.apply(id, Edit::MarkAnnotation { page: 2, index: 0, marked: true, author: "Bob".into() }).unwrap(); + s.apply(id, Edit::LockAnnotation { page: 1, index: 1, locked: true }).unwrap(); + s.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: 5.0, dy: 5.0 }).unwrap(); + s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).unwrap(); + s.undo(id).unwrap(); + let d = s.get(id).unwrap(); + let full = pdfcraft_render::inspect(d.bytes.clone(), None).unwrap(); + assert_eq!(format!("{:?}", d.info.annotations), format!("{:?}", full.annotations)); + assert_eq!(d.info.file_size, full.file_size); + assert_eq!(d.info.annotations.len(), 7); + assert!(d.info.annotations.iter().any(|a| a.locked)); +} + +#[test] +fn form_edits_refresh_field_values_without_a_full_inspection() { + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "ok".into(), value: FieldValue::Check(true) }).unwrap(); + let d = s.get(id).unwrap(); + let full = pdfcraft_render::inspect(d.bytes.clone(), None).unwrap(); + let values = |fs: &[pdfcraft_render::Field]| fs.iter().map(|f| (f.name.clone(), f.value.clone())).collect::>(); + assert_eq!(values(&d.info.fields), values(&full.fields)); + assert_eq!(values(&d.info.fields), [("name".to_string(), Some("Ada".to_string())), ("ok".to_string(), Some("Yes".to_string()))]); +} + +#[test] +fn form_refresh_keeps_first_value_when_field_names_repeat() { + // Two widgets share one name; the refresh reads the first. + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V (first) /Rect [20 250 180 270] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (dup) /V (second) /Rect [20 220 180 240] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (other) /Rect [20 190 180 210] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + 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("dup.pdf", None, Arc::new(bytes), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("x".into()) }).unwrap(); + let dup = s.get(id).unwrap().info.fields.iter().find(|f| f.name == "dup").unwrap().value.clone(); + assert_eq!(dup.as_deref(), Some("first")); +} + +#[test] +fn multiselect_choice_indices_stay_sorted_through_engine_save_and_reopen() { + let (mut s, id) = session_with(1); + s.apply( + id, + Edit::AddField { + page: 0, + rect: [20.0, 180.0, 180.0, 240.0], + kind: NewField::List { options: vec!["Cheese".into(), "Ham".into(), "Olives".into()], multi: true }, + name: Some("toppings".into()), + }, + ) + .unwrap(); + let chosen = vec!["Olives".to_string(), "Ham".to_string()]; + s.apply(id, Edit::SetFieldValue { name: "toppings".into(), value: FieldValue::Choice(chosen.clone()) }).unwrap(); + assert_eq!(s.get(id).unwrap().form[0].value, chosen, "the live engine keeps selection order"); + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let all = pdfcraft_forms::fields(&cos); + let list = all.iter().find(|field| field.name == "toppings").unwrap(); + let object = cos.get(list.obj); + let indices = object.as_dict().unwrap().get(b"I").unwrap().as_array().unwrap(); + assert_eq!(indices.iter().map(|index| index.as_int().unwrap()).collect::>(), [1, 2]); + assert_eq!(list.value, chosen, "sorted /I does not reorder /V"); + let reopened = s.open("reopened.pdf", None, saved, None).unwrap(); + assert_eq!(s.get(reopened).unwrap().form[0].value, chosen, "reopening keeps selection order"); +} + +#[test] +fn preparing_a_form_adds_renames_and_deletes_fields() { + let (mut s, id) = session_with(1); + assert!(s.get(id).unwrap().form.is_empty()); + let add = |kind: NewField, y: f64| Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: None }; + s.apply(id, add(NewField::Text { multiline: false }, 250.0)).unwrap(); + s.apply(id, add(NewField::CheckBox, 200.0)).unwrap(); + s.apply(id, add(NewField::Text { multiline: false }, 150.0)).unwrap(); + let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + assert_eq!(names(&s), ["Text1", "Check Box1", "Text2"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add field")); + let props = FieldProps { name: Some("email".into()), tooltip: Some("Your e-mail".into()), required: Some(true), ..Default::default() }; + s.apply(id, Edit::SetFieldProps { name: "Text2".into(), props: Box::new(props) }).unwrap(); + s.apply(id, Edit::DeleteField { name: "Check Box1".into() }).unwrap(); + assert_eq!(names(&s), ["Text1", "email"]); + s.apply(id, Edit::SetFieldValue { name: "email".into(), value: FieldValue::Text("ada@example.org".into()) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("f.pdf", None, saved, None).unwrap(); + let d = s2.get(id2).unwrap(); + assert_eq!(d.form.iter().map(|f| f.name.as_str()).collect::>(), ["Text1", "email"]); + assert_eq!(d.form[1].value, ["ada@example.org"]); + assert!(page_texts(&s2, id2).iter().any(|t| t.contains("ada@example.org"))); + s.undo(id).unwrap(); + s.undo(id).unwrap(); + 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 {} {stream_entries} >>\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 +} + +/// Put a redaction mark over `needle` where page 1 shows it, as Find text and redact does. +fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default()); + let text = r.render(RenderRequestFor::text(0)).text.expect("the page has text"); + let range = text.find(needle).pop().expect("the page shows the text"); + let info = &doc.info.pages[0]; + let quads = text.line_rects(range).into_iter().map(|v| pdfcraft_annot::rect_quad(info.view_rect_to_user(v).map(f64::from))).collect(); + let shape = Shape::Redact { quads, overlay: String::new(), look: Default::default() }; + let mark = + Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() }); + s.apply(id, mark).unwrap(); +} + +/// 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 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"); + 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] +fn redaction_marks_apply_for_good_and_undo() { + let (mut s, id) = session_with(2); + // "Page 1" at 24 pt from x 20: the "1" starts near x 82.7 (Helvetica widths). + let shape = + Shape::Redact { quads: vec![pdfcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])], overlay: String::new(), look: Default::default() }; + let mark = + Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() }); + s.apply(id, mark).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add redaction mark")); + assert_eq!(s.get(id).unwrap().redaction_marks(), 1); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"], "marking alone changes nothing"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!((d.can_undo(), d.redaction_marks()), (Some("Apply redactions"), 0)); + // An independent extractor (the renderer's) no longer finds the "1". + assert_eq!(page_texts(&s, id), ["Page", "Page 2"]); + let saved = s.save_bytes(id).unwrap(); + assert!(!saved.windows(8).any(|w| w == b"(Page 1)"), "a full rewrite: the old revision is gone"); + assert!(saved.windows(8).any(|w| w == b"(Page 2)")); + let mut s2 = Session::new(); + let id2 = s2.open("r.pdf", None, saved, None).unwrap(); + assert_eq!(page_texts(&s2, id2), ["Page", "Page 2"]); + s.undo(id).unwrap(); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"]); + assert_eq!(s.apply(id, Edit::ClearRedactions).map(|_| s.get(id).unwrap().redaction_marks()), Ok(0)); + 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); + let shape = Shape::Redact { + quads: vec![pdfcraft_annot::rect_quad([0.0, 0.0, 16000.0, 1600.0])], + overlay: "X".into(), + look: pdfcraft_annot::OverlayLook { size: 2.0, repeat: true, ..Default::default() }, + }; + s.apply( + id, + Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Tester".into() }), + ) + .unwrap(); + let before = s.get(id).unwrap().bytes.clone(); + assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(pdfcraft_redact::RedactError::OverlayLimit)))); + let doc = s.get(id).unwrap(); + assert_eq!(doc.bytes, before); + assert_eq!(doc.redaction_marks(), 1); + assert_eq!(doc.can_undo(), Some("Add redaction mark")); + assert_eq!(page_texts(&s, id), ["Page 1"]); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_marks(), 0); + assert_eq!(page_texts(&s, id), ["Page 1"]); +} + +/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the +/// layers untouched, the document's own configuration prints. +#[test] +fn printing_keeps_hidden_layers_hidden() { + let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 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 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + b"<< /Type /OCG /Name (Layer A) >>".to_vec(), + b"<< /Type /OCG /Name (Layer B) >>".to_vec(), + ]; + 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()); + + // Red and green pixels on the first printed sheet. + let printed = |s: &Session, id: DocId| { + let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + let page = render_page(&out, printed); + let px = page.rgba.as_chunks::<4>().0; + let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); + let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count(); + (red, green) + }; + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); + let (red, green) = printed(&s, id); + assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green"); + let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap(); + assert!(s.set_layer_visible(id, a, false)); + let (red, green) = printed(&s, id); + assert_eq!(red, 0, "Layer A was hidden, so it doesn't print"); + assert!(green > 100, "Layer B still prints: {green} green"); + assert!(s.set_layer_visible(id, a, true)); + let (red, _) = printed(&s, id); + assert!(red > 100, "shown again, Layer A prints again: {red} red"); +} + +#[test] +fn printing_lays_out_sheets_that_render() { + let (s, id) = session_with(5); + let settings = print::Settings { + pages: print::select_pages(5, Some("2-5"), &[], print::Subset::All, false).unwrap(), + layout: print::Layout::multiple(2), + ..Default::default() + }; + let bytes = s.print_pdf(id, &settings).unwrap(); + let mut s2 = Session::new(); + let id2 = s2.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + assert_eq!(s2.get(id2).unwrap().info.pages.len(), 2, "four pages, two per sheet"); + let texts = page_texts(&s2, id2); + assert!(texts[0].contains("Page 2") && texts[0].contains("Page 3") && texts[1].contains("Page 5"), "{texts:?}"); + // Printing honours the permissions. + let p = + Protection { open_password: Some("pw".into()), permissions_password: Some("owner".into()), printing: Printing::None, ..Default::default() }; + let (mut s3, id3) = session_with(1); + s3.apply(id3, Edit::Protect(p)).unwrap(); + let saved = s3.save_bytes(id3).unwrap(); + let mut s4 = Session::new(); + let id4 = s4.open("locked.pdf", None, saved, Some("pw")).unwrap(); + assert_eq!(s4.print_pdf(id4, &print::Settings { pages: vec![0], ..Default::default() }), Err(EditError::NotPermitted("printing"))); +} + +/// A one-page document whose red square is in a layer; `off` switches the layer off by default. +fn red_layer_pdf(off: bool) -> Vec { + let content = "/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC"; + let off = if off { "/OFF [5 0 R] " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << {off}/Order [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 100] /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /OCG /Name (Red) >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes() +} + +/// Does the first page of `pdf` show any red when drawn? +fn first_page_has_red(pdf: Vec) -> bool { + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf), pdfcraft_render::RenderConfig::default()); + let page = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 0.5, ..Default::default() }); + page.rgba.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[1] < 60 && p[2] < 60) +} + +#[test] +fn a_layer_that_is_off_does_not_print_and_one_that_is_on_does() { + for (off, prints) in [(true, false), (false, true)] { + let source = red_layer_pdf(off); + assert_eq!(first_page_has_red(source.clone()), prints, "on screen (off: {off})"); + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(source), None).unwrap(); + let sheet = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + assert_eq!(first_page_has_red(sheet), prints, "printed (layer off by default: {off})"); + } +} + +#[test] +fn crop_and_duplicate_pages_show_in_the_viewer() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::SetPageBox { pages: vec![0], which: PageBox::Crop, spec: BoxSpec::Margins([10.0, 20.0, 30.0, 40.0]) }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.can_undo(), Some("Crop page")); + assert_eq!(d.info.pages[0].crop, [10.0, 20.0, 170.0, 260.0]); + assert_eq!((d.info.pages[0].width, d.info.pages[0].height), (160.0, 240.0), "the viewer shows the cropped size"); + assert_eq!(d.page_boxes()[1][1], [0.0, 0.0, 200.0, 300.0]); + s.apply(id, Edit::DuplicatePages { pages: vec![1], at: None }).unwrap(); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page")); +} + +#[test] +fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { + let (mut s, id) = session_with(2); + let mut hf = HeaderFooter::default(); + hf.text[4] = "Page <<1>> of <> · <>".into(); + s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf.clone(), replace: false }).unwrap(); + // The session clock is 2023-11-14. + assert_eq!(page_texts(&s, id)[1], "Page 2\nPage 2 of 2 · 2023-11-14"); + assert_eq!(s.get(id).unwrap().marks(), [MarkKind::HeaderFooter]); + hf.text[4] = "Draft".into(); + s.apply(id, Edit::AddHeaderFooter { pages: vec![0, 1], settings: hf, replace: true }).unwrap(); + assert_eq!(page_texts(&s, id)[0], "Page 1\nDraft"); + s.apply( + id, + Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "SECRET".into(), ..Watermark::default() }, file: None, replace: false }, + ) + .unwrap(); + s.apply( + id, + Edit::AddBackground { + pages: vec![1], + settings: Background { color: [1.0, 0.9, 0.9], opacity: 1.0, ..Background::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + assert_eq!(s.get(id).unwrap().marks().len(), 3); + assert!(page_texts(&s, id)[0].contains("SECRET")); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::HeaderFooter }).unwrap(); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }).unwrap(); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"]); + assert!(s.get(id).unwrap().marks().is_empty()); + assert!(matches!(s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }), Err(EditError::Edit(_)))); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background")); +} + +/// 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); + let mut header = HeaderFooter::default(); + header.text[0] = "Header".into(); + source.apply(source_id, Edit::AddHeaderFooter { pages: vec![0], settings: header, replace: false }).unwrap(); + source + .apply( + source_id, + Edit::AddWatermark { pages: vec![0], settings: Watermark { text: "Draft".into(), ..Watermark::default() }, file: None, replace: false }, + ) + .unwrap(); + source + .apply( + source_id, + Edit::AddBackground { + pages: vec![0], + settings: Background { color: [0.9, 0.9, 1.0], opacity: 1.0, ..Background::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + source + .apply( + source_id, + Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 180.0, 280.0], text: "Added note".into(), ..Default::default() } }, + ) + .unwrap(); + + let bytes = source.save_bytes(source_id).unwrap(); + let reference = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + let expected_marks = pdfcraft_edit::marks_present(&reference); + let expected_added = pdfcraft_edit::list_added(&reference); + source.mark_saved(source_id, bytes.clone(), None).unwrap(); + let refreshed = source.get(source_id).unwrap(); + assert_eq!(refreshed.marks(), expected_marks); + assert_eq!(refreshed.added(), expected_added); + let mut reopened = Session::new(); + let reopened_id = reopened.open("marked.pdf", None, bytes, None).unwrap(); + let opened = reopened.get(reopened_id).unwrap(); + assert_eq!(opened.marks(), expected_marks); + assert_eq!(opened.added(), expected_added); + assert_eq!(opened.marks().len(), 3); + assert!(opened.marks().contains(&MarkKind::HeaderFooter)); + assert!(opened.marks().contains(&MarkKind::Watermark)); + assert!(opened.marks().contains(&MarkKind::Background)); + assert_eq!(opened.added().len(), 1); +} + +#[test] +fn secondary_indexes_load_once_and_follow_edit_scopes() { + reset_secondary_scan_counts(); + let (mut session, id) = session_with(1); + assert_eq!(secondary_scan_counts(), (0, 0), "open does not scan markers or links"); + + { + let doc = session.get(id).unwrap(); + assert!(doc.marks().is_empty()); + assert!(doc.added().is_empty()); + assert!(doc.added().is_empty()); + assert_eq!(secondary_scan_counts(), (1, 0), "marks and added content share one scan"); + assert!(doc.links().is_empty()); + 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()); + assert_eq!(secondary_scan_counts(), (1, 1), "metadata does not invalidate either index"); + + 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, + Edit::AddLink { + page: 0, + rect: [10.0, 10.0, 80.0, 30.0], + action: LinkAction::Uri("https://example.org".into()), + style: LinkStyle::default(), + }, + ) + .unwrap(); + assert_eq!(secondary_scan_counts(), (1, 2), "a link edit invalidates only the link list"); + assert_eq!(session.get(id).unwrap().links()[0].index, 1, "link indexes include earlier annotations"); + assert_eq!(secondary_scan_counts(), (1, 3)); + session.undo(id).unwrap(); + assert_eq!(session.get(id).unwrap().links().len(), 0); + assert_eq!(secondary_scan_counts(), (1, 4), "undo rebuilds the link list once"); + session.redo(id).unwrap(); + assert_eq!(session.get(id).unwrap().links()[0].index, 1); + assert_eq!(secondary_scan_counts(), (1, 5), "redo rebuilds the link list once"); + 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(); + assert_eq!(secondary_scan_counts(), (1, 6), "content edits defer rebuilding until a marker getter is used"); + assert_eq!(session.get(id).unwrap().added().len(), 1); + assert_eq!(session.get(id).unwrap().links().len(), 1); + assert_eq!(secondary_scan_counts(), (2, 6), "content edits preserve the link cache"); + + let mut header = HeaderFooter::default(); + header.text[0] = "Header".into(); + session.apply(id, Edit::AddHeaderFooter { pages: vec![0], settings: header, replace: false }).unwrap(); + assert_eq!(session.get(id).unwrap().marks(), [MarkKind::HeaderFooter]); + assert_eq!(secondary_scan_counts(), (3, 6)); + session.undo(id).unwrap(); + assert!(session.get(id).unwrap().marks().is_empty()); + assert_eq!(secondary_scan_counts(), (4, 6), "undo invalidates marker state"); + session.undo(id).unwrap(); + assert!(session.get(id).unwrap().added().is_empty()); + assert_eq!(secondary_scan_counts(), (5, 6), "undo of added content invalidates marker state"); + session.redo(id).unwrap(); + assert_eq!(session.get(id).unwrap().added().len(), 1); + assert_eq!(secondary_scan_counts(), (6, 6), "redo of added content rebuilds once"); + session.redo(id).unwrap(); + assert_eq!(session.get(id).unwrap().marks(), [MarkKind::HeaderFooter]); + assert_eq!(secondary_scan_counts(), (7, 6), "redo of marks rebuilds once"); +} + +#[test] +fn export_images_and_text_follow_the_working_file() { + let (mut s, id) = session_with(2); + s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap(); + let doc = s.get(id).unwrap(); + let mut ex = export::Exporter::new(doc); + let png = ex.png(1, 144.0).unwrap(); + assert_eq!(&png[1..4], b"PNG"); + // 200×300 pt rotated → 300×200 pt at 2 px/pt. + let w = u32::from_be_bytes(png[16..20].try_into().unwrap()); + let h = u32::from_be_bytes(png[20..24].try_into().unwrap()); + assert_eq!((w, h), (600, 400)); + assert_eq!(ex.text_of(&[0, 1]).unwrap(), "Page 1\n\u{c}Page 2\n"); + assert!(ex.png(5, 72.0).is_err()); +} + +#[test] +fn create_and_reduce() { + let mut s = Session::new(); + let blank = s.create_blank(612.0, 792.0, 2).unwrap(); + let id = s.open_new("Untitled.pdf", blank).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 2); + let text = s.create_from_text("notes", "hello\nworld").unwrap(); + let t = s.open_new("notes.pdf", text).unwrap(); + assert_eq!(page_texts(&s, t), ["hello\nworld"]); + // A file with the same page imported twice shares nothing until reduced. + let (mut s2, id2) = session_with(1); + let src = Arc::new(fixture(1)); + s2.apply( + id2, + Edit::InsertPagesFrom { + name: "x".into(), + bytes: src.clone(), + pages: None, + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }, + ) + .unwrap(); + let full = s2.save_full_bytes(id2).unwrap(); + let (reduced, _) = s2.reduced_bytes(id2).unwrap(); + assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len()); + let mut s3 = Session::new(); + let r = s3.open("r.pdf", None, reduced, None).unwrap(); + assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]); +} + +#[test] +fn reduce_saves_nothing_for_a_compact_file_whatever_the_time() { + use std::sync::atomic::{AtomicI64, Ordering}; + // The copy gets a new /ModDate inside a compressed object stream. Made a few seconds after + // the document, it was sometimes a byte or two smaller and saved (#490). + static NOW: AtomicI64 = AtomicI64::new(1_760_000_000); + let mut s = Session::new().with_clock(|| NOW.load(Ordering::Relaxed)); + let text = s.create_from_text("notes", &"lorem ipsum ".repeat(500)).unwrap(); + let id = s.open_new("notes.pdf", text.clone()).unwrap(); + for t in (1..86_400).step_by(863) { + NOW.store(1_760_000_000 + t, Ordering::Relaxed); + let (reduced, _) = s.reduced_bytes(id).unwrap(); + assert!(!optimizer::reduce_saves(text.len(), reduced.len()), "{t}: {} → {}", text.len(), reduced.len()); + } + assert!(optimizer::reduce_saves(10_000, 9_000)); +} + +#[test] +fn an_optimize_job_reports_its_stages_and_can_be_cancelled() { + use crate::optimizer::OptimizeStage; + let (s, id) = session_with(2); + let settings = optimize::Settings::default(); + let mut stages = Vec::new(); + let (bytes, _) = s + .optimize_job(id, &settings, &[]) + .unwrap() + .run(|st| { + stages.push(st); + true + }) + .unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert_eq!(stages.first(), Some(&OptimizeStage::Discarding)); + assert_eq!(&stages[stages.len() - 3..], [OptimizeStage::CleaningUp, OptimizeStage::Merging, OptimizeStage::Writing]); + let fractions: Vec = stages.iter().map(|st| st.fraction()).collect(); + assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "the bar never goes back: {fractions:?}"); + assert!(fractions.iter().all(|f| (0.0..=1.0).contains(f))); + assert_eq!( + OptimizeStage::Images { done: 0, total: 0 }.fraction(), + OptimizeStage::Images { done: 5, total: 5 }.fraction(), + "no images: no division by zero" + ); + + // Cancelled at the merge: nothing is written, the open document is untouched. + let before = s.get(id).unwrap().bytes.clone(); + let r = s.optimize_job(id, &settings, &[]).unwrap().run(|st| st != OptimizeStage::Merging); + assert!(matches!(r, Err(EditError::Cancelled)), "{r:?}"); + assert_eq!(s.get(id).unwrap().bytes, before); +} + +#[test] +fn flattening_keeps_the_look_and_drops_the_objects() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Ada".into()) }).unwrap(); + s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])).unwrap(); + let red_before = pixel(&s, id, 0, 100, 100); + s.apply(id, Edit::Flatten { comments: true, fields: true }).unwrap(); + let d = s.get(id).unwrap(); + assert!(d.info.annotations.is_empty() && d.form.is_empty(), "no comments or fields remain"); + assert_eq!(pixel(&s, id, 0, 100, 100), red_before, "the rectangle is now page content"); + assert_eq!(page_texts(&s, id), ["Ada"], "the field's text is now page content"); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.len(), 2); +} + +fn fill_item(shape: Shape, contents: &str) -> Edit { + let style = Style::default_for(&shape); + Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: contents.into(), author: "Ada".into() }) +} + +fn comment_text(s: &Session, id: DocId) -> Vec { + let mut t: Vec = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone().unwrap_or_default()).collect(); + t.sort(); + t +} + +/// 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"); +} + +#[test] +fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); + s.apply(id, fill_item(Shape::Mark { rect: [140.0, 200.0, 160.0, 220.0], mark: FillMark::Check }, "")).unwrap(); + s.apply(id, fill_item(Shape::Signature { strokes: vec![vec![[10.0, 40.0], [40.0, 70.0], [70.0, 40.0]]] }, "")).unwrap(); + s.apply(id, fill_item(Shape::TypedSignature { rect: [90.0, 40.0, 150.0, 70.0], contours: vec![vec![[0.1, 0.2], [0.5, 0.9], [0.9, 0.2]]] }, "")) + .unwrap(); + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 8, 8); + enc.set_color(png::ColorType::Rgba); + let pixel = [40u8, 60, 80, 200]; + enc.write_header().unwrap().write_image_data(&pixel.repeat(64)).unwrap(); + } + let image = SignatureImage::from_bytes(&png).unwrap(); + let info = s.get(id).unwrap().info.pages[0].clone(); + s.apply(id, image.edit(0, &info, [20.0, 120.0], false, "Ada").unwrap()).unwrap(); + s.apply(id, fill_item(Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 280.0, 80.0, 280.0, 20.0, 270.0, 80.0, 270.0]] }, "keep")) + .unwrap(); + s.apply(id, fill_item(Shape::TextBox { rect: [20.0, 150.0, 120.0, 180.0], font_size: 12.0 }, "Note")).unwrap(); + s.apply(id, Edit::AddField { page: 0, rect: [20.0, 230.0, 120.0, 250.0], kind: NewField::Text { multiline: false }, name: Some("email".into()) }) + .unwrap(); + + s.apply(id, Edit::FlattenFillSign).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Flatten Fill & Sign")); + assert_eq!(comment_text(&s, id), ["Note", "keep"]); + assert_eq!(s.get(id).unwrap().form.iter().map(|f| f.name.as_str()).collect::>(), ["email"]); + s.undo(id).unwrap(); + assert!(comment_text(&s, id).contains(&"Hello".to_string()), "undo restores the typewriter"); + + s.apply(id, Edit::FlattenFillSign).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved.clone(), None).unwrap(); + let mut again = Session::new(); + let id2 = again.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(comment_text(&again, id2), ["Note", "keep"]); + assert_eq!(again.get(id2).unwrap().form.iter().map(|f| f.name.as_str()).collect::>(), ["email"]); +} + +#[test] +fn flattening_fill_and_sign_does_nothing_when_there_are_none() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::TextMarkup { kind: Markup::Highlight, quads: vec![[20.0, 200.0, 80.0, 200.0, 20.0, 190.0, 80.0, 190.0]] }, "keep")) + .unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + assert!(!s.get(id).unwrap().dirty); + s.apply(id, Edit::FlattenFillSign).unwrap(); + assert!(!s.get(id).unwrap().dirty, "nothing was baked in, so the document stays saved"); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add highlight"), "no undo step for a flatten that changed nothing"); + assert_eq!(comment_text(&s, id), ["keep"]); +} + +#[test] +fn flattening_fill_and_sign_is_refused_without_permission_to_change() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); + let p = Protection { printing: Printing::Low, changes: Changes::CommentFillSign, copy: false, ..protection(None, Some("boss")) }; + s.apply(id, Edit::Protect(p)).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut other = Session::new(); + let id2 = other.open("locked.pdf", None, saved, None).unwrap(); + let before = other.get(id2).unwrap().bytes.clone(); + assert_eq!(other.apply(id2, Edit::FlattenFillSign), Err(EditError::NotPermitted("changes to the document"))); + assert_eq!(other.get(id2).unwrap().bytes, before); + assert!(!other.get(id2).unwrap().dirty); + assert!(comment_text(&other, id2).contains(&"Hello".to_string())); +} + +#[test] +fn flattening_fill_and_sign_on_a_signed_file_stays_incremental() { + 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(1)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + s.mark_signed(id, signed.clone(), None).unwrap(); + s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); + s.apply(id, Edit::FlattenFillSign).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(signed.as_ref()), "the signature's bytes stay an incremental update"); + assert!(!comment_text(&s, id).contains(&"Hello".to_string())); +} + +#[test] +fn flattening_fill_and_sign_reopens_an_encrypted_file() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Typewriter { rect: [20.0, 200.0, 120.0, 220.0], font_size: 10.0 }, "Hello")).unwrap(); + s.apply(id, Edit::Protect(protection(Some("secret"), None))).unwrap(); + s.apply(id, Edit::FlattenFillSign).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut again = Session::new(); + assert!(again.open("p.pdf", None, saved.clone(), None).is_err()); + let id2 = again.open("p.pdf", None, saved, Some("secret")).unwrap(); + assert!(!comment_text(&again, id2).contains(&"Hello".to_string())); +} + +#[test] +fn pages_export_as_png_jpeg_and_tiff() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(1); + let mut ex = Exporter::new(s.get(id).unwrap()); + let png = ex.image(0, 72.0, ImageFormat::Png).unwrap(); + assert!(png.starts_with(b"\x89PNG")); + let jpg = ex.image(0, 72.0, ImageFormat::Jpeg { quality: 85 }).unwrap(); + let decoded = image::load_from_memory_with_format(&jpg, image::ImageFormat::Jpeg).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (200, 300), "the page at 72 dpi"); + let corner = decoded.to_rgb8().get_pixel(2, 2).0; + assert!(corner.iter().all(|c| *c > 240), "white paper, not black: {corner:?}"); + let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap(); + let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (400, 600)); + let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(&tif)).unwrap(); + let compression = tags.get_tag_u32(tiff::tags::Tag::Compression).unwrap(); + let predictor = tags.get_tag_u32(tiff::tags::Tag::Predictor).unwrap(); + 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}; + let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap(); + let mut res = |tag| match d.get_tag(tag).unwrap() { + Value::Rational(n, d) => f64::from(n) / f64::from(d), + v => panic!("{tag:?} is {v:?}"), + }; + let (x, y) = (res(Tag::XResolution), res(Tag::YResolution)); + (d.get_tag_u32(Tag::ResolutionUnit).unwrap(), x, y) +} + +/// An exported TIFF or JPEG says the resolution it was drawn at, so other programs show and print it +/// at the page's size. A TIFF said 1 × 1 with no unit, and a JPEG said nothing. +#[test] +fn exported_pages_record_the_resolution_they_were_drawn_at() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(1); + let mut ex = Exporter::new(s.get(id).unwrap()); + assert_eq!(tiff_resolution(&ex.image(0, 144.0, ImageFormat::Tiff).unwrap()), (2, 144.0, 144.0)); + let jpg = ex.image(0, 144.0, ImageFormat::Jpeg { quality: 85 }).unwrap(); + assert_eq!(&jpg[6..11], b"JFIF\0"); + assert_eq!(jpg[13..18], [1, 0, 144, 0, 144], "JFIF density in dots per inch"); + // pHYs counts pixels per metre: 72 dpi would read back as 72.009, and a page made from the PNG + // (doc_create from images) would come out 0.01% small. + let png = ex.image(0, 72.0, ImageFormat::Png).unwrap(); + assert!(png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap().info().pixel_dims.is_none(), "a PNG states no resolution"); + + // A page too large for the renderer at the asked resolution says the one it was drawn at. + let mut s = Session::new().with_clock(|| 1_700_000_000); + let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap(); + let id = s.open("wide.pdf", None, bytes, None).unwrap(); + let mut ex = Exporter::new(s.get(id).unwrap()); + let used = ex.dpi_used(0, 600.0); + let (unit, x, y) = tiff_resolution(&ex.image(0, 600.0, ImageFormat::Tiff).unwrap()); + assert!(unit == 2 && (x - used).abs() < 0.01 && (y - used).abs() < 0.01, "{x} × {y}, drawn at {used}"); +} + +#[test] +fn added_text_and_images_render_and_stay_editable() { + let (mut s, id) = session_with(1); + let text = AddedText { rect: [20.0, 250.0, 180.0, 280.0], text: "Added note".into(), size: 12.0, ..AddedText::default() }; + s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add text")); + assert!(page_texts(&s, id)[0].contains("Added note"), "the renderer's extractor sees page content"); + // A PNG, centred at its natural size. + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 20, 10); + enc.set_color(png::ColorType::Rgb); + let mut w = enc.write_header().unwrap(); + w.write_image_data(&[0u8; 600]).unwrap(); + } + s.apply(id, Edit::AddImage { page: 0, rect: None, name: "dot.png".into(), bytes: Arc::new(png) }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.added().len(), 2); + assert_eq!(d.added()[1].content.rect(), [90.0, 145.0, 110.0, 155.0], "20×10 px at 72 dpi, centred on 200×300"); + let moved = d.added()[0].content.with_rect([30.0, 100.0, 190.0, 130.0]); + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: moved }).unwrap(); + assert_eq!(s.get(id).unwrap().added()[0].content.rect()[3], 130.0); + s.apply(id, Edit::DeleteContent { page: 0, index: 1 }).unwrap(); + assert_eq!(s.get(id).unwrap().added().len(), 1); + s.undo(id).unwrap(); + 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, + 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. + let mut px = Vec::new(); + for _y in 0..10 { + for x in 0..20 { + px.extend_from_slice(if x < 10 { &[255, 0, 0] } else { &[0, 0, 255] }); + } + } + let mut png = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png, 20, 10); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&px).unwrap(); + } + let (mut s, id) = session_with(1); + // A 100×100 box at (50, 100) on the 200×300 page. + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "rb.png".into(), bytes: Arc::new(png) }).unwrap(); + let colour_at = |s: &Session, x: u32, y_from_top: u32| -> [u8; 3] { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + let i = ((y_from_top * out.width + x) * 4) as usize; + [out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]] + }; + let red = |c: [u8; 3]| c[0] > 200 && c[2] < 60; + let blue = |c: [u8; 3]| c[2] > 200 && c[0] < 60; + // Display y 100–200 is rows 100–200 from the top on a 300 pt page. + assert!(red(colour_at(&s, 70, 150)) && blue(colour_at(&s, 130, 150)), "as placed: red left, blue right"); + let item = |s: &Session| s.get(id).unwrap().added()[0].content.clone(); + let AddedContent::Image(mut img) = item(&s) else { panic!() }; + img.rotation = 1; + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); + assert!(red(colour_at(&s, 100, 180)) && blue(colour_at(&s, 100, 120)), "a quarter turn left: red at the bottom"); + img.rotation = 0; + img.flip_h = true; + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); + 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.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] +fn reload_reads_the_new_file_into_the_same_document() { + let (mut s, id) = session_with(2); + let other = s.open("other.pdf", None, Arc::new(fixture(1)), None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let before = s.get(id).unwrap().edit_generation(); + s.reload(id, Arc::new(fixture(3))).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!((d.id, d.name.as_str()), (id, "fixture.pdf"), "same document, same name"); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 3"], "the new version"); + assert!(!d.dirty && d.can_undo().is_none(), "nothing unsaved, nothing to undo"); + assert!(d.edit_generation() > before, "caches keyed by the generation see a change"); + assert_eq!(s.docs().iter().map(|d| d.id).collect::>(), [id, other], "no new tab, same order"); + // A file that isn't a PDF (yet) leaves the document as it was. + assert!(s.reload(id, Arc::new(b"%PDF-1.7 half written".to_vec())).is_err()); + assert_eq!(page_texts(&s, id).len(), 3); + 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); + s.apply(id, Edit::DeletePages { pages: vec![0] }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.apply(id, Edit::InsertBlankPage { at: 0, width: 200.0, height: 300.0 }).unwrap(); + assert!(s.get(id).unwrap().dirty); + s.revert(id).unwrap(); + let d = s.get(id).unwrap(); + assert!(!d.dirty && d.can_undo().is_none() && d.can_redo().is_none()); + assert_eq!(page_texts(&s, id), ["Page 2"], "the saved state, not the opened one"); + assert_eq!(d.info.pages[0].rotation, 0); +} + +#[test] +fn split_by_size_and_bookmarks_and_page_filters() { + let (mut s, id) = session_with(6); + // Every page alone is a few hundred bytes: a limit of about two pages gives three parts. + let one = s.split(id, &pdfcraft_organize::SplitBy::PageCount(1)).unwrap()[0].2.len(); + let parts = s.split_by_size(id, one * 2 + one / 2).unwrap(); + assert!(parts.len() >= 2 && parts.len() <= 6, "{}", parts.len()); + 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, 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; + let all: Vec = (0..6).collect(); + assert_eq!(filter_pages(info, &all, PageParity::Even, PageOrientation::Both), [1, 3, 5]); + assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Landscape), Vec::::new()); + assert_eq!(filter_pages(info, &all, PageParity::Odd, PageOrientation::Portrait), [0, 2, 4]); +} + +#[test] +fn links_from_urls_and_link_edits() { + // One page whose text contains a web address. + let mut objs: Vec> = + vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec()]; + objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec()); + objs.push(b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec()); + let body = b"BT /F1 12 Tf 20 100 Td (Visit www.example.org today) Tj ET"; + objs.push([format!("<< /Length {} >>\nstream\n", body.len()).into_bytes(), body.to_vec(), b"\nendstream".to_vec()].concat()); + 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", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + 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()); + let mut s = Session::new(); + let id = s.open("u.pdf", None, Arc::new(out), None).unwrap(); + let found = s.find_urls(id); + assert_eq!(found.len(), 1); + assert_eq!(found[0].2, "http://www.example.org"); + s.apply(id, Edit::AddLinks { links: found, style: LinkStyle::default() }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.links().len(), 1); + assert_eq!(d.info.links.len(), 1, "the viewer follows it"); + assert!(s.find_urls(id).is_empty(), "already linked"); + // Remove all links and undo. + s.apply(id, Edit::RemoveLinks { pages: None }).unwrap(); + assert!(s.get(id).unwrap().links().is_empty()); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove all links")); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().links().len(), 1); + // Link Properties and delete. + let l = s.get(id).unwrap().links()[0].clone(); + s.apply(id, Edit::SetLink { page: 0, index: l.index, rect: None, action: Some(LinkAction::Page(0)), style: None }).unwrap(); + assert_eq!(s.get(id).unwrap().links()[0].action, LinkAction::Page(0)); + s.apply(id, Edit::DeleteLink { page: 0, index: l.index }).unwrap(); + assert!(s.get(id).unwrap().links().is_empty()); + assert!(s.apply(id, Edit::RemoveLinks { pages: None }).is_err(), "nothing left to remove"); +} + +#[test] +fn appearance_only_edit_preserves_gotor_action_semantics() { + let objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Link /Rect [10 10 80 30] /A << /S /GoToR /F (target.pdf) /D [2 /FitH 180] /NewWindow true >> >>".to_vec(), + ]; + 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 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()); + + let mut session = Session::new(); + let id = session.open("source.pdf", Some("/docs/source.pdf".into()), Arc::new(bytes), None).unwrap(); + let before = session.get(id).unwrap().info.links[0].target.clone(); + let mut style = session.get(id).unwrap().links()[0].style; + style.visible = true; + style.width = 2.0; + session.apply(id, Edit::SetLink { page: 0, index: 0, rect: None, action: None, style: Some(style) }).unwrap(); + + let after = &session.get(id).unwrap().info.links[0].target; + assert_eq!(*after, before, "the GoToR file, destination and /NewWindow hint must remain unchanged"); + assert_eq!(session.get(id).unwrap().links()[0].style, style, "the appearance edit is applied"); +} + +#[test] +fn comments_lock_take_checkmarks_hide_and_summarize() { + let (mut s, id) = session_with(2); + s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Agreed".into(), author: "Ada".into() }).unwrap(); + s.apply(id, Edit::LockAnnotation { page: 1, index: 0, locked: true }).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Lock comment")); + assert!(s.get(id).unwrap().info.annotations[0].locked); + assert!(s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 5.0, dy: 0.0 }).is_err(), "locked comments stay put"); + assert!(s.apply(id, Edit::DeleteAnnotation { page: 1, index: 0 }).is_err(), "and can't be deleted"); + s.apply(id, Edit::SetAnnotationContents { page: 1, index: 0, text: "Still editable".into() }).unwrap(); + s.apply(id, Edit::LockAnnotation { page: 1, index: 0, locked: false }).unwrap(); + s.apply(id, Edit::MoveAnnotation { page: 1, index: 0, dx: 5.0, dy: 0.0 }).unwrap(); + + s.apply(id, Edit::MarkAnnotation { page: 1, index: 0, marked: true, author: "Ada".into() }).unwrap(); + let a = &s.get(id).unwrap().info.annotations; + let mark = a.iter().find(|r| r.is_mark()).expect("a checkmark reply"); + assert_eq!(mark.state.as_deref(), Some("Marked")); + + // Hiding comments drops the rectangle but keeps the page. + let px = pixel(&s, id, 1, 105, 100); + assert!(px[0] > 200 && px[1] < 40, "drawn: {px:?}"); + assert!(s.set_hide_comments(id, true)); + assert!(!s.set_hide_comments(id, true)); + assert_eq!(pixel(&s, id, 1, 105, 100), [255, 255, 255, 255]); + assert!(s.set_hide_comments(id, false)); + assert!(pixel(&s, id, 1, 105, 100)[1] < 40); + + let text = comment_summary("doc.pdf", &s.get(id).unwrap().info.annotations, SummarySort::Page); + assert!(text.starts_with("Summary of Comments on doc.pdf")); + assert!(text.contains("Page: 2\nNumber: 1 Author: Reviewer Subject: Rectangle"), "{text}"); + assert!(text.contains("Still editable") && text.contains(" Agreed"), "{text}"); + assert!(!text.contains("Marked"), "checkmarks aren't listed: {text}"); + let pdf = s.summarize_comments(id, SummarySort::Author).unwrap(); + let n = s.open_new("summary.pdf", pdf).unwrap(); + assert!(!s.get(n).unwrap().info.pages.is_empty()); + 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() { + let (mut s, id) = session_with(1); + s.apply(id, rect_comment(0, [40.0, 40.0, 90.0, 90.0])).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "FIRST_REPLY".into(), author: "A".into() }).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 1, text: "NESTED_REPLY".into(), author: "B".into() }).unwrap(); + let text = comment_summary("doc.pdf", &s.get(id).unwrap().info.annotations, SummarySort::Page); + assert!(text.contains("FIRST_REPLY"), "{text}"); + assert!(text.contains("NESTED_REPLY"), "replies to replies are summarized: {text}"); + assert!(text.contains(" NESTED_REPLY"), "the nested reply is indented under its parent: {text}"); +} + +#[test] +fn reading_colors_set_and_invalidated() { + let (mut s, id) = session_with(1); + // Every open document holds a value; the default is Off. + assert_eq!(s.reading_colors(id), Some(ReadingColors::default())); + assert!(s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK)); + assert_eq!(s.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK)); + assert!(!s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK), "unchanged"); + assert!(s.set_reading_colors(id, ReadingColors::default()), "back to Off"); + assert_eq!(s.reading_colors(id), Some(ReadingColors::default())); + // No such document. + assert_eq!(s.reading_colors(DocId(u64::MAX)), None); + assert!(!s.set_reading_colors(DocId(u64::MAX), ReadingColors::WHITE_ON_BLACK)); +} + +#[test] +fn signing_saving_trusting_and_commenting_afterwards() { + 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(2)), None).unwrap(); + assert!(s.get(id).unwrap().signatures.is_empty()); + let opts = SignOptions { page: 1, rect: Some([20.0, 20.0, 180.0, 60.0]), reason: Some("Approved".into()), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + s.mark_signed(id, signed, Some("/tmp/signed.pdf".into())).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.name, "signed.pdf"); + assert!(doc.is_signed() && !doc.dirty && doc.can_undo().is_none(), "signing can't be undone"); + let sig = &doc.signatures[0]; + assert_eq!((sig.status, sig.page, sig.date.as_deref()), (SignatureStatus::Unknown, Some(1), Some("D:20270115080000Z"))); + assert_eq!(s.save_full_bytes(id).unwrap_err(), EditError::Signed); + // Trusting the signer makes it valid; a later comment is permitted. + s.set_trusted_certificates(vec![digital_id.certificate.clone()]); + assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); + s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])).unwrap(); + let sig = &s.get(id).unwrap().signatures[0]; + let comments = sign::Modification::Allowed(vec![sign::kind::COMMENTS]); + assert_eq!((sig.status, sig.modification.clone()), (SignatureStatus::Valid, comments)); + // Saving keeps the signature (incremental). + let saved = s.save_bytes(id).unwrap(); + s.mark_saved(id, saved, None).unwrap(); + assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); + // Edits that would need a full rewrite are refused, and change nothing. + let before = s.get(id).unwrap().can_undo().map(str::to_owned); + assert!(matches!(s.apply(id, Edit::Sanitize), Err(EditError::SignedRewrite(_)))); + assert!(!s.get(id).unwrap().dirty && s.get(id).unwrap().can_undo().map(str::to_owned) == before); +} + +#[test] +fn validated_signatures_without_layers_keep_their_appearance() { + 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(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + s.mark_signed(id, signed, None).unwrap(); + s.set_trusted_certificates(vec![digital_id.certificate.clone()]); + let doc = s.get(id).unwrap(); + let sig = &doc.signatures[0]; + assert_eq!(sig.status, SignatureStatus::Valid); + // Our own flat appearance has no validation layers: nothing is substituted, and + // the display copy carries no generated caption. + let display = doc.display.clone(); + assert!(!display.windows(b"Signature valid".len()).any(|w| w == b"Signature valid")); + // With nothing to draw, the display copy aliases the file exactly as before. + assert!(Arc::ptr_eq(&doc.display, &doc.bytes)); +} + +#[test] +fn signed_pages_render_trust_save_and_reopen() { + 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(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + // The signed bytes open elsewhere too: validity is in the file, not the session. + let mut t = Session::new().with_clock(|| 1_800_000_000); + let tid = t.open("signed.pdf", None, signed.clone(), None).unwrap(); + assert!(t.get(tid).unwrap().signatures.iter().any(|x| x.signed && x.status == SignatureStatus::Unknown)); + s.mark_signed(id, signed, None).unwrap(); + // The signed page renders, from the file and from the display copy alike. + for bytes in [s.get(id).unwrap().bytes.clone(), s.get(id).unwrap().display.clone()] { + let mut renderer = pdfcraft_render::PageRenderer::new(bytes, s.get(id).unwrap().config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + assert!(rendered.width > 0 && rendered.height > 0); + } + // Trusting the signer makes it valid; saving keeps it valid across sessions. + s.set_trusted_certificates(vec![digital_id.certificate.clone()]); + assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); + let saved = s.save_bytes(id).unwrap(); + let mut u = Session::new().with_clock(|| 1_800_000_000); + u.set_trusted_certificates(vec![digital_id.certificate.clone()]); + let uid = u.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert_eq!(u.get(uid).unwrap().signatures[0].status, SignatureStatus::Valid); +} + +#[test] +fn signed_documents_round_trip_behind_a_user_password() { + 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 doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap()); + // Wrong passwords refuse; the right one signs, validates, saves and reopens. + let mut e = Session::new().with_clock(|| 1_800_000_000); + assert!(e.open("enc.pdf", None, encrypted.clone(), Some("wrong")).is_err(), "wrong passwords refuse"); + let eid = e.open("enc.pdf", None, encrypted, Some("user")).unwrap(); + let signed = e.sign(eid, &digital_id, SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }).unwrap(); + e.mark_signed(eid, signed, None).unwrap(); + e.set_trusted_certificates(vec![digital_id.certificate.clone()]); + assert_eq!(e.get(eid).unwrap().signatures[0].status, SignatureStatus::Valid); + let saved = e.save_bytes(eid).unwrap(); + let mut f = Session::new().with_clock(|| 1_800_000_000); + f.set_trusted_certificates(vec![digital_id.certificate.clone()]); + assert!(f.open("enc-saved.pdf", None, saved.clone(), Some("wrong")).is_err()); + let fid = f.open("enc-saved.pdf", None, saved.clone(), Some("user")).unwrap(); + assert_eq!(f.get(fid).unwrap().signatures[0].status, SignatureStatus::Valid); +} + +/// ByteRange placeholder values: fixed width, patched after writing. +const LAYERED_BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333]; + +/// A one-page file whose signature field carries a layered appearance on the +/// page (background, words, vector glyph, border, unverified caption): object +/// numbers 1 catalog, 2 pages, 3 page, 4 field, 5 AcroForm, 6 /N, 7 frame, +/// 8 background, 9 words, 10 glyph, 11 caption, 12 border, 13 Helvetica, +/// 14 page content. The field is unsigned: `sign_prepared` adds the value. +fn layered_signable_fixture() -> Vec { + fn stream(bbox: &str, resources: &str, ops: &str) -> String { + format!("<< /Type /XObject /Subtype /Form /BBox {bbox} /Length {} /Resources {resources} >>\nstream\n{ops}\nendstream", ops.len()) + } + let helv = "<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>"; + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 14 0 R /Resources << /Font << /F1 13 0 R >> >> /Annots [4 0 R] >>" + .to_string(), + "<< /FT /Sig /T (Signature1) /Subtype /Widget /Rect [10 10 110 60] /P 3 0 R /AP << /N 6 0 R >> >>".to_string(), + "<< /Fields [4 0 R] /SigFlags 3 >>".to_string(), + stream("[0 0 50 30]", "<< /XObject << /frame 7 0 R >> >>", "q /frame Do Q"), + stream( + "[0 0 50 30]", + "<< /XObject << /bg 8 0 R /words 9 0 R /glyph 10 0 R /bd 12 0 R /cap 11 0 R >> >>", + "q /bg Do /words Do /glyph Do /bd Do /cap Do Q", + ), + stream("[0 0 100 100]", "<< >>", "0 g 0 0 100 100 re f"), + stream("[0 0 100 100]", helv, "BT /F1 10 Tf 5 50 Td (Signed by Test) Tj ET"), + stream("[0 0 50 30]", "<< >>", "0.8 0.1 0.1 RG 3 w 5 5 m 20 5 l 12 22 l h S"), + stream("[0 21 50 30]", helv, "BT /F1 9 Tf 2 22 Td (Signature Not Verified) Tj ET"), + stream("[0 0 100 100]", "<< >>", "0 g 0 0 100 100 re f"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + "<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 150 Td (Contract text) Tj ET\nendstream".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (k, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", k + 1).as_bytes()); + } + let start = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes()); + out.extend_from_slice(b"0000000000 65535 f \n"); + 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{start}\n%%EOF", objs.len() + 1).as_bytes()); + out +} + +/// Whether `haystack` holds `needle`. +fn bytes_has(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle) +} + +/// Sign `doc`, whose field already carries its layered appearance, without +/// touching that appearance: reserve `/ByteRange` and `/Contents`, hash the +/// ranges and embed a detached CMS over them. The signed bytes keep the +/// prepared appearance, so they validate exactly like a product signer's file. +fn sign_prepared(doc: &mut pdfcraft_cos::Document, id: &sign::pkcs12::DigitalId, date: &str) -> Vec { + use pdfcraft_cos::{Dict, Object, PdfString}; + let reserve = 8192 + id.certificate.raw.len() + id.chain.iter().map(|c| c.raw.len()).sum::(); + let mut v = Dict::new(); + v.set(b"Type".to_vec(), Object::name("Sig")); + v.set(b"Filter".to_vec(), Object::name("Adobe.PPKLite")); + v.set(b"SubFilter".to_vec(), Object::name("ETSI.CAdES.detached")); + v.set(b"ByteRange".to_vec(), Object::Array([0].iter().chain(LAYERED_BR_MARK.iter()).map(|n| Object::Int(*n)).collect())); + v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true })); + v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + let sig = doc.add(Object::Dict(v)); + // The field by name, never by number: a full save (encryption, repairs) is + // free to renumber objects, and a hardcoded reference would stamp the value + // onto whatever moves into that slot. + let root = doc.root().expect("catalog"); + let af = doc.get(root).as_dict().expect("catalog dict").get(b"AcroForm").cloned().expect("AcroForm"); + let fields = doc.resolve(&af).as_dict().expect("AcroForm dict").get(b"Fields").cloned().expect("Fields"); + let field = doc.resolve(&fields).as_array().expect("Fields array").iter().filter_map(|o| o.as_ref()).next().expect("signature field"); + doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap(); + let save = pdfcraft_cos::SaveOptions { object_streams: false, ..pdfcraft_cos::SaveOptions::default() }; + let mut out = pdfcraft_cos::write_incremental(doc, &save).unwrap(); + let mark = format!("0 {} {} {}", LAYERED_BR_MARK[0], LAYERED_BR_MARK[1], LAYERED_BR_MARK[2]); + let br = out.windows(mark.len()).position(|w| w == mark.as_bytes()).expect("ByteRange placeholder"); + let start = out[..br].windows(3).rposition(|w| w == b"obj").expect("signature object"); + let end = br + out[br..].windows(6).position(|w| w == b"endobj").expect("signature end"); + let mut zeros = vec![b'<']; + zeros.extend(std::iter::repeat_n(b'0', reserve * 2)); + zeros.push(b'>'); + let region = &out[start..end]; + let c = region.windows(zeros.len()).position(|w| w == zeros.as_slice()).expect("Contents placeholder"); + let (cstart, cend) = (start + c, start + c + zeros.len()); + let mut br_text = format!("0 {cstart} {cend} {}", out.len() - cend).into_bytes(); + assert!(br_text.len() <= mark.len(), "ByteRange patch fits its placeholder"); + br_text.resize(mark.len(), b' '); + out[br..br + mark.len()].copy_from_slice(&br_text); + let alg = id.key.preferred_digest(); + let digest = alg.digest(&[&out[..cstart], &out[cend..]]); + let cms = sign::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest).unwrap(); + assert!(cms.len() <= reserve, "signature fits its placeholder"); + let hex: Vec = cms.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + out[cstart + 1..cstart + 1 + hex.len()].copy_from_slice(&hex); + out +} + +/// Replace one synthetic appearance stream while retaining its resource dictionary. +fn signature_layer_ops(doc: &mut pdfcraft_cos::Document, number: u32, ops: &[u8]) { + let r = pdfcraft_cos::ObjRef::new(number, 0); + let dict = doc.resolve(&pdfcraft_cos::Object::Ref(r)).as_dict().unwrap().clone(); + doc.set(r, pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(dict, ops.to_vec()))); +} + +fn printed_pixels(bytes: Vec) -> (u32, u32, Vec) { + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), Default::default()); + let r = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(r.error.is_none(), "{:?}", r.error); + (r.width, r.height, r.rgba.to_vec()) +} + +/// Validation belongs to viewer chrome, not to document artwork or export sources. +fn assert_signature_artwork_survives_trust(mut cos: pdfcraft_cos::Document, expected_text: &[&str]) { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let identity = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = identity.chain.first().cloned().unwrap_or_else(|| identity.certificate.clone()); + let signed = Arc::new(sign_prepared(&mut cos, &identity, "D:20261002120000+01'00'")); + let mut session = Session::new().with_clock(|| 1_800_000_000); + let id = session.open("artwork.pdf", None, signed.clone(), None).unwrap(); + let original_display = session.get(id).unwrap().display.clone(); + let mut exporter = export::Exporter::new(session.get(id).unwrap()); + let original_text = exporter.text_of(&[0]).unwrap(); + let original_png = exporter.png(0, 72.0).unwrap(); + for text in expected_text { + assert!(original_text.contains(text), "fixture text {text:?} must be exported: {original_text:?}"); + } + let mut settings = print::Settings { pages: vec![0], paper: (200.0, 200.0), ..Default::default() }; + let original_vector_print = printed_pixels(session.print_pdf(id, &settings).unwrap()); + settings.raster = Some(print::Raster { dpi: 72.0, grayscale: false }); + let original_raster_print = printed_pixels(session.print_pdf(id, &settings).unwrap()); + session.set_trusted_certificates(vec![anchor.clone()]); + let doc = session.get(id).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(doc.display == original_display, "trust must preserve all original appearance bytes"); + let mut exporter = export::Exporter::new(doc); + assert_eq!(exporter.text_of(&[0]).unwrap(), original_text, "text exports retain signer details and baked caption"); + assert_eq!(exporter.png(0, 72.0).unwrap(), original_png, "image exports exclude the viewer overlay"); + assert_eq!(printed_pixels(session.print_pdf(id, &settings).unwrap()), original_raster_print, "Print as Image retains document artwork"); + settings.raster = None; + assert_eq!(printed_pixels(session.print_pdf(id, &settings).unwrap()), original_vector_print); + assert_eq!(session.save_bytes(id).unwrap(), signed, "unchanged saves retain the signed file exactly"); + session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Preserved artwork".into() }).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert!(saved.starts_with(&signed), "a real save appends without replacing signed appearance streams"); + let mut reopened = Session::new().with_clock(|| 1_800_000_000); + reopened.set_trusted_certificates(vec![anchor]); + let id = reopened.open("saved.pdf", None, saved, None).unwrap(); + let doc = reopened.get(id).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + let mut exporter = export::Exporter::new(doc); + assert_eq!(exporter.text_of(&[0]).unwrap(), original_text); + assert_eq!(exporter.png(0, 72.0).unwrap(), original_png); +} + +#[test] +fn chamfered_signature_borders_survive_trust_exports_printing_and_save() { + for raster_glyph in [false, true] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + signature_layer_ops(&mut doc, 12, b"0 G 1 w 10 1 m 90 1 l 99 10 l 99 90 l 90 99 l 10 99 l 1 90 l 1 10 l h S"); + signature_layer_ops(&mut doc, 10, b"0 G 2 w 0 0 50 30 re S"); + if raster_glyph { + let mut image = pdfcraft_cos::Dict::new(); + image.set(b"Subtype", pdfcraft_cos::Object::name("Image")); + image.set(b"Width", pdfcraft_cos::Object::Int(8)); + image.set(b"Height", pdfcraft_cos::Object::Int(8)); + image.set(b"ColorSpace", pdfcraft_cos::Object::name("DeviceRGB")); + image.set(b"BitsPerComponent", pdfcraft_cos::Object::Int(8)); + let image = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(image, vec![0; 8 * 8 * 3]))); + let mut xobjects = pdfcraft_cos::Dict::new(); + xobjects.set(b"im", pdfcraft_cos::Object::Ref(image)); + xobjects.set(b"unused", pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))); + let mut resources = pdfcraft_cos::Dict::new(); + resources.set(b"XObject", pdfcraft_cos::Object::Dict(xobjects)); + doc.update_dict(pdfcraft_cos::ObjRef::new(10, 0), |d| d.set(b"Resources", pdfcraft_cos::Object::Dict(resources))).unwrap(); + signature_layer_ops(&mut doc, 10, b"q 20 0 0 20 5 5 cm /im Do Q"); + } + assert_signature_artwork_survives_trust(doc, &["Not Verified"]); + } +} + +#[test] +fn mixed_signature_caption_details_survive_trust_exports_printing_and_save() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + signature_layer_ops(&mut doc, 11, b"BT /F1 5 Tf 2 27 Td (Alice Signer) Tj 0 -3 Td (alice@example.org) Tj 0 -3 Td (Signature Not Verified) Tj ET"); + assert_signature_artwork_survives_trust(doc, &["Alice Signer", "alice@example.org", "Not Verified"]); +} + +/// A real layered signature through `Session`: validity, original artwork, +/// rendering, saving and reopening. `password` opens the signed bytes. +fn check_layered_round_trip(signed: Arc>, password: Option<&str>, anchor: &sign::x509::Certificate) { + let mut s = Session::new().with_clock(|| 1_800_000_000); + let vid = s.open("layered.pdf", None, signed.clone(), password).unwrap(); + let doc = s.get(vid).unwrap(); + let sig = &doc.signatures[0]; + assert!(sig.signed && sig.field == "Signature1" && sig.page == Some(0) && sig.visible); + assert_eq!(sig.status, SignatureStatus::Unknown, "untrusted at first"); + // Trust changes the verdict while preserving every appearance byte. The page + // overlay is UI chrome and is not part of the document's display/export source. + s.set_trusted_certificates(vec![anchor.clone()]); + let doc = s.get(vid).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(Arc::ptr_eq(&doc.display, &doc.bytes), "trust does not build a signature display copy"); + assert!(bytes_has(&doc.display, b"Not Verified")); + assert!(!bytes_has(&doc.display, b"Signature valid")); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + // Saving keeps the original appearance and the signature: the validated + // state never reaches the file. + let saved = s.save_bytes(vid).unwrap(); + assert!(bytes_has(&saved, b"Not Verified"), "saved bytes keep the baked appearance"); + assert!(!bytes_has(&saved, b"Signature valid")); + let mut t = Session::new().with_clock(|| 1_800_000_000); + t.set_trusted_certificates(vec![anchor.clone()]); + let tid = t.open("layered-saved.pdf", None, saved.clone(), password).unwrap(); + let rsig = &t.get(tid).unwrap().signatures[0]; + assert_eq!(rsig.status, SignatureStatus::Valid); +} + +/// A trusted layered signature whose original appearance remains document artwork. +fn trusted_layered_session() -> (Session, DocId) { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let id = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, "D:20261002120000+01'00'")); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let vid = s.open("layered.pdf", None, signed, None).unwrap(); + s.set_trusted_certificates(vec![anchor]); + assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Valid); + (s, vid) +} + +/// Metadata edits retain the original caption; the UI can still show its verdict badge. +#[test] +fn a_metadata_edit_keeps_the_original_signature_appearance() { + let (mut s, vid) = trusted_layered_session(); + s.apply(vid, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap(); + let doc = s.get(vid).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(bytes_has(&doc.display, b"Not Verified")); + assert!(!bytes_has(&doc.display, b"Signature valid")); +} + +/// An unchanged document never reaches the writer, so the edit is what makes this the +/// place the display copy would leak if saving ever read it. +#[test] +fn saving_after_an_edit_keeps_the_baked_appearance() { + let (mut s, vid) = trusted_layered_session(); + s.apply(vid, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap(); + let saved = s.save_bytes(vid).unwrap(); + assert!(bytes_has(&saved, b"Not Verified"), "the writer keeps the baked appearance: {saved:?}"); + assert!(!bytes_has(&saved, b"Signature valid"), "the validated state never reaches the file"); +} + +#[test] +fn real_layered_signatures_keep_their_original_appearance() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let id = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let date = "D:20261002120000+01'00'"; + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, date)); + check_layered_round_trip(signed, None, &anchor); +} + +#[test] +fn real_layered_signatures_round_trip_behind_a_user_password() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let id = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let date = "D:20261002120000+01'00'"; + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap()); + let mut doc = pdfcraft_cos::Document::open_with_password(encrypted, Some("user")).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, date)); + let mut probe = Session::new().with_clock(|| 1_800_000_000); + assert!(probe.open("enc.pdf", None, signed.clone(), Some("wrong")).is_err(), "wrong passwords refuse"); + // Encrypted appearance bytes remain unchanged through trust and save/reopen. + let mut s = Session::new().with_clock(|| 1_800_000_000); + let vid = s.open("enc.pdf", None, signed.clone(), Some("user")).unwrap(); + assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Unknown); + s.set_trusted_certificates(vec![anchor.clone()]); + let doc = s.get(vid).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(Arc::ptr_eq(&doc.display, &doc.bytes), "encrypted signature artwork remains original"); + let saved = s.save_bytes(vid).unwrap(); + let mut t = Session::new().with_clock(|| 1_800_000_000); + t.set_trusted_certificates(vec![anchor.clone()]); + assert!(t.open("enc-saved.pdf", None, saved.clone(), Some("wrong")).is_err()); + let tid = t.open("enc-saved.pdf", None, saved.clone(), Some("user")).unwrap(); + assert_eq!(t.get(tid).unwrap().signatures[0].status, SignatureStatus::Valid); +} + +#[test] +#[ignore = "timing probe"] +fn probe_edit_latency_on_a_large_signed_document() { + // ~120 MB: one page whose content stream is large. + let big: Vec = (0..120_000_000u32).map(|i| b"0123456789 "[(i % 11) as usize]).collect(); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offs = vec![]; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 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 300] /Contents 4 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", big.len() + 3).into_bytes(), b"% ".to_vec(), big, b"\nendstream".to_vec()].concat(), + ]; + for (i, o) in objs.iter().enumerate() { + offs.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n", i + 1).bytes()); + pdf.extend(o); + pdf.extend(b"\nendobj\n"); + } + let x = pdf.len(); + pdf.extend(b"xref\n0 5\n0000000000 65535 f \n"); + for o in offs { + pdf.extend(format!("{o:010} 00000 n \n").bytes()); + } + pdf.extend(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").bytes()); + 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 t = std::time::Instant::now(); + let id = s.open("big.pdf", None, Arc::new(pdf), None).unwrap(); + eprintln!("open: {:?}", t.elapsed()); + let t = std::time::Instant::now(); + s.apply(id, rect_comment(0, [5.0, 10.0, 50.0, 50.0])).unwrap(); + eprintln!("comment edit before signing: {:?}", t.elapsed()); + s.undo(id).unwrap(); + let t = std::time::Instant::now(); + let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); + eprintln!("sign: {:?}", t.elapsed()); + let t = std::time::Instant::now(); + s.mark_signed(id, signed, None).unwrap(); + eprintln!("mark_signed (reopen + validate): {:?}", t.elapsed()); + for k in 0..3 { + let t = std::time::Instant::now(); + s.apply(id, rect_comment(0, [10.0 + k as f64, 10.0, 50.0, 50.0])).unwrap(); + eprintln!("comment edit {k}: {:?}", t.elapsed()); + } +} + +/// A tiny uncompressed BMP of one colour. +fn bmp(w: u32, h: u32, bgr: [u8; 3]) -> Vec { + let row = (w * 3).div_ceil(4) * 4; + let size = 54 + row * h; + let mut b = b"BM".to_vec(); + b.extend(size.to_le_bytes()); + b.extend([0u8; 4]); + b.extend(54u32.to_le_bytes()); + b.extend(40u32.to_le_bytes()); + b.extend((w as i32).to_le_bytes()); + b.extend((h as i32).to_le_bytes()); + b.extend(1u16.to_le_bytes()); + b.extend(24u16.to_le_bytes()); + b.extend([0u8; 24]); + for _ in 0..h { + for _ in 0..w { + b.extend(bgr); + } + b.extend(vec![0u8; (row - w * 3) as usize]); + } + b +} + +#[test] +fn backgrounds_and_watermarks_from_files() { + let (mut s, id) = session_with(1); + // A source PDF whose second page is solid blue. + let blue = { + let mut src = Session::new(); + let sid = src.open("src.pdf", None, Arc::new(fixture(2)), None).unwrap(); + src.apply( + sid, + Edit::AddBackground { + pages: vec![1], + settings: Background { color: [0.0, 0.0, 1.0], ..Background::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + src.save_bytes(sid).unwrap() + }; + let file = MarkFile { name: "src.pdf".into(), bytes: blue, page: 1 }; + s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: false, file: Some(file) }).unwrap(); + let corner = pixel(&s, id, 0, 3, 3); + assert!(corner[2] > 200 && corner[0] < 40, "the blue page fills the background: {corner:?}"); + // The page's own text still draws on top (at "Page 1", 20,150). + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add background")); + // A red image watermark at half the page, on top, unrotated. + let red = MarkFile { name: "stamp.bmp".into(), bytes: Arc::new(bmp(4, 4, [0, 0, 255])), page: 0 }; + let wm = Watermark { rotation: 0.0, opacity: 1.0, scale: 0.5, ..Watermark::default() }; + s.apply(id, Edit::AddWatermark { pages: vec![0], settings: wm, replace: false, file: Some(red) }).unwrap(); + let centre = pixel(&s, id, 0, 100, 150); + assert!(centre[0] > 200 && centre[2] < 40, "the red picture sits in the middle: {centre:?}"); + let edge = pixel(&s, id, 0, 10, 150); + assert!(edge[2] > 200, "outside it the background shows: {edge:?}"); + // A bad file is refused clearly. + let junk = MarkFile { name: "x.png".into(), bytes: Arc::new(b"not an image".to_vec()), page: 0 }; + assert!(s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: true, file: Some(junk) }).is_err()); +} + +/// Scan & OCR ▸ Recognize text on a page that is only a picture of text (needs the models: +/// `cargo xtask models`; skipped without them). +#[test] +fn recognize_text_reads_the_euro_sign() { + 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", "Total amount due: €250 by Friday").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 id = s.open("scan.pdf", None, scan, None).unwrap(); + s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); + let text = page_texts(&s, id)[0].clone(); + // The model was trained with € where the ocrs crate's alphabet has a second E. + assert!(text.contains("€250"), "{text}"); +} + +const SYNTHETIC_SCAN_DPI: f64 = 300.0; + +/// A scan of the OCR sentence at the source page's physical size, without a text layer. +fn synthetic_ocr_scan(s: &mut Session) -> (DocId, [f32; 2]) { + 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 original = &s.get(id).unwrap().info.pages[0]; + let size = [original.width, original.height]; + // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR. + let png = export::Exporter::new(s.get(id).unwrap()).png(0, SYNTHETIC_SCAN_DPI).unwrap(); + // Exported PNGs have no resolution metadata. The 72 DPI import default would enlarge + // the page and change the PDF-point coordinates the placement assertions exercise. + let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(SYNTHETIC_SCAN_DPI)).unwrap(); + (s.open("scan.pdf", None, scan, None).unwrap(), size) +} + +#[test] +fn synthetic_ocr_scan_keeps_the_original_page_size_without_models() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let (id, expected) = synthetic_ocr_scan(&mut s); + let page = &s.get(id).unwrap().info.pages[0]; + // Rounding to whole raster pixels may change a page edge by at most one pixel. + let tolerance = 72.0 / SYNTHETIC_SCAN_DPI; + for (actual, expected) in [page.width, page.height].into_iter().zip(expected) { + assert!(f64::from((actual - expected).abs()) <= tolerance, "page edge {actual} pt, expected {expected} pt"); + } + assert_eq!(page_texts(&s, id), [""], "the scan has no text until OCR runs"); +} + +#[test] +fn recognize_text_makes_a_scanned_page_searchable() { + if !ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let (id, _) = synthetic_ocr_scan(&mut s); + assert_eq!(page_texts(&s, id), [""], "a picture has no text"); + + let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); + assert_eq!(found.len(), 1); + let text = page_texts(&s, id)[0].to_lowercase(); + for w in ["quick", "brown", "fox", "lazy"] { + assert!(text.contains(w), "{text}"); + } + assert_eq!(s.get(id).unwrap().can_undo(), Some("Recognize text")); + // The text sits over the words: "quick" is left of "lazy" and on the same line. + let words = &found[0].words; + let q = words.iter().find(|w| w.text.to_lowercase().contains("quick")).unwrap(); + let l = words.iter().find(|w| w.text.to_lowercase().contains("lazy")).unwrap(); + let top = |w: &ocr::PlacedWord| w.origin[1] + w.up[1]; + // OCR boxes can differ by a few pixels at 300 DPI and still sit on the same line. + let line_tolerance = q.up[1].abs().max(l.up[1].abs()) / 4.0; + assert!(q.origin[0] < l.origin[0] && (top(q) - top(l)).abs() < line_tolerance, "{q:?} {l:?}"); + + // A second pass skips the page: it has text now. + let again = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); + assert!(again[0].skipped.is_some()); +} + +/// #1028: a page with its own text and a picture of text. By default it is skipped, as before; +/// with `skip_text_pages: false` the picture is read and the page's own text is not added again. +#[test] +fn recognize_text_reads_a_picture_on_a_page_that_has_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 id = s.open("scan.pdf", None, scan, None).unwrap(); + // Real text in the empty lower part of the picture. + 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.75, w * 0.9, h * 0.85], text: "Invoice reference 4711".into(), size: 18.0, ..Default::default() }; + s.apply(id, Edit::AddText { page: 0, text: note }).unwrap(); + let count = |s: &Session, word: &str| page_texts(s, id)[0].to_lowercase().matches(word).count(); + assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 0)); + + let skipped = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); + assert!(skipped[0].skipped.is_some(), "pages with text are still skipped by default"); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add text"), "nothing was added"); + + let hybrid = ocr::OcrSettings { skip_text_pages: false, ..Default::default() }; + let found = s.recognize_text(id, &[], hybrid.clone()).unwrap(); + assert_eq!(found[0].skipped, None); + assert!(found[0].already_on_page >= 2, "the page's own words were recognised and left out: {:?}", found[0]); + let added = found[0].text().to_lowercase(); + assert!(added.contains("brown") && added.contains("lazy") && !added.contains("invoice"), "{added}"); + assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1), "{:?}", page_texts(&s, id)); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Recognize text")); + + // Reading the page again adds nothing: every word is there now. + let again = s.recognize_text(id, &[], hybrid).unwrap(); + assert!(again[0].words.is_empty(), "{:?}", again[0]); + 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) + }; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript << /Names [(helpers) 10 0 R] >> >> /AcroForm << /Fields [4 0 R 5 0 R 6 0 R 7 0 R] /CO [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(), + text("price", 250, "/V (2.5)"), + text("qty", 220, "/AA << /V << /S /JavaScript /JS (if (event.value > 100) { app.alert('At most 100'); event.rc = false; }) >> >>"), + text("total", 190, "/AA << /C << /S /JavaScript /JS (event.value = times\\(getField('price').value, getField('qty').value\\);) >> /F << /S /JavaScript /JS (event.value = 'USD ' + util.printf('%.2f', event.value);) >> >>"), + "<< /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(), + format!("<< /S /JavaScript /JS ({helpers}) >>"), + ]; + 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 +} + +/// 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); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + assert_eq!(value(&s, "total"), ["10"], "calculated by the script"); + assert!(page_texts(&s, id)[0].contains("USD 10.00"), "{:?}", page_texts(&s, id)); + let out = s.take_js_output(id); + assert!(out.console.contains(&"times".to_string()), "{out:?}"); + + let err = s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap_err(); + assert_eq!(err.to_string(), "At most 100"); + assert_eq!(value(&s, "qty"), ["4"]); + + // 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, [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()); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + + // JavaScript off: scripts don't run. + s.set_javascript(false); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap(); + assert_eq!(value(&s, "total"), ["12"]); + 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); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + let initial_qty = value(&s, "qty"); + let initial_total = value(&s, "total"); + assert!(s.take_js_output(id).is_empty()); + + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + assert_eq!(value(&s, "qty"), ["4"]); + assert_eq!(value(&s, "total"), ["10"]); + let output = s.take_js_output(id); + assert!(output.console.contains(&"times".to_string()), "{output:?}"); + assert_eq!(output.console.iter().filter(|line| *line == "times").count(), 1, "{output:?}"); + + s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Report".into() }).unwrap(); + assert!(s.take_js_output(id).is_empty(), "a metadata edit does not rerun field scripts"); + + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().info.title, None); + assert_eq!(value(&s, "qty"), ["4"]); + assert!(s.take_js_output(id).is_empty(), "undoing metadata does not rerun field scripts"); + + s.redo(id).unwrap(); + assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Report")); + assert_eq!(value(&s, "qty"), ["4"]); + assert!(s.take_js_output(id).is_empty(), "redoing metadata does not rerun field scripts"); + + s.undo(id).unwrap(); + s.undo(id).unwrap(); + assert_eq!(value(&s, "qty"), initial_qty); + assert_eq!(value(&s, "total"), initial_total); + assert!(s.take_js_output(id).is_empty(), "undoing the scripted edit does not replay its side effect"); + + s.redo(id).unwrap(); + assert_eq!(value(&s, "qty"), ["4"]); + assert_eq!(value(&s, "total"), ["10"]); + assert!(s.take_js_output(id).is_empty(), "redoing the scripted edit restores its snapshot without replay"); +} + +#[test] +fn detecting_fields_on_a_printed_form() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let text = s.create_from_text("t", "Name: ______________________\n\nEmail address: ____________________\n\nPlain text without blanks.").unwrap(); + let id = s.open("paper.pdf", None, text, None).unwrap(); + let found = s.detect_fields(id, &[]); + let names: Vec<&str> = found.iter().map(|(_, c)| c.name.as_str()).collect(); + assert_eq!(names, ["Name", "Email address"], "{found:?}"); + let added = s.auto_detect_fields(id, &[]).unwrap(); + assert_eq!(added, ["Name", "Email address"]); + let d = s.get(id).unwrap(); + assert_eq!(d.form.len(), 2); + assert_eq!(d.can_undo(), Some("Detect form fields")); + // The field sits on the blank: right of the label, at its height. + let f = &d.form[0]; + assert!(f.widgets[0].rect[0] > 72.0 + 20.0, "{:?}", f.widgets[0].rect); + 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 { + let content = "BT /F1 14 Tf 30 270 Td (Gewerbe-Ummeldung) Tj ET \ + BT /F1 10 Tf 30 230 Td (Name:) Tj ET 0.75 w 80 228 m 380 228 l S \ + 1 w 30 150 350 50 re S BT /F1 7 Tf 34 191 Td (Anschrift) Tj ET \ + BT /F1 8 Tf 30 124 Td (Telefon) Tj ET 30 90 350 30 re S \ + 30 50 10 10 re S BT /F1 9 Tf 46 51 Td (Haupt) Tj ET 150 50 10 10 re S BT /F1 9 Tf 166 51 Td (Zweig) Tj ET"; + let objs = [ + b"<< /Type /Catalog /Pages 2 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 420 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, obj) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(obj); + 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 offset in offsets { + out.extend_from_slice(format!("{offset: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 +} + +/// How the paper form was scanned. +#[derive(Clone, Copy, Debug)] +enum Scan { + Straight, + /// Stored sideways and turned upright by `/Rotate 90`. + Sideways, + /// Speckled paper, uneven light and lines that lose some pixels. + Noisy, + /// Laid on the scanner a degree askew. + Skewed, +} + +/// The paper form as an image-only PDF at 150 dpi (the page keeps its 420 × 300 pt). +fn scanned_form(scan: Scan) -> Arc> { + 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 png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + let mut img = image::load_from_memory(&png).unwrap().to_rgba8(); + match scan { + Scan::Straight => {} + Scan::Sideways => img = image::imageops::rotate270(&img), + Scan::Skewed => { + // Turned a degree about the centre (nearest pixel; the corners stay paper). + let (w, h) = (img.width() as f64, img.height() as f64); + let (sin, cos) = 1f64.to_radians().sin_cos(); + let src = img.clone(); + for (x, y, p) in img.enumerate_pixels_mut() { + let (dx, dy) = (x as f64 - w / 2.0, y as f64 - h / 2.0); + let (sx, sy) = (cos * dx + sin * dy + w / 2.0, -sin * dx + cos * dy + h / 2.0); + *p = if sx >= 0.0 && sy >= 0.0 && sx < w && sy < h { *src.get_pixel(sx as u32, sy as u32) } else { image::Rgba([255; 4]) }; + } + } + Scan::Noisy => { + let (w, mut seed) = (img.width(), 7u32); + for (x, _, p) in img.enumerate_pixels_mut() { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); + let noise = ((seed >> 16) % 48) as u8; + let shade = (x * 50 / w) as u8; + for c in &mut p.0[..3] { + *c = if *c < 128 && noise < 4 { 230 } else { c.saturating_sub(shade).saturating_sub(noise) }; + } + } + } + } + let mut bytes = std::io::Cursor::new(Vec::new()); + img.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let pdf = s.create_from_images_with_resolution(&[("scan.png".into(), bytes.into_inner())], ImageResolution::Dpi(150.0)).unwrap(); + if !matches!(scan, Scan::Sideways) { + return pdf; + } + let id = s.open("scan.pdf", None, pdf, None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.save_bytes(id).unwrap() +} + +/// Fields on a scanned paper form are found from its lines and boxes, wherever the page turns +/// them, and named from the labels when text recognition is installed. +#[test] +fn detecting_fields_on_a_scanned_form() { + use pdfcraft_forms::detect::Kind::{CheckBox, Text}; + for scan in [Scan::Straight, Scan::Sideways, Scan::Noisy, Scan::Skewed] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("scan.pdf", None, scanned_form(scan), None).unwrap(); + assert_eq!(page_texts(&s, id), [""], "{scan:?}: a picture has no text"); + let info = s.get(id).unwrap().info.pages[0].clone(); + assert_eq!((info.width.round(), info.height.round()), (420.0, 300.0), "{scan:?}"); + // Where a field shows on the page, in points from the bottom-left as displayed. + let shown = |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]), 300.0 - a[1].max(b[1]), a[0].max(b[0]), 300.0 - a[1].min(b[1])].map(f64::from) + }; + let found = s.detect_fields(id, &[]); + let got: Vec<(pdfcraft_forms::detect::Kind, [f64; 4])> = found.iter().map(|(_, c)| (c.kind.clone(), shown(c.rect))).collect(); + // In reading order: the rule after "Name:", the box below "Anschrift", the "Telefon" + // box, two check boxes. + let want = [ + (Text, [80.0, 229.0, 380.0, 245.0]), + (Text, [31.0, 151.0, 379.0, 189.0]), + (Text, [31.0, 91.0, 379.0, 119.0]), + (CheckBox, [30.0, 50.0, 40.0, 60.0]), + (CheckBox, [150.0, 50.0, 160.0, 60.0]), + ]; + assert_eq!(got.len(), want.len(), "{scan:?}: {got:?}"); + for ((kind, r), (want_kind, w)) in got.iter().zip(&want) { + assert_eq!(kind, want_kind, "{scan:?}: {got:?}"); + // The labelled box's top is wherever the label ends; everything else is close. A + // degree askew, a 350 pt line climbs 6 pt, and the field stays inside the box. + let tol = if matches!(scan, Scan::Skewed) { [6.5; 4] } else { [2.5, 2.5, 2.5, if w[3] == 189.0 { 6.0 } else { 2.5 }] }; + assert!(r.iter().zip(w).zip(tol).all(|((a, b), t)| (a - b).abs() <= t), "{scan:?}: {r:?}, want {w:?}"); + } + let names: Vec<&str> = found.iter().map(|(_, c)| c.name.as_str()).collect(); + if ocr::available() { + // (Askew, recognition may read "Haupt" and "Zweig" as one phrase.) + assert!(names.contains(&"Name") && names.contains(&"Telefon") && names.iter().any(|n| n.starts_with("Haupt")), "{scan:?}: {names:?}"); + } else { + // Without text recognition the fields get the default names. + assert!(names.iter().all(|n| n.starts_with("Text") || n.starts_with("Check Box")), "{scan:?}: {names:?}"); + } + + // Added as one step, where they were found; on the turned page each field turns with + // it, so typing reads upright. + let added = s.auto_detect_fields(id, &[]).unwrap(); + assert_eq!(added.len(), want.len()); + let d = s.get(id).unwrap(); + assert_eq!(d.can_undo(), Some("Detect form fields")); + let turn = if matches!(scan, Scan::Sideways) { 90 } else { 0 }; + for (_, c) in &found { + let f = d.form.iter().find(|f| f.name == c.name).unwrap(); + assert_eq!(f.widgets[0].rotation, turn, "{scan:?}: {}", f.name); + let r = f.widgets[0].rect; + assert!(r.iter().zip(c.rect).all(|(a, b)| (a - b).abs() < 0.01), "{scan:?}: {r:?} moved from {:?}", c.rect); + } + assert!(s.detect_fields(id, &[]).is_empty(), "{scan:?}: nothing left to detect"); + } +} + +#[test] +fn detecting_comb_boxes_adds_one_comb_field_per_row() { + // The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`. + let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n"); + for i in 0..5 { + body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18)); + } + for i in 0..10 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap(); + let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect(); + assert_eq!( + found, + [ + ("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }), + ("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 }) + ] + ); + s.auto_detect_fields(id, &[]).unwrap(); + let d = s.get(id).unwrap(); + let combs: Vec<(&str, FormFieldKind, bool, Option)> = + d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect(); + assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]); + assert_eq!(d.can_undo(), Some("Detect form fields")); +} + +#[test] +fn comparing_two_versions_of_a_document() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let v1 = s.create_from_text("t", "The contract starts on Monday.\n\nPayment is due within thirty days.\n\nSigned by both parties.").unwrap(); + let v2 = s + .create_from_text("t", "The contract starts on Tuesday.\n\nPayment is due within thirty days of invoice.\n\nSigned by both parties.") + .unwrap(); + let old = s.open("v1.pdf", None, v1, None).unwrap(); + let new = s.open("v2.pdf", None, v2, None).unwrap(); + let c = s.compare(old, new).unwrap(); + let got: Vec<(compare::Kind, &str, &str)> = c.changes.iter().map(|x| (x.kind, x.old.text.as_str(), x.new.text.as_str())).collect(); + assert_eq!(got, [(compare::Kind::Replaced, "Monday.", "Tuesday."), (compare::Kind::Replaced, "days.", "days of invoice.")], "{got:?}"); + assert!(s.compare(old, old).unwrap().identical()); + + let report = s.compare_report(old, new).unwrap(); + let rid = s.open("report.pdf", None, report, None).unwrap(); + let text = page_texts(&s, rid).join(" "); + assert!(text.contains("2 changes: 2 replaced") && text.contains("Tuesday."), "{text}"); + + // Visually, only the changed words' places differ (and the page is otherwise the same). + let vis = s.compare_visual(old, new, 72.0).unwrap(); + assert!(!vis.is_empty() && vis.iter().all(|(p, r)| *p == 0 && r[3] > 600.0), "{vis:?}"); + assert!(s.compare_visual(old, old, 72.0).unwrap().is_empty()); + + assert_eq!(s.mark_differences(old, new).unwrap(), 2); + let d = s.get(new).unwrap(); + assert_eq!(d.can_undo(), Some("Mark differences")); + let marks: Vec<_> = d.info.annotations.iter().filter(|a| a.author.as_deref() == Some("Compare")).collect(); + assert_eq!(marks.len(), 2); + assert!(marks[0].contents.as_deref().unwrap().starts_with("Replaced: \"Monday.\"")); +} + +#[test] +fn actions_run_their_steps_on_files() { + let s = Session::new(); + let src = s.create_from_text("t", "Quarterly numbers").unwrap(); + let built = actions::builtin(); + let numbers = built.iter().find(|a| a.name == "Add Page Numbers").unwrap(); + let r = actions::run_on(numbers, "q.pdf", src.clone(), |_, _| {}).unwrap(); + let mut s2 = Session::new(); + let id = s2.open("out.pdf", None, r.bytes, None).unwrap(); + assert!(page_texts(&s2, id)[0].contains("Page 1 of 1"), "{:?}", page_texts(&s2, id)); + assert_eq!(r.log, ["Add footer"]); + + let custom = actions::Action { + name: "Mine".into(), + description: String::new(), + steps: vec![ + actions::Step::from_id("set_title", "Q3").unwrap(), + actions::Step::AddWatermark("DRAFT".into()), + actions::Step::ReduceFileSize, + actions::Step::FlattenComments, + ], + builtin: false, + }; + let mut steps = Vec::new(); + let r = actions::run_on(&custom, "q.pdf", src, |i, n| steps.push((i, n))).unwrap(); + assert_eq!(steps, [(0, 4), (1, 4), (2, 4), (3, 4)]); + assert_eq!(r.log.len(), 4, "{:?}", r.log); + let id = s2.open("out2.pdf", None, r.bytes, None).unwrap(); + let d = s2.get(id).unwrap(); + assert_eq!(d.info.title.as_deref(), Some("Q3")); + assert!(page_texts(&s2, id)[0].contains("DRAFT")); + 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(); + let text = s.create_from_text("t", "Picture page").unwrap(); + let id = s.open("t.pdf", None, text, None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 20.0).unwrap(); + let pic = s.create_from_images(&[("p.png".into(), png)]).unwrap(); + let pid = s.open("p.pdf", None, pic, None).unwrap(); + let d = s.get(pid).unwrap(); + assert_eq!(d.export_pages()[0].images.len(), 1); + let docx = d.export_office(compare::OfficeFormat::Docx); + assert!(docx.windows(16).any(|w| w == b"word/media/image")); + let pptx = d.export_office(compare::OfficeFormat::Pptx); + assert!(pptx.windows(15).any(|w| w == b"ppt/media/image")); + if let Ok(dir) = std::env::var("PDFCRAFT_EXPORT_DIR") { + std::fs::write(format!("{dir}/pic.docx"), &docx).unwrap(); + } + assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("data:image/png;base64,")); +} + +/// #314: a page whose content is drawn through a form XObject exports its text (the file from +/// the report: no xref table, wrong stream lengths). +#[test] +fn exporting_office_files_reads_form_xobjects() { + let pdf = b"%PDF-1.4 +1 0 obj <
> endobj +2 0 obj <> endobj +3 0 obj <>>>/Contents 5 0 R>> endobj +4 0 obj <> endobj +5 0 obj <> stream +q 1 0 0 1 0 0 cm /Fm1 Do Q +endstream endobj +7 0 obj <>>>/Length 60>> stream +BT /F1 18 Tf 72 760 Td (Hello from a test invoice) Tj ET +endstream endobj +trailer <> +%%EOF +"; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf.to_vec()), None).unwrap(); + let d = s.get(id).unwrap(); + let blocks: Vec = d.export_pages()[0].blocks.iter().map(|b| b.text.clone()).collect(); + assert_eq!(blocks, ["Hello from a test invoice"]); + assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("Hello from a test invoice")); + // Edit text still offers only what the page's own streams show. + assert!(d.text_blocks(0).is_empty()); +} + +#[test] +fn exporting_office_files_keeps_text_colour() { + // #526: a dark green heading came out black in Word. + let content = "BT /F1 24 Tf 0.05 0.23 0.18 rg 72 700 Td (Annual Report) Tj ET \ +BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body size.) 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 >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".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()); + let mut s = Session::new(); + let id = s.open("c.pdf", None, Arc::new(pdf), None).unwrap(); + let d = s.get(id).unwrap(); + let html = String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap(); + assert!(html.contains("style=\"color:#0D3B2E\">Annual Report"), "{html}"); + let pptx = d.export_office(compare::OfficeFormat::Pptx); + assert!(pptx.starts_with(b"PK")); + assert!(pptx.windows(b"ppt/slides/slide1.xml".len()).any(|w| w == b"ppt/slides/slide1.xml")); + let blocks = &d.export_pages()[0].blocks; + assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}"); +} + +#[test] +fn office_export_positions_follow_the_displayed_crop_rotation_and_units() { + use pdfcraft_cos::{Document as CosDocument, ObjRef, Object, SaveOptions, write_full}; + let mut pdf = CosDocument::open(Arc::new(fixture(1))).unwrap(); + let mut page = pdf.get(ObjRef::new(4, 0)).as_dict().unwrap().clone(); + page.set("CropBox", Object::Array([10, 20, 190, 280].map(Object::Int).to_vec())); + page.set("Rotate", Object::Int(90)); + page.set("UserUnit", Object::Int(2)); + pdf.set(ObjRef::new(4, 0), page); + let mut session = Session::new(); + let id = session.open("turned.pdf", None, Arc::new(write_full(&pdf, &SaveOptions::default()).unwrap()), None).unwrap(); + let doc = session.get(id).unwrap(); + let pages = doc.export_pages(); + let b = &pages[0].blocks[0]; + assert!((b.rotation + 90.0).abs() < 0.01, "clockwise page rotation: {b:?}"); + assert!((b.rect[0] - 248.0).abs() < 0.01, "crop and UserUnit applied: {b:?}"); + assert_eq!(b.size, 48.0); + assert_eq!((pages[0].width, pages[0].height), (520.0, 360.0)); +} + +#[test] +fn guard_turns_a_panic_into_an_error() { + assert_eq!(guard(|| 7), Ok(7)); + assert_eq!(guard(|| -> u8 { panic!("boom") }), Err("boom".to_string())); + let n = 3; + assert_eq!(guard(|| -> u8 { panic!("page {n} is bad") }), Err("page 3 is bad".to_string())); +} + +#[test] +fn measurement_edits_are_transactional_and_honour_annotation_permissions() { + let mut session = Session::default(); + let id = session.open("plan.pdf", None, Arc::new(fixture(1)), None).unwrap(); + let m = measure::NewMeasurement { + page: 0, + kind: measure::Kind::Distance, + points: vec![[10.0, 20.0], [40.0, 60.0]], + scale: measure::Scale::new(0.1, "m", 2).unwrap(), + style: Style::default(), + label: "Wall".into(), + author: "Tester".into(), + }; + session.apply(id, Edit::AddMeasurement(m.clone())).unwrap(); + let before = session.get(id).unwrap().bytes.clone(); + assert_eq!(session.get(id).unwrap().measurements().unwrap().measurements[0].reading.label, "5.00 m"); + let mut invalid = m.clone(); + invalid.points[1][0] = f64::INFINITY; + assert!(session.apply(id, Edit::AddMeasurement(invalid)).is_err()); + assert_eq!(session.get(id).unwrap().bytes, before); + session.undo(id).unwrap(); + assert!(session.get(id).unwrap().measurements().unwrap().measurements.is_empty()); + session.redo(id).unwrap(); + assert_eq!(session.get(id).unwrap().bytes, before); + let p = pdfcraft_cos::Permissions { bits: 0, owner: false }; + assert_eq!(check_permission(&Edit::AddMeasurement(m), &p), Err(EditError::NotPermitted("comments"))); + assert_eq!( + check_permission( + &Edit::SetMeasurementScale { page: 0, bbox: [0.0, 0.0, 100.0, 100.0], name: "Detail".into(), scale: measure::Scale::default() }, + &p + ), + Err(EditError::NotPermitted("comments")) + ); +} + +/// 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))); + 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 x = doc.xfa.as_ref().expect("laid out from the template"); + assert_eq!((x.pages, x.fields), (2, 14), "{x:?}"); + assert!(x.warnings.is_empty(), "{:?}", x.warnings); + assert_eq!(doc.info.pages.len(), 2, "the placeholder page is gone"); + assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Dynamic), "still an XFA form for Adobe's viewers"); + assert!(!doc.dirty, "laying out is not an edit"); + assert!(doc.bytes.starts_with(&original[..]), "the layout is an appended revision; the original bytes stay"); + let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect(); + assert!(names.contains(&"familyName") && names.contains(&"answer") && names.contains(&"what2"), "{names:?}"); + let family = doc.form.iter().find(|f| f.name == "familyName").unwrap(); + assert_eq!((family.tooltip.as_deref(), family.max_len), (Some("Your family name"), Some(30))); + let answer = doc.form.iter().find(|f| f.name == "answer").unwrap(); + assert_eq!(answer.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Y", "N"]); + assert!(page_texts(&s, id)[0].contains("Sample Form"), "{:?}", page_texts(&s, id)); + // Fill it in and save: one more incremental revision on top. + s.apply(id, Edit::SetFieldValue { name: "familyName".into(), value: FieldValue::Text("Singh".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "answer".into(), value: FieldValue::Radio(Some("N".into())) }).unwrap(); + assert!(s.get(id).unwrap().dirty); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(&original[..])); + // The datasets packet carries the values too, for Adobe's viewers. + let cos = pdfcraft_cos::Document::open(saved.clone()).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].head[0].familyName[0]")), Some("Singh")); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].head[0].answer[0]")), Some("N")); + let id2 = s.open("saved.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + assert_eq!(d2.info.pages.len(), 2, "a saved form is not laid out a second time"); + assert_eq!(d2.xfa.as_ref().map(|x| (x.pages, x.fields)), Some((2, 14)), "the layout is remembered"); + assert_eq!(d2.form.iter().find(|f| f.name == "familyName").unwrap().value, vec!["Singh".to_string()]); + assert_eq!(d2.form.iter().find(|f| f.name == "answer").unwrap().value, vec!["N".to_string()]); +} + +#[test] +fn a_signed_xfa_signature_box_shows_the_picture_the_file_holds() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("signed.pdf", None, Arc::new(pdfcraft_xfa::fixtures::signed_shell()), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.xfa.as_ref().map(|x| (x.pages, x.fields)), Some((1, 2))); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let at = |x: u32, y: u32| -> [u8; 4] { + let i = ((y * out.width + x) * 4) as usize; + out.rgba[i..i + 4].try_into().unwrap() + }; + // The signed box (3in by 1in from half an inch in) is filled with the signature's blue + // picture; the unsigned one below it is not. + assert_eq!(at(36 + 108, 36 + 36), [0, 0, 255, 255]); + assert_ne!(at(36 + 108, 36 + 72 + 36), [0, 0, 255, 255]); +} + +#[test] +fn an_xfa_form_without_a_template_opens_with_its_placeholder_and_a_warning() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell("")); + let mut s = Session::new(); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + let doc = s.get(id).unwrap(); + assert!(doc.xfa.is_none()); + assert_eq!(doc.info.pages.len(), 1); + assert!(doc.info.warnings.iter().any(|w| w.contains("could not be laid out") && w.contains("no template")), "{:?}", doc.info.warnings); +} + +#[test] +fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() { + let data = "
Kaur2001-02-03Yabc
"; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template_with_data(1, data))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + let doc = s.get(id).unwrap(); + let value = |n: &str| doc.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone(); + assert_eq!(value("familyName"), vec!["Kaur".to_string()]); + assert_eq!(value("born"), vec!["2001-02-03".to_string()]); + assert_eq!(value("answer"), vec!["Y".to_string()]); + assert_eq!(value("what0_3"), vec!["c".to_string()], "three rows from the data: {:?}", doc.form.iter().map(|f| &f.name).collect::>()); + 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")); + let mut s = Session::new().with_clock(|| 1_700_000_000); + 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.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Ada".to_string()]); + assert_eq!(doc.form.iter().find(|f| f.name == "form1[0].page1[0].agree[0]").unwrap().value, vec!["1".to_string()]); + assert!(!doc.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision"); + // Rewriting the fields from the XFA data is recorded, not silent. + let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning"); + assert!(note.starts_with("2 form field values") && note.contains("form1[0].page1[0].name[0]"), "{note}"); + s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("Grace".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].agree[0]".into(), value: FieldValue::Check(false) }).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("form1[0].page1[0].name[0]")), Some("Grace")); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")), Some(""), "unchecked, with no off value"); + // Reopening agrees with itself: nothing to apply, nothing dirty. + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + assert_eq!(d2.form.iter().find(|f| f.name == "form1[0].page1[0].name[0]").unwrap().value, vec!["Grace".to_string()]); + assert!(!d2.dirty); + 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. + let data = concat!( + "Ada1keep", + "2020unbound" + ); + let original = Arc::new(pdfcraft_xfa::fixtures::static_shell(data)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("static.pdf", None, original, None).expect("opens"); + s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("Grace".into()) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let xdp = pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp; + for kept in [ + "keep", + "2020", + "unbound", + ] { + assert!(xdp.contains(kept), "lost {kept:?}: {xdp}"); + } + let data = pdfcraft_xfa::parse_datasets(&xdp).unwrap(); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Grace")); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")), Some("1")); + // A value that can't be written is reported on the document. + let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("rich")); + let id = s.open("rich.pdf", None, original, None).expect("opens"); + s.apply(id, Edit::SetFieldValue { name: "form1[0].page1[0].name[0]".into(), value: FieldValue::Text("plain".into()) }).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.info.warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.info.warnings); + 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
"; + 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 field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}")); + use pdfcraft_forms::FieldKind as K; + // The forms layer sees a combo box with its options and the data's saved value, a + // multi-select list with both picks, an editable combo, a password field and a signature. + let country = field(&s, "country"); + assert_eq!((country.kind, country.value.clone()), (K::Combo, vec!["JP".to_string()])); + assert_eq!(country.options, vec![("CA".into(), "Canada".into()), ("FR".into(), "France".into()), ("JP".into(), "Japan".into())]); + let langs = field(&s, "langs"); + assert_eq!(langs.kind, K::List); + assert!(langs.has(pdfcraft_forms::flags::MULTI_SELECT)); + 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)); + // 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 + // in the editable list and refused in the fixed one. + s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap(); + assert_eq!(field(&s, "summary").value, vec!["Country: CA".to_string()]); + s.apply(id, Edit::SetFieldValue { name: "langs".into(), value: FieldValue::Choice(vec!["French".into(), "Spanish".into()]) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed".into()) }).unwrap(); + assert!(s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["Mars".into()]) }).is_err()); + // Saved: the datasets hold the saved values, several on separate lines; reopened, the + // fields and the calculation are what they were. + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].country[0]")), Some("CA")); + assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].langs[0]")), Some("French\nSpanish")); + assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].other[0]")), Some("typed")); + let id2 = s.open("again.pdf", None, saved.clone(), None).unwrap(); + let d2 = s.get(id2).unwrap(); + let f2 = |n: &str| d2.form.iter().find(|f| f.name == n).unwrap().value.clone(); + assert_eq!(f2("country"), vec!["CA".to_string()]); + assert_eq!(f2("langs"), vec!["French".to_string(), "Spanish".to_string()]); + assert_eq!(f2("summary"), vec!["Country: CA".to_string()]); + // The PNG of the image field and the GIF of the draw are pictures on the page. + assert_eq!(s.get(id).unwrap().page_images(0).len(), 2); + // Filled in by another viewer since: saved values, several on separate lines, and shown + // text (an older viewer's), are taken on reopen; shown text that means the current value + // is not a change. + let mut cos = pdfcraft_cos::Document::open(saved).unwrap(); + pdfcraft_xfa::write_data_value(&mut cos, &pdfcraft_xfa::som_to_path("form[0].page1[0].langs[0]"), "English\nSpanish").unwrap(); + pdfcraft_xfa::write_data_value(&mut cos, &pdfcraft_xfa::som_to_path("form[0].page1[0].country[0]"), "Canada").unwrap(); + let edited = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap()); + let id3 = s.open("elsewhere.pdf", None, edited, None).unwrap(); + let d3 = s.get(id3).unwrap(); + let f3 = |n: &str| d3.form.iter().find(|f| f.name == n).unwrap().value.clone(); + assert_eq!(f3("langs"), vec!["English".to_string(), "Spanish".to_string()]); + assert_eq!(f3("country"), vec!["CA".to_string()]); + let taken: Vec<&String> = d3.info.warnings.iter().filter(|w| w.contains("another viewer")).collect(); + assert_eq!(taken.len(), 1, "{:?}", d3.info.warnings); + 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; + let data = "
JPEnglish\nSpanish
"; + 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 field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}")); + // Export: XML is the datasets document, XDP wraps it; the fields' current values are in it. + s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap(); + let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap(); + assert!(xml.starts_with("CA") && !xml.contains("CA") && xdp.contains("href=\"fields.pdf\""), "{xdp}"); + // Import: the data file's values replace the form's; the calculation runs; undo brings + // the old values back. + let file = "
FRFrench1234
"; + s.apply(id, Edit::ImportData { name: "data.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); + assert_eq!(field(&s, "country").value, vec!["FR".to_string()]); + assert_eq!(field(&s, "langs").value, vec!["French".to_string()]); + assert_eq!(field(&s, "pin").value, vec!["1234".to_string()]); + assert_eq!(field(&s, "summary").value, vec!["Country: FR".to_string()], "{:?}", s.take_js_output(id)); + s.undo(id).unwrap(); + assert_eq!(field(&s, "country").value, vec!["CA".to_string()]); + s.redo(id).unwrap(); + assert_eq!(field(&s, "country").value, vec!["FR".to_string()]); + // A file in another encoding, and one with a long comment before its root, import too. + let latin1: Vec = + "
Société
".chars().map(|c| c as u32 as u8).collect(); + s.apply(id, Edit::ImportData { name: "l1.xml".into(), bytes: Arc::new(latin1) }).unwrap(); + assert_eq!(field(&s, "other").value, vec!["Société".to_string()]); + let commented = format!("
after
", "x".repeat(5000)); + s.apply(id, Edit::ImportData { name: "c.xml".into(), bytes: Arc::new(commented.into_bytes()) }).unwrap(); + assert_eq!(field(&s, "other").value, vec!["after".to_string()]); + let utf16: Vec = "
wide
".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + s.apply(id, Edit::ImportData { name: "w.xml".into(), bytes: Arc::new([&[0xFF, 0xFE][..], &utf16].concat()) }).unwrap(); + assert_eq!(field(&s, "other").value, vec!["wide".to_string()]); + let shift_jis = b"
".to_vec(); + assert!(s.apply(id, Edit::ImportData { name: "sj.xml".into(), bytes: Arc::new(shift_jis) }).is_err()); + // A data file for another form is refused. + assert!(s.apply(id, Edit::ImportData { name: "o.xml".into(), bytes: Arc::new(b"1".to_vec()) }).is_err()); + assert_eq!(field(&s, "other").value, vec!["wide".to_string()]); + // XFDF still goes the usual way on an XFA form. + let xfdf = + "9999"; + s.apply(id, Edit::ImportData { name: "d.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap(); + assert_eq!(field(&s, "pin").value, vec!["9999".to_string()]); + assert_eq!(field(&s, "country").value, vec!["FR".to_string()]); + // A picture chosen for the image field goes into the data and onto the page. + let png = pdfcraft_xfa::fixtures::tiny_png(); + assert_eq!(field(&s, "photo").button, Some(pdfcraft_forms::af::ButtonAction::ImportIcon), "clicking it picks a picture"); + s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + assert!(d.text_at(&som_to_path("form[0].page1[0].photo[0]")).unwrap().starts_with("R0lGODlh")); + 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. + let before = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap().object_numbers().len(); + s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "pin".into(), value: FieldValue::Text("1".into()) }).unwrap(); + let after = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let datasets = after + .object_numbers() + .into_iter() + .filter(|n| matches!(&*after.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|b| b.starts_with(b"Ada1")); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("static.pdf", None, original, None).expect("opens"); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name.ends_with(n)).map(|f| f.value.clone()).unwrap_or_default(); + assert_eq!(value(&s, "name[0]"), vec!["Ada".to_string()]); + assert_eq!(value(&s, "agree[0]"), vec!["1".to_string()]); + // The file names only `name`: `agree` is emptied too, since the data was replaced. + s.apply(id, Edit::ImportData { name: "d.xml".into(), bytes: Arc::new(b"Bob".to_vec()) }).unwrap(); + assert_eq!(value(&s, "name[0]"), vec!["Bob".to_string()]); + assert!(value(&s, "agree[0]").is_empty(), "{:?}", value(&s, "agree[0]")); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].name[0]")), Some("Bob")); + assert!(d.text_at(&pdfcraft_xfa::som_to_path("form1[0].page1[0].agree[0]")).is_none_or(str::is_empty)); + let xml = String::from_utf8(s.export_data(id, DataFormat::Xml, false, true).unwrap()).unwrap(); + 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())); + // 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(); + assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1); + let file = "3a10b32c0.5
"; + s.apply(id, Edit::ImportData { name: "rows.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); + assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 3, "{:?}", names(&s)); + assert_eq!(value(&s, "total"), vec!["15".to_string()], "qty 3 × price 5, calculated after the import: {:?}", s.take_js_output(id)); + assert_eq!(value(&s, "grand"), vec!["42.5".to_string()]); + // More rows than the layout shows: the document says so. + let rows: String = (0..60).map(|i| format!("r{i}1")).collect(); + let file = format!("
1{rows}
"); + s.apply(id, Edit::ImportData { name: "many.xml".into(), bytes: Arc::new(file.into_bytes()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("60 rows")), "{out:?}"); + // A file with one row takes the others away. + let file = "
1only7
"; + s.apply(id, Edit::ImportData { name: "one.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); + assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 1, "{:?}", names(&s)); + assert_eq!(value(&s, "grand"), vec!["7".to_string()]); + // Not an XFA data file on an XFA form: refused with the usual message, nothing changed. + assert!(s.apply(id, Edit::ImportData { name: "junk.xml".into(), bytes: Arc::new(b"".to_vec()) }).is_err()); + assert_eq!(value(&s, "grand"), vec!["7".to_string()]); + // The automation tool's view: export then import through a file round-trips the rows. + let out = s.export_data(id, DataFormat::Xdp, false, true).unwrap(); + 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() { + 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).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; + let totals: Vec<&pdfcraft_forms::Field> = form.iter().filter(|f| f.name.starts_with("total")).collect(); + assert_eq!(totals.len(), sections * rows); + assert!(totals.iter().all(|f| !f.value.is_empty()), "every row's total was calculated"); + let sums: Vec<&pdfcraft_forms::Field> = form.iter().filter(|f| f.name.starts_with("sum")).collect(); + assert_eq!(sums.len(), sections); + // 2 × (1 + 2 + … + rows) + 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, 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()]); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "sum").unwrap().value, vec![(rows * (rows + 1) - 2 + 200).to_string()]); + 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 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 + + + + + "#, + ); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("iso.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(); + let out = s.take_js_output(id); + assert!(out.errors.iter().any(|e| e.contains("boom")), "{out:?}"); + 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())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("scripted.pdf", None, bytes, None).expect("opens"); + let add_row = s.get(id).unwrap().form.iter().find(|f| f.name == "addRow").expect("the XFA button is in the form snapshot"); + assert!(matches!(add_row.button.as_ref(), Some(pdfcraft_forms::af::ButtonAction::Script(_)))); + let value = |s: &Session, n: &str| { + s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_else(|| panic!("no field {n}")) + }; + let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + // On open: the initialize script set qty, the calculations ran, nothing is dirty. + assert_eq!(value(&s, "qty"), vec!["2".to_string()]); + assert_eq!(value(&s, "total"), vec!["10".to_string()], "{:?}", s.take_js_output(id)); + assert_eq!(value(&s, "grand"), vec!["0".to_string()]); + assert!(!s.get(id).unwrap().xfa_scripts_off()); + assert!(!s.get(id).unwrap().dirty); + assert!(!names(&s).iter().any(|n| n == "note"), "details is hidden"); + // A change recalculates. + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap(); + assert_eq!(value(&s, "total"), vec!["15".to_string()], "{:?}", s.take_js_output(id)); + // 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.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(); + let o = s.run_javascript(id, "", Some("addRow")).unwrap(); + assert_eq!(o.error, None); + let after = names(&s); + assert_eq!(after.len(), before + 2, "{after:?}"); + assert!(after.iter().any(|n| n == "amount_2")); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run form script")); + s.apply(id, Edit::SetFieldValue { name: "amount".into(), value: FieldValue::Text("10".into()) }).unwrap(); + 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)); + // Message boxes reach the viewer. + let o = s.run_javascript(id, "", Some("hello")).unwrap(); + 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)); + s.undo(id).unwrap(); + assert!(!names(&s).iter().any(|n| n == "note")); + s.redo(id).unwrap(); + assert!(names(&s).iter().any(|n| n == "note")); + // Removing the row takes its amount out of the sum. + s.run_javascript(id, "", Some("removeRow")).unwrap(); + assert!(!names(&s).iter().any(|n| n == "amount_2")); + assert_eq!(value(&s, "grand"), vec!["10".to_string()]); + // FormCalc scripts run too. + let o = s.run_javascript(id, "", Some("legacy")).unwrap(); + assert_eq!(o.error, None); + 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(); + let d2 = s.get(id2).unwrap(); + let n2: Vec<&str> = d2.form.iter().map(|f| f.name.as_str()).collect(); + assert!(n2.contains(&"note") && !n2.contains(&"amount_2"), "{n2:?}"); + assert_eq!(d2.form.iter().find(|f| f.name == "grand").unwrap().value, vec!["10".to_string()]); + assert_eq!(d2.form.iter().find(|f| f.name == "qty").unwrap().value, vec!["500".to_string()]); + assert!(!d2.dirty); +} + +/// A repeating table inside an area of a positioned page (as in real Designer forms): every +/// row is laid out, the values Adobe wrote (under the area's parent: areas are +/// not data scopes) are read, a FormCalc total over all rows computes, and what is filled here +/// is written back where Adobe's viewers read it, with no element for the area. +#[test] +fn xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them() { + let data = "
Regular4
5
"; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::leave_template(data))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("leave.pdf", None, bytes, None).expect("opens"); + let value = |s: &Session, n: &str| { + s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.first().cloned().unwrap_or_default() + }; + let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + // Three rows (occur min="3"), not one. + assert_eq!(names(&s).iter().filter(|n| n.starts_with("reason")).count(), 3, "{:?}", names(&s)); + // Adobe's data, bound past the area. + assert_eq!((value(&s, "reason"), value(&s, "days"), value(&s, "carried")), ("Regular".into(), "4".into(), "5".into())); + // The total walks rows 0 to 2: 5 + 30 - 4. + assert_eq!(value(&s, "rest"), "31", "{:?}", s.take_js_output(id)); + // A second regular row recalculates it. + s.apply(id, Edit::SetFieldValue { name: "reason_2".into(), value: FieldValue::Text("Regular".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "days_2".into(), value: FieldValue::Text("2".into()) }).unwrap(); + assert_eq!(value(&s, "rest"), "29", "{:?}", s.take_js_output(id)); + assert!(s.take_js_output(id).errors.is_empty()); + // Written where Adobe reads it: no element for the area, the rows under the table. + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + let path = |som: &str| pdfcraft_xfa::som_to_path(som); + assert_eq!(d.count(&path("form[0].page[0]"), "box"), 0, "an element for the area was written"); + assert_eq!(d.count(&path("form[0].page[0].table[0]"), "row"), 3); + assert_eq!(d.text_at(&path("form[0].page[0].table[0].row[1].days[0]")), Some("2")); + assert_eq!(d.text_at(&path("form[0].page[0].rest[0]")), Some("29")); + // Reopened, the values come back from that data. + let mut s2 = Session::new().with_clock(|| 1_700_000_000); + let id2 = s2.open("again.pdf", None, s.get(id).unwrap().bytes.clone(), None).expect("reopens"); + let v2 = |n: &str| s2.get(id2).unwrap().form.iter().find(|f| f.name == n).unwrap().value.first().cloned().unwrap_or_default(); + assert_eq!((v2("days_2"), v2("rest")), ("2".into(), "29".into())); +} + +#[test] +fn a_value_a_script_sets_reaches_every_field_bound_to_the_same_data_node() { + // `carried` inside the area and a second `carried` outside it bind to one data node (areas + // are not data scopes). In document order: `setter` writes the inner one, `echo` reads the + // outer one in the same event, and the saved data holds the one value. + let tpl = pdfcraft_xfa::fixtures::leave_template("
5
") + .replace( + r#" "#, + r#" + "#, + ) + .replace( + " \n \n", + r#" + + + +"#, + ); + assert!(tpl.contains("name=\"echo\"") && tpl.contains("name=\"setter\""), "the fixture was reshaped"); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("shared.pdf", None, bytes, None).expect("opens"); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{out:?}"); + let values = |s: &Session, id: DocId, n: &str| { + s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with(n)).map(|f| f.value.join("")).collect::>() + }; + assert_eq!(values(&s, id, "carried"), vec!["9".to_string(), "9".to_string()], "both fields show the node's value"); + assert_eq!(values(&s, id, "echo"), vec!["9".to_string()], "read in the same event the setter ran in"); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let d = pdfcraft_xfa::data_of(&cos).unwrap(); + assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].carried[0]")), Some("9")); + assert_eq!(d.count(&pdfcraft_xfa::som_to_path("form[0].page[0]"), "carried"), 1, "one data node"); + // The inner field hidden (no widget): the outer widget still takes the node's value, and + // an unrelated edit does not write the old value back over it. + let hidden = Arc::new(pdfcraft_xfa::fixtures::shell( + &tpl.replace(r#""#, + r#"90"#, + ))); + let id = s.open("check.pdf", None, checkbox, None).expect("opens"); + let carried: Vec<(pdfcraft_forms::FieldKind, Vec)> = + s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("carried")).map(|f| (f.kind, f.value.clone())).collect(); + assert_eq!( + carried, + vec![(pdfcraft_forms::FieldKind::CheckBox, vec!["9".to_string()]), (pdfcraft_forms::FieldKind::Text, vec!["9".to_string()])], + "{carried:?}" + ); +} + +#[test] +fn flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out() { + let data = "
JPEnglish\nSpanish
"; + 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"); + assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); + s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed here".into()) }).unwrap(); + s.apply(id, Edit::Flatten { comments: false, fields: true }).unwrap(); + let d = s.get(id).unwrap(); + assert!(d.form.is_empty(), "no fields remain"); + assert_eq!(d.info.xfa, None, "no longer an XFA form: Adobe's viewers show the baked pages"); + assert!(d.xfa.is_none()); + let texts = page_texts(&s, id).join("\n"); + for want in ["Japan", "typed here", "Country: JP", "12345"] { + assert!(texts.contains(want), "{want} is page content now: {texts}"); + } + // Saved and reopened: a plain PDF, the same page content, nothing of the XFA left. + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(pdfcraft_xfa::read_packets(&cos).unwrap().is_none()); + let id2 = s.open("flat.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + assert_eq!((d2.info.xfa, d2.form.len()), (None, 0)); + assert!(page_texts(&s, id2).join("\n").contains("typed here")); + // Undone, the form is a laid-out XFA form again. + s.undo(id).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); + assert_eq!(d.form.iter().find(|f| f.name == "other").unwrap().value, vec!["typed here".to_string()]); + // Flattening comments alone leaves the XFA form as it is. + s.apply(id, Edit::Flatten { comments: true, fields: false }).unwrap(); + assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); +} + +#[test] +fn a_dynamic_xfa_form_that_was_never_laid_out_keeps_its_xfa_and_the_user_is_told_what_went() { + // A template that can't be read: the form keeps its placeholder page and its XFA. + let broken = "